Compare commits

..

107 Commits

Author SHA1 Message Date
Tiago Ribeiro
fec3b51553 Created two new permissions 2025-02-17 10:32:57 +00:00
João Correia
d8386bdd8e Merged in approval-workflows (pull request #152)
Approval workflows

Approved-by: Tiago Ribeiro
2025-02-11 12:09:17 +00:00
Joao Correia
df2f83e496 make access confidential when user submits exam with approval process. make access private upon approval workflow completed. 2025-02-10 13:25:11 +00:00
Joao Correia
e214d8b598 improve edited exam changes again 2025-02-10 11:30:24 +00:00
Joao Correia
c14f16c97a improve edited exam changes printing format 2025-02-09 21:12:29 +00:00
Joao Correia
ca2cf739ee improve edited exam changes printing format 2025-02-09 20:56:55 +00:00
João Correia
d432fb4bc4 Merged in approval-workflows (pull request #151)
Approval workflows

Approved-by: Tiago Ribeiro
2025-02-09 18:44:44 +00:00
Joao Correia
d5bffc9bad Add pagination to approval workflows table and change module styling to match project colors. 2025-02-09 18:10:59 +00:00
Joao Correia
75b4643918 Add button to submit exam without approval process 2025-02-09 17:37:19 +00:00
Joao Correia
9ae6b8e894 make sure admin id is passed to step component if the admin is not assigned to the workflow but approved a step. 2025-02-09 16:55:50 +00:00
Joao Correia
6f6c5a4209 make first step approved by default 2025-02-09 15:44:37 +00:00
Tiago Ribeiro
769b1b39d3 Added the permission 2025-02-09 11:35:52 +00:00
Francisco Lima
4bb12c7f01 Merged in addedAccess-bugfixes (pull request #150)
AddedAccess bugfixes

Approved-by: Tiago Ribeiro
2025-02-09 11:16:24 +00:00
Francisco Lima
a80a342ae2 Merged develop into addedAccess-bugfixes 2025-02-09 04:32:42 +00:00
José Lima
e5e60fcce9 fixed some issues related to build 2025-02-09 04:29:32 +00:00
José Lima
b175d8797e added access variable to exams soo we can distinguish private, public and confidential exams and also bugfixes and improvements 2025-02-09 04:28:34 +00:00
João Correia
f06349e350 Merged in approval-workflows (pull request #149)
filter workflows user can see based on entities

Approved-by: Tiago Ribeiro
2025-02-08 19:35:46 +00:00
Tiago Ribeiro
34caf9986c Merged develop into approval-workflows 2025-02-08 19:35:31 +00:00
Joao Correia
3a3d3d014d filter workflows user can see based on entities 2025-02-08 19:23:42 +00:00
João Correia
c49c303f20 Merged in approval-workflows (pull request #148)
temporary fix for same exam instance being used for all entities and implement approval process skip edge cases

Approved-by: Tiago Ribeiro
2025-02-08 18:03:43 +00:00
Joao Correia
cbe353c2c5 - start only one of the configured workflows (randomly at the moment) for the exam Author.
- skip approval process for admins
2025-02-08 15:26:16 +00:00
Tiago Ribeiro
991adede96 Merged in develop (pull request #147)
Develop
2025-02-07 17:54:57 +00:00
Tiago Ribeiro
f95bce6fa2 Did some fixes related to master corporates 2025-02-07 16:19:47 +00:00
João Correia
1dd6cead9e Merged in workflow-permissions (pull request #146)
Workflow permissions

Approved-by: Tiago Ribeiro
2025-02-07 15:43:34 +00:00
Joao Correia
5a72ebaea1 Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into workflow-permissions 2025-02-07 13:06:59 +00:00
Joao Correia
00d2a7c2ad forgot permissions on [id] view 2025-02-07 12:57:26 +00:00
João Correia
a6e122e82d Merged in approval-workflows (pull request #145)
Fix bug where workflows were being created again after exam edit - and other improvements.

Approved-by: Tiago Ribeiro
2025-02-07 07:41:25 +00:00
Joao Correia
bf2aa29b98 implement workflow permissions 2025-02-06 23:26:21 +00:00
Joao Correia
cf12a4ed4f implement logging of exam edits on workflow's current step 2025-02-06 19:12:18 +00:00
Tiago Ribeiro
8711802b97 Merged develop into approval-workflows 2025-02-06 18:50:07 +00:00
Tiago Ribeiro
36afde8aa4 Created the new permissions as an example 2025-02-06 18:48:31 +00:00
Joao Correia
752881df41 - Fix bug where workflows were being created again after exam update
- Moved createWorkflows function into an helper file instead of a post request.
- Moved the workflow creation logic into the post of exam creation instead of a seperate post in each exam module
2025-02-06 13:16:32 +00:00
Tiago Ribeiro
63604b68e2 Added the permission to update the privacy of an exam 2025-02-06 12:12:34 +00:00
Tiago Ribeiro
d74aa39076 Merge branches 'main' and 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2025-02-06 11:52:16 +00:00
Joao Correia
c3849518fb amend last commit 2025-02-05 19:14:18 +00:00
Joao Correia
7fb5e1a62b fix typo and bug on exam edit. It was throwing an exception if it found an id with the same owners, but should throw when the owners are different. It was also throwing an error if owners was not set in exam. 2025-02-05 18:55:31 +00:00
Joao Correia
4b405297f2 Merge branch 'approval-workflows' into develop 2025-02-05 17:26:58 +00:00
Joao Correia
f0849b9b42 - fix assignees bug after editing active workflow
- only allow corporate+ to configure workflows
- give admins and devs permissions to approve and reject steps even when they are not assigned to them.
- small fixes
2025-02-05 16:50:09 +00:00
Joao Correia
845a5aa9dc fix stale state behaviour 2025-02-05 13:34:47 +00:00
Joao Correia
d48c7b0d03 implement clone in new builder and fix typo 2025-02-05 13:03:42 +00:00
Joao Correia
6692c201e4 instanciate all workflows configured for an exam author based on different entities. 2025-02-05 12:37:53 +00:00
Joao Correia
f4c7961caa implement edit active workflow and do not allow editing on already completed steps 2025-02-05 00:43:49 +00:00
Joao Correia
b215885dc6 - Make isDiagnostic false when all steps of the exam workflow have been approved.
- Implement Load Exam and Edit Exam buttons
2025-02-04 23:22:56 +00:00
Joao Correia
de15eb5ee1 implement initialization of approval workflows on exam creation. 2025-02-04 22:04:58 +00:00
Joao Correia
d3385caaf8 use custom hook to render approval workflows list instead of reloading full page. 2025-02-03 12:52:03 +00:00
Joao Correia
19f2193414 use custom hook to rerender workflow instead of reloading full page. 2025-02-03 12:31:21 +00:00
Joao Correia
d59b654ac2 do not allow empty steps in workflows 2025-02-03 11:34:56 +00:00
Francisco Lima
29b6a02118 Merged in layout-bug-fixes (pull request #144)
Remove unused imports and changed and improved layout design and responsiveness in some components and fixed some bugs.

Approved-by: Tiago Ribeiro
2025-02-03 09:35:25 +00:00
Francisco Lima
b77476dc9a Merged develop into layout-bug-fixes 2025-02-03 00:07:05 +00:00
José Marques Lima
5a685ebe80 Remove unused imports and changed and improved layout design and responsiveness in some components and fixed some bugs. 2025-02-02 23:58:23 +00:00
Joao Correia
835a9bee03 - Filter available form intakers so that no form intaker can be in two workflows at once.
- add getApprovalWorkflowByIntaker to prepare workflow start after exam creation.
- fix builder bug with step keys
- ignore edit view for now because it will only be available for active workflows and not configured workflows.
2025-02-02 22:40:05 +00:00
Joao Correia
16545d2075 refactor workflows api 2025-02-02 11:11:52 +00:00
Joao Correia
b684262759 Fix id handling on update 2025-02-01 23:14:17 +00:00
Joao Correia
ac539332e6 major change on how workflow builder works. It now fetches in edit mode all the currently configured workflows 2025-02-01 22:36:42 +00:00
Francisco Lima
ed87c8b163 Merged in refactor-getserverprops (pull request #143)
Fix Finish page with scores in exams

Approved-by: Tiago Ribeiro
2025-01-31 22:40:29 +00:00
José Marques Lima
e33ab315ad Merge branch 'develop' into refactor-getserverprops 2025-01-31 22:37:36 +00:00
José Marques Lima
1feef5c419 Fix Finish page 2025-01-31 22:34:43 +00:00
Joao Correia
a0229cd971 implement rejection of steps 2025-01-31 20:56:40 +00:00
Joao Correia
662e3b0266 - implement approval of steps
- remove currentStep field from step
- implement save comments on step
- fix _id issue when saving to mongo
2025-01-31 17:01:20 +00:00
Tiago Ribeiro
b9aec7261f Updated the maxPoolSize 2025-01-31 12:26:43 +00:00
Tiago Ribeiro
54a9f6869a Reduced the maxPoolSize 2025-01-31 12:05:39 +00:00
Joao Correia
9de4cba8e8 refactor fetched users into single array and replace Image tag with img 2025-01-31 10:37:14 +00:00
Francisco Lima
7d750dc584 Merged in refactor-getserverprops (pull request #142)
Refactor most getServerProps to make independent requests in parallel and projected the data only to return the necessary fields and changed some functions

Approved-by: Tiago Ribeiro
2025-01-30 20:02:27 +00:00
José Marques Lima
98ba0bfc04 Refactor most getServerProps to fetch independent request in parallel and projected the data only to return the necessary fields and changed some functions 2025-01-30 18:25:42 +00:00
Joao Correia
f89b42c41c remove currentStep from step type 2025-01-30 12:06:13 +00:00
Joao Correia
c968044160 switch to mongo's id handling 2025-01-30 11:50:28 +00:00
Joao Correia
5d727fc528 implement delete workflow 2025-01-30 11:07:13 +00:00
Joao Correia
bdc5ff7797 - edit workflow back-end implementation
- clone workflow back-end implementation
- added loading and redirecting to form submissions
- fixed form intake in progress bug
- fixed rendering bug
2025-01-29 20:49:19 +00:00
Joao Correia
011c6e9e30 Start implementing with back-end. Create workflows completed and fetching workflows on server side as well, to show them in the table. 2025-01-29 17:50:03 +00:00
Joao Correia
42a8ec2f8a small fixes 2025-01-29 15:35:59 +00:00
Francisco Lima
58aebaa66c Merged in ENCOA-316-ENCOA-317 (pull request #141)
Fix login page having a Card

Approved-by: Tiago Ribeiro
2025-01-29 08:59:55 +00:00
José Marques Lima
b69b6e6c77 Fix login page having a Card 2025-01-28 20:31:19 +00:00
Francisco Lima
86af876f01 Merged in ENCOA-316-ENCOA-317 (pull request #140)
Fix entities Page not rendering

Approved-by: Tiago Ribeiro
2025-01-28 09:35:02 +00:00
Tiago Ribeiro
b685259dc7 Merged develop into ENCOA-316-ENCOA-317 2025-01-28 09:30:34 +00:00
José Marques Lima
16b959fb7a Fix entities Page not rendering 2025-01-27 22:06:34 +00:00
Joao Correia
a40ae04aa3 Add workflow table name filter 2025-01-27 12:50:18 +00:00
Francisco Lima
db95fc5681 Merged in ENCOA-316-ENCOA-317 (pull request #139)
ENCOA-316 ENCOA-317

Approved-by: Tiago Ribeiro
2025-01-27 09:27:09 +00:00
Joao Correia
8db47a3962 Filter out empty select inputs on form submission 2025-01-26 14:07:25 +00:00
Joao Correia
ab81a1753d - Implement cloning of workflow
- Entity change will now only clear the assignees instead of the whole workflow
- Fix bug where side panel was showing all workflow assignees instead of just selected step assignees
2025-01-26 04:31:36 +00:00
José Marques Lima
c98af863c3 Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into ENCOA-316-ENCOA-317 2025-01-25 20:01:52 +00:00
Joao Correia
73610dc273 implement edit workflow 2025-01-25 19:45:39 +00:00
José Marques Lima
37216e2a5a ENCOA-316 ENCOA-317:
Refactor components to remove Layout wrapper and pass it in the App component , implemented a skeleton feedback while loading page and improved API calls related to Dashboard/User Profile
2025-01-25 19:38:29 +00:00
Joao Correia
ac072b0a5a small fixes and animate side panel content 2025-01-25 15:47:33 +00:00
Joao Correia
2c0153e055 Fix animations 2025-01-25 15:10:52 +00:00
Joao Correia
2eff08bf86 Add approval by to step details panel and add text size prop to UserWithProfilePicture component 2025-01-25 04:23:28 +00:00
Joao Correia
f71a7182dd - Refactor of workflow and steps types to differentiate between editView and normalView.
- Added side panel with steps details
2025-01-25 03:44:50 +00:00
Joao Correia
1f7639a30e - initial selected step
- assignees id to name on table view
2025-01-24 17:09:37 +00:00
Joao Correia
41d09eaad8 Make data dynamic in workflow view. Add requester and startDate to workflows. 2025-01-24 14:14:07 +00:00
Joao Correia
f6b0c96b3b Finish Approval Workflow builder for the most part. TODO: implement permissions 2025-01-24 00:33:45 +00:00
Joao Correia
dcd25465fd on workflow builder, only render steps if name and entity are set. reset workflow on entity reset. 2025-01-23 22:56:45 +00:00
Joao Correia
c921d54d50 code refactoring 2025-01-23 22:12:25 +00:00
Joao Correia
a4f60455b5 Render previous select input options when switching between workflows in builder 2025-01-23 17:08:32 +00:00
Joao Correia
a0936cb1a4 Prevent same input on selects from the same step.
Change behaviour of initial step to allow multiple assignees
2025-01-23 15:10:14 +00:00
Joao Correia
aa76c2b54b Work on workflow builder:
- Made number of approvers dynamic with many select inputs as needed
- Tracking approval select input changes with step.assignees
- Fetching teachers and corporates from backend
- Responsive styling when rendering several select inputs
2025-01-23 02:48:25 +00:00
Joao Correia
4e81c08adb fix bug in last commit where all entities would show up on select filter instead of user entities 2025-01-22 18:07:09 +00:00
Joao Correia
4895f00184 Add entityId to workflow. Allow filter workflows based on entityId. Restrict creation of workflows based on user entities. 2025-01-22 16:39:18 +00:00
carlos.mesquita
f727ab4792 Merged in feature/ExamGenRework (pull request #138)
ENCOA-315

Approved-by: Tiago Ribeiro
2025-01-22 08:27:46 +00:00
Joao Correia
8f8d5e5640 Work on workflows table 2025-01-22 00:30:14 +00:00
Joao Correia
73e2e95449 work on non editable approval workflow steps view 2025-01-21 20:42:03 +00:00
Joao Correia
48187fc7f2 fix step numbering bug from previous commit and prepare non editable workflow view 2025-01-21 00:10:14 +00:00
Joao Correia
01222b3a13 dynamic list of new workflows in workflow builder and some code refactoring 2025-01-20 23:32:32 +00:00
Joao Correia
39a397d262 Add progress vertical bars to pipeline steps 2025-01-20 11:44:29 +00:00
Joao Correia
50d2841349 Implement reordering of steps 2025-01-19 22:26:02 +00:00
Joao Correia
f485c782f3 Start implementing workflow step form behaviour 2025-01-19 19:23:56 +00:00
Joao Correia
c2c9b3374c Start work on Approval Workflow id page 2025-01-17 20:17:51 +00:00
Joao Correia
66d23b4140 Add local test data, implement [id].tsx for approval workflows 2025-01-17 18:44:52 +00:00
Joao Correia
580e319fb9 start implementation of approval workflows. Add nav, page, empty table, and status filtering 2025-01-17 15:20:46 +00:00
132 changed files with 16239 additions and 8865 deletions

2
.gitignore vendored
View File

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

View File

@@ -39,6 +39,7 @@
"country-codes-list": "^1.6.11",
"currency-symbol-map": "^5.1.0",
"daisyui": "^3.1.5",
"deep-diff": "^1.0.2",
"eslint": "8.33.0",
"eslint-config-next": "13.1.6",
"exceljs": "^4.4.0",
@@ -97,6 +98,7 @@
"devDependencies": {
"@simbathesailor/use-what-changed": "^2.0.0",
"@types/blob-stream": "^0.1.33",
"@types/deep-diff": "^1.0.5",
"@types/formidable": "^3.4.0",
"@types/howler": "^2.2.11",
"@types/lodash": "^4.14.191",

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,12 @@
import {infoButtonStyle} from "@/constants/buttonStyles";
import {Module} from "@/interfaces";
import {User} from "@/interfaces/user";
import useExamStore from "@/stores/exam";
import {getExam, getExamById} from "@/utils/exams";
import {getExam} from "@/utils/exams";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {writingMarking} from "@/utils/score";
import {Menu} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import {useRouter} from "next/router";
import {useEffect, useState} from "react";
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
import { useState} from "react";
import { BsQuestionSquare} from "react-icons/bs";
import {toast} from "react-toastify";
import Button from "./Low/Button";
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";

View File

@@ -19,7 +19,7 @@ interface SettingsEditorProps {
children?: ReactNode;
canPreview: boolean;
canSubmit: boolean;
submitModule: () => void;
submitModule: (requiresApproval: boolean) => void;
preview: () => void;
}
@@ -95,7 +95,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
}, [updateLocalAndScheduleGlobal]);
return (
<div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit`}>
<div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit -2xl:w-full`}>
<div className={`w-full flex justify-center text-ielts-${module} font-bold text-xl`}>{sectionLabel} Settings</div>
<div className="flex flex-col gap-4">
<Dropdown
@@ -148,18 +148,33 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
</div>
</Dropdown>
{children}
<div className="flex flex-row justify-between mt-4">
<div className="flex flex-col gap-3 mt-4">
<button
className={clsx(
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
"disabled:cursor-not-allowed disabled:text-gray-200"
)}
onClick={submitModule}
onClick={() => submitModule(true)}
disabled={!canSubmit}
>
<FaFileUpload className="mr-2" size={18} />
Submit Module as Exam
Submit module as exam for approval
</button>
<button
className={clsx(
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
"disabled:cursor-not-allowed disabled:text-gray-200"
)}
onClick={() => {
if (!confirm(`Are you sure you want to skip the approval process for this exam?`)) return;
submitModule(false);
}}
disabled={!canSubmit}
>
<FaFileUpload className="mr-2" size={18} />
Submit module as exam and skip approval process
</button>
<button
className={clsx(
@@ -171,7 +186,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
disabled={!canPreview}
>
<FaEye className="mr-2" size={18} />
Preview Module
Preview module
</button>
</div>
</div>

View File

@@ -17,6 +17,7 @@ import ListeningComponents from "./listening/components";
import ReadingComponents from "./reading/components";
import SpeakingComponents from "./speaking/components";
import SectionPicker from "./Shared/SectionPicker";
import { getExamById } from "@/utils/exams";
const LevelSettings: React.FC = () => {
@@ -37,7 +38,7 @@ const LevelSettings: React.FC = () => {
difficulty,
sections,
minTimer,
isPrivate,
access,
} = useExamEditorStore(state => state.modules[currentModule]);
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>(
@@ -75,7 +76,7 @@ const LevelSettings: React.FC = () => {
});
});
const submitLevel = async () => {
const submitLevel = async (requiresApproval: boolean) => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
@@ -194,17 +195,23 @@ const LevelSettings: React.FC = () => {
category: s.settings.category
};
}).filter(part => part.exercises.length > 0),
requiresApproval: requiresApproval,
isDiagnostic: false,
minTimer,
module: "level",
id: title,
difficulty,
private: isPrivate,
access,
};
const result = await axios.post('/api/exam/level', exam);
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
// Successfully submitted exam
if (result.status === 200) {
toast.success(result.data.message);
} else if (result.status === 207) {
toast.warning(result.data.message);
}
Array.from(audioMap.values()).forEach(url => {
URL.revokeObjectURL(url);
@@ -237,7 +244,7 @@ const LevelSettings: React.FC = () => {
isDiagnostic: false,
variant: undefined,
difficulty,
private: isPrivate,
access,
} as LevelExam);
setExerciseIndex(0);
setQuestionIndex(0);

View File

@@ -233,7 +233,7 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
>
<div className="flex flex-row gap-2 items-center px-2 pb-4">
<div className="flex flex-row flex-wrap gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input

View File

@@ -17,6 +17,7 @@ import { usePersistentExamStore } from "@/stores/exam";
import { playSound } from "@/utils/sound";
import { toast } from "react-toastify";
import ListeningComponents from "./components";
import { getExamById } from "@/utils/exams";
const ListeningSettings: React.FC = () => {
const router = useRouter();
@@ -26,7 +27,7 @@ const ListeningSettings: React.FC = () => {
difficulty,
sections,
minTimer,
isPrivate,
access,
instructionsState
} = useExamEditorStore(state => state.modules[currentModule]);
@@ -64,7 +65,7 @@ const ListeningSettings: React.FC = () => {
}
];
const submitListening = async () => {
const submitListening = async (requiresApproval: boolean) => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
@@ -137,19 +138,25 @@ const ListeningSettings: React.FC = () => {
category: s.settings.category
};
}),
requiresApproval: requiresApproval,
isDiagnostic: false,
minTimer,
module: "listening",
id: title,
variant: sections.length === 4 ? "full" : "partial",
difficulty,
private: isPrivate,
access,
instructions: instructionsURL
};
const result = await axios.post('/api/exam/listening', exam);
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
// Successfully submitted exam
if (result.status === 200) {
toast.success(result.data.message);
} else if (result.status === 207) {
toast.warning(result.data.message);
}
} else {
toast.error('No audio sections found in the exam! Please either import them or generate them.');
@@ -185,7 +192,7 @@ const ListeningSettings: React.FC = () => {
isDiagnostic: false,
variant: sections.length === 4 ? "full" : "partial",
difficulty,
private: isPrivate,
access,
instructions: instructionsState.currentInstructionsURL
} as ListeningExam);
setExerciseIndex(0);

View File

@@ -5,103 +5,140 @@ import ExercisePicker from "../../ExercisePicker";
import { generate } from "../Shared/Generate";
import GenerateBtn from "../Shared/GenerateBtn";
import { LevelPart, ReadingPart } from "@/interfaces/exam";
import { LevelSectionSettings, ReadingSectionSettings } from "@/stores/examEditor/types";
import {
LevelSectionSettings,
ReadingSectionSettings,
} from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
interface Props {
localSettings: ReadingSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<ReadingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
currentSection: ReadingPart | LevelPart;
generatePassageDisabled?: boolean;
levelId?: number;
level?: boolean;
localSettings: ReadingSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (
updates: Partial<ReadingSectionSettings | LevelSectionSettings>,
schedule?: boolean
) => void;
currentSection: ReadingPart | LevelPart;
generatePassageDisabled?: boolean;
levelId?: number;
level?: boolean;
}
const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndScheduleGlobal, currentSection, levelId, level = false, generatePassageDisabled = false}) => {
const { currentModule } = useExamEditorStore();
const {
focusedSection,
difficulty,
} = useExamEditorStore(state => state.modules[currentModule]);
const ReadingComponents: React.FC<Props> = ({
localSettings,
updateLocalAndScheduleGlobal,
currentSection,
levelId,
level = false,
generatePassageDisabled = false,
}) => {
const { currentModule } = useExamEditorStore();
const { focusedSection, difficulty } = useExamEditorStore(
(state) => state.modules[currentModule]
);
const generatePassage = useCallback(() => {
generate(
levelId ? levelId : focusedSection,
"reading",
"passage",
{
method: 'GET',
queryParams: {
difficulty,
...(localSettings.readingTopic && { topic: localSettings.readingTopic })
}
},
(data: any) => [{
title: data.title,
text: data.text
}],
level ? focusedSection : undefined,
level
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSettings.readingTopic, difficulty, focusedSection, levelId]);
const onTopicChange = useCallback((readingTopic: string) => {
updateLocalAndScheduleGlobal({ readingTopic });
}, [updateLocalAndScheduleGlobal]);
return (
<>
<Dropdown
title="Generate Passage"
module="reading"
open={localSettings.isPassageOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
disabled={generatePassageDisabled}
>
<div className="flex flex-row gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.readingTopic}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
module="reading"
genType="passage"
sectionId={focusedSection}
generateFnc={generatePassage}
level={level}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Add Exercises"
module="reading"
open={localSettings.isReadingTopicOpean}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })}
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""}
>
<ExercisePicker
module="reading"
sectionId={levelId !== undefined ? levelId : focusedSection}
extraArgs={{ text: currentSection === undefined || currentSection.text === undefined ? "" : currentSection.text.content }}
levelSectionId={focusedSection}
level={level}
/>
</Dropdown>
</>
const generatePassage = useCallback(() => {
generate(
levelId ? levelId : focusedSection,
"reading",
"passage",
{
method: "GET",
queryParams: {
difficulty,
...(localSettings.readingTopic && {
topic: localSettings.readingTopic,
}),
},
},
(data: any) => [
{
title: data.title,
text: data.text,
},
],
level ? focusedSection : undefined,
level
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSettings.readingTopic, difficulty, focusedSection, levelId]);
const onTopicChange = useCallback(
(readingTopic: string) => {
updateLocalAndScheduleGlobal({ readingTopic });
},
[updateLocalAndScheduleGlobal]
);
return (
<>
<Dropdown
title="Generate Passage"
module="reading"
open={localSettings.isPassageOpen}
setIsOpen={(isOpen: boolean) =>
updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)
}
contentWrapperClassName={level ? `border border-ielts-reading` : ""}
disabled={generatePassageDisabled}
>
<div
className="flex flex-row flex-wrap gap-2 items-center px-2 pb-4 "
>
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">
Topic (Optional)
</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.readingTopic}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
module="reading"
genType="passage"
sectionId={focusedSection}
generateFnc={generatePassage}
level={level}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Add Exercises"
module="reading"
open={localSettings.isReadingTopicOpean}
setIsOpen={(isOpen: boolean) =>
updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })
}
contentWrapperClassName={level ? `border border-ielts-reading` : ""}
disabled={
currentSection === undefined ||
currentSection.text === undefined ||
currentSection.text.content === "" ||
currentSection.text.title === ""
}
>
<ExercisePicker
module="reading"
sectionId={levelId !== undefined ? levelId : focusedSection}
extraArgs={{
text:
currentSection === undefined || currentSection.text === undefined
? ""
: currentSection.text.content,
}}
levelSectionId={focusedSection}
level={level}
/>
</Dropdown>
</>
);
};
export default ReadingComponents;

View File

@@ -12,132 +12,141 @@ import axios from "axios";
import { playSound } from "@/utils/sound";
import { toast } from "react-toastify";
import ReadingComponents from "./components";
import { getExamById } from "@/utils/exams";
const ReadingSettings: React.FC = () => {
const router = useRouter();
const router = useRouter();
const {
setExam,
setExerciseIndex,
setPartIndex,
setQuestionIndex,
setBgColor,
} = usePersistentExamStore();
const {
setExam,
setExerciseIndex,
setPartIndex,
setQuestionIndex,
setBgColor,
} = usePersistentExamStore();
const { currentModule, title } = useExamEditorStore();
const {
focusedSection,
difficulty,
sections,
minTimer,
isPrivate,
type,
} = useExamEditorStore(state => state.modules[currentModule]);
const { currentModule, title } = useExamEditorStore();
const { focusedSection, difficulty, sections, minTimer, access, type } =
useExamEditorStore((state) => state.modules[currentModule]);
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ReadingSectionSettings>(
currentModule,
focusedSection
);
const { localSettings, updateLocalAndScheduleGlobal } =
useSettingsState<ReadingSectionSettings>(currentModule, focusedSection);
const currentSection = sections.find((section) => section.sectionId == focusedSection)?.state as ReadingPart;
const currentSection = sections.find(
(section) => section.sectionId == focusedSection
)?.state as ReadingPart;
const defaultPresets: Option[] = [
{
label: "Preset: Reading Passage 1",
value:
"Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas.",
},
{
label: "Preset: Reading Passage 2",
value:
"Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views.",
},
{
label: "Preset: Reading Passage 3",
value:
"Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas.",
},
];
const defaultPresets: Option[] = [
{
label: "Preset: Reading Passage 1",
value: "Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas."
},
{
label: "Preset: Reading Passage 2",
value: "Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views."
},
{
label: "Preset: Reading Passage 3",
value: "Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas."
}
];
const canPreviewOrSubmit = sections.some(
(s) =>
(s.state as ReadingPart).exercises &&
(s.state as ReadingPart).exercises.length > 0
);
const canPreviewOrSubmit = sections.some(
(s) => (s.state as ReadingPart).exercises && (s.state as ReadingPart).exercises.length > 0
);
const submitReading = () => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
const exam: ReadingExam = {
parts: sections.map((s) => {
const exercise = s.state as ReadingPart;
return {
...exercise,
intro: localSettings.currentIntro,
category: localSettings.category
};
}),
isDiagnostic: false,
minTimer,
module: "reading",
id: title,
variant: sections.length === 3 ? "full" : "partial",
difficulty,
private: isPrivate,
type: type!
const submitReading = (requiresApproval: boolean) => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
const exam: ReadingExam = {
parts: sections.map((s) => {
const exercise = s.state as ReadingPart;
return {
...exercise,
intro: localSettings.currentIntro,
category: localSettings.category,
};
}),
requiresApproval: requiresApproval,
isDiagnostic: false,
minTimer,
module: "reading",
id: title,
variant: sections.length === 3 ? "full" : "partial",
difficulty,
access,
type: type!,
};
axios.post(`/api/exam/reading`, exam)
.then((result) => {
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
})
.catch((error) => {
console.log(error);
toast.error(error.response.data.error || "Something went wrong while submitting, please try again later.");
})
}
axios
.post(`/api/exam/reading`, exam)
.then((result) => {
playSound("sent");
// Successfully submitted exam
if (result.status === 200) {
toast.success(result.data.message);
} else if (result.status === 207) {
toast.warning(result.data.message);
}
})
.catch((error) => {
console.log(error);
toast.error(
error.response.data.error ||
"Something went wrong while submitting, please try again later."
);
});
};
const preview = () => {
setExam({
parts: sections.map((s) => {
const exercises = s.state as ReadingPart;
return {
...exercises,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
minTimer,
module: "reading",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
private: isPrivate,
type: type!
} as ReadingExam);
setExerciseIndex(0);
setQuestionIndex(0);
setPartIndex(0);
setBgColor("bg-white");
openDetachedTab("popout?type=Exam&module=reading", router)
}
const preview = () => {
setExam({
parts: sections.map((s) => {
const exercises = s.state as ReadingPart;
return {
...exercises,
intro: s.settings.currentIntro,
category: s.settings.category,
};
}),
minTimer,
module: "reading",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
access: access,
type: type!,
} as ReadingExam);
setExerciseIndex(0);
setQuestionIndex(0);
setPartIndex(0);
setBgColor("bg-white");
openDetachedTab("popout?type=Exam&module=reading", router);
};
return (
<SettingsEditor
sectionLabel={`Passage ${focusedSection}`}
sectionId={focusedSection}
module="reading"
introPresets={[defaultPresets[focusedSection - 1]]}
preview={preview}
canPreview={canPreviewOrSubmit}
canSubmit={canPreviewOrSubmit}
submitModule={submitReading}
>
<ReadingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
/>
</SettingsEditor>
);
return (
<SettingsEditor
sectionLabel={`Passage ${focusedSection}`}
sectionId={focusedSection}
module="reading"
introPresets={[defaultPresets[focusedSection - 1]]}
preview={preview}
canPreview={canPreviewOrSubmit}
canSubmit={canPreviewOrSubmit}
submitModule={submitReading}
>
<ReadingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
/>
</SettingsEditor>
);
};
export default ReadingSettings;

View File

@@ -11,6 +11,7 @@ import openDetachedTab from "@/utils/popout";
import axios from "axios";
import { playSound } from "@/utils/sound";
import SpeakingComponents from "./components";
import { getExamById } from "@/utils/exams";
export interface Avatar {
name: string;
@@ -29,7 +30,7 @@ const SpeakingSettings: React.FC = () => {
} = usePersistentExamStore();
const { title, currentModule } = useExamEditorStore();
const { focusedSection, difficulty, sections, minTimer, isPrivate } = useExamEditorStore((store) => store.modules[currentModule])
const { focusedSection, difficulty, sections, minTimer, access } = useExamEditorStore((store) => store.modules[currentModule])
const section = sections.find((section) => section.sectionId == focusedSection)?.state;
@@ -83,7 +84,7 @@ const SpeakingSettings: React.FC = () => {
});
})();
const submitSpeaking = async () => {
const submitSpeaking = async (requiresApproval: boolean) => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
@@ -180,16 +181,22 @@ const SpeakingSettings: React.FC = () => {
minTimer,
module: "speaking",
id: title,
requiresApproval: requiresApproval,
isDiagnostic: false,
variant: undefined,
difficulty,
instructorGender: "varied",
private: isPrivate,
access,
};
const result = await axios.post('/api/exam/speaking', exam);
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
// Successfully submitted exam
if (result.status === 200) {
toast.success(result.data.message);
} else if (result.status === 207) {
toast.warning(result.data.message);
}
Array.from(urlMap.values()).forEach(url => {
URL.revokeObjectURL(url);
@@ -232,7 +239,7 @@ const SpeakingSettings: React.FC = () => {
isDiagnostic: false,
variant: undefined,
difficulty,
private: isPrivate,
access,
} as SpeakingExam);
setExerciseIndex(0);
setQuestionIndex(0);

View File

@@ -12,6 +12,8 @@ import axios from "axios";
import { playSound } from "@/utils/sound";
import { toast } from "react-toastify";
import WritingComponents from "./components";
import { getExamById } from "@/utils/exams";
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
const WritingSettings: React.FC = () => {
const router = useRouter();
@@ -21,7 +23,7 @@ const WritingSettings: React.FC = () => {
const {
minTimer,
difficulty,
isPrivate,
access,
sections,
focusedSection,
type,
@@ -79,14 +81,14 @@ const WritingSettings: React.FC = () => {
isDiagnostic: false,
variant: undefined,
difficulty,
private: isPrivate,
access,
type: type!
});
setExerciseIndex(0);
openDetachedTab("popout?type=Exam&module=writing", router)
}
const submitWriting = async () => {
const submitWriting = async (requiresApproval: boolean) => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
@@ -129,16 +131,22 @@ const WritingSettings: React.FC = () => {
minTimer,
module: "writing",
id: title,
requiresApproval: requiresApproval,
isDiagnostic: false,
variant: undefined,
difficulty,
private: isPrivate,
access,
type: type!
};
const result = await axios.post(`/api/exam/writing`, exam)
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
// Successfully submitted exam
if (result.status === 200) {
toast.success(result.data.message);
} else if (result.status === 207) {
toast.warning(result.data.message);
}
} catch (error: any) {
console.error('Error submitting exam:', error);

View File

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

View File

@@ -4,8 +4,8 @@ import Checkbox from "../Low/Checkbox";
import Input from "../Low/Input";
import Select from "../Low/Select";
import { capitalize } from "lodash";
import { Difficulty } from "@/interfaces/exam";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam";
import { useCallback, useEffect, useState } from "react";
import { toast } from "react-toastify";
import { ModuleState, SectionState } from "@/stores/examEditor/types";
import { Module } from "@/interfaces";
@@ -20,220 +20,282 @@ import { defaultSectionSettings } from "@/stores/examEditor/defaults";
import Button from "../Low/Button";
import ResetModule from "./Standalone/ResetModule";
import ListeningInstructions from "./Standalone/ListeningInstructions";
import { EntityWithRoles } from "@/interfaces/entity";
const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"];
const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
const { currentModule, dispatch } = useExamEditorStore();
const {
sections,
minTimer,
expandedSections,
examLabel,
isPrivate,
difficulty,
sectionLabels,
importModule
} = useExamEditorStore(state => state.modules[currentModule]);
const ExamEditor: React.FC<{
levelParts?: number;
entitiesAllowEditPrivacy: EntityWithRoles[];
}> = ({ levelParts = 0, entitiesAllowEditPrivacy = [] }) => {
const { currentModule, dispatch } = useExamEditorStore();
const {
sections,
minTimer,
expandedSections,
examLabel,
access,
difficulty,
sectionLabels,
importModule,
} = useExamEditorStore((state) => state.modules[currentModule]);
const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1);
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
const [numberOfLevelParts, setNumberOfLevelParts] = useState(
levelParts !== 0 ? levelParts : 1
);
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
// For exam edits
useEffect(() => {
if (levelParts !== 0) {
setNumberOfLevelParts(levelParts);
dispatch({
type: 'UPDATE_MODULE',
payload: {
updates: {
sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({
id: i + 1,
label: `Part ${i + 1}`
}))
},
module: "level"
}
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelParts])
// For exam edits
useEffect(() => {
if (levelParts !== 0) {
setNumberOfLevelParts(levelParts);
dispatch({
type: "UPDATE_MODULE",
payload: {
updates: {
sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({
id: i + 1,
label: `Part ${i + 1}`,
})),
},
module: "level",
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelParts]);
useEffect(() => {
const currentSections = sections;
const currentLabels = sectionLabels;
let updatedSections: SectionState[];
let updatedLabels: any;
if (currentModule === "level" && currentSections.length !== currentLabels.length || numberOfLevelParts !== currentSections.length) {
const newSections = [...currentSections];
const newLabels = [...currentLabels];
for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
if (currentSections.length !== numberOfLevelParts) newSections.push(defaultSectionSettings(currentModule, i + 1));
newLabels.push({
id: i + 1,
label: `Part ${i + 1}`
});
}
updatedSections = newSections;
updatedLabels = newLabels;
} else if (numberOfLevelParts < currentSections.length) {
updatedSections = currentSections.slice(0, numberOfLevelParts);
updatedLabels = currentLabels.slice(0, numberOfLevelParts);
} else {
return;
}
const updatedExpandedSections = expandedSections.filter(
sectionId => updatedSections.some(section => section.sectionId === sectionId)
);
dispatch({
type: 'UPDATE_MODULE',
payload: {
updates: {
sections: updatedSections,
sectionLabels: updatedLabels,
expandedSections: updatedExpandedSections
}
}
useEffect(() => {
const currentSections = sections;
const currentLabels = sectionLabels;
let updatedSections: SectionState[];
let updatedLabels: any;
if (
(currentModule === "level" &&
currentSections.length !== currentLabels.length) ||
numberOfLevelParts !== currentSections.length
) {
const newSections = [...currentSections];
const newLabels = [...currentLabels];
for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
if (currentSections.length !== numberOfLevelParts)
newSections.push(defaultSectionSettings(currentModule, i + 1));
newLabels.push({
id: i + 1,
label: `Part ${i + 1}`,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [numberOfLevelParts]);
const sectionIds = sections.map((section) => section.sectionId)
const updateModule = useCallback((updates: Partial<ModuleState>) => {
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
}, [dispatch]);
const toggleSection = (sectionId: number) => {
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
toast.error("Include at least one section!");
return;
}
dispatch({ type: 'TOGGLE_SECTION', payload: { sectionId } });
};
const ModuleSettings: Record<Module, React.ComponentType> = {
reading: ReadingSettings,
writing: WritingSettings,
speaking: SpeakingSettings,
listening: ListeningSettings,
level: LevelSettings
};
const Settings = ModuleSettings[currentModule];
const showImport = importModule && ["reading", "listening", "level"].includes(currentModule);
const updateLevelParts = (parts: number) => {
setNumberOfLevelParts(parts);
}
updatedSections = newSections;
updatedLabels = newLabels;
} else if (numberOfLevelParts < currentSections.length) {
updatedSections = currentSections.slice(0, numberOfLevelParts);
updatedLabels = currentLabels.slice(0, numberOfLevelParts);
} else {
return;
}
return (
<>
{showImport ? <ImportOrStartFromScratch module={currentModule} setNumberOfLevelParts={updateLevelParts} /> : (
<>
{isResetModuleOpen && <ResetModule module={currentModule} isOpen={isResetModuleOpen} setIsOpen={setIsResetModuleOpen} setNumberOfLevelParts={setNumberOfLevelParts}/>}
<div className="flex gap-4 w-full items-center">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => updateModule({ minTimer: parseInt(e) < 15 ? 15 : parseInt(e) })}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 flex-grow">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
isMulti={true}
options={DIFFICULTIES.map((x) => ({
value: x,
label: capitalize(x)
}))}
onChange={(values) => {
const selectedDifficulties = values ? values.map(v => v.value as Difficulty) : [];
updateModule({ difficulty: selectedDifficulties });
}}
value={
difficulty
? difficulty.map(d => ({
value: d,
label: capitalize(d)
}))
: null
}
/>
</div>
{(sectionLabels.length != 0 && currentModule !== "level") ? (
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">{sectionLabels[0].label.split(" ")[0]}</label>
<div className="flex flex-row gap-8">
{sectionLabels.map(({ id, label }) => (
<span
key={id}
className={clsx(
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
sectionIds.includes(id)
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
: "bg-white border-mti-gray-platinum"
)}
onClick={() => toggleSection(id)}
>
{label}
</span>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-3 w-1/3">
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
<Input type="number" name="Number of Parts" min={1} onChange={(v) => setNumberOfLevelParts(parseInt(v))} value={numberOfLevelParts} />
</div>
)}
<div className="flex flex-col gap-3 w-fit h-fit">
<div className="h-6" />
<Checkbox isChecked={isPrivate} onChange={(checked) => updateModule({ isPrivate: checked })}>
Privacy (Only available for Assignments)
</Checkbox>
</div>
</div>
<div className="flex flex-row gap-3 w-full">
<div className="flex flex-col gap-3 flex-grow">
<label className="font-normal text-base text-mti-gray-dim">Exam Label *</label>
<Input
type="text"
placeholder="Exam Label"
name="label"
onChange={(text) => updateModule({ examLabel: text })}
roundness="xl"
value={examLabel}
required
/>
</div>
{currentModule === "listening" && <ListeningInstructions />}
<Button
onClick={() => setIsResetModuleOpen(true)}
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
className={`text-white self-end`}
>
Reset Module
</Button>
</div>
<div className="flex flex-row gap-8">
<Settings />
<div className="flex-grow max-w-[66%]">
<SectionRenderer />
</div>
</div>
</>
)}
</>
const updatedExpandedSections = expandedSections.filter((sectionId) =>
updatedSections.some((section) => section.sectionId === sectionId)
);
dispatch({
type: "UPDATE_MODULE",
payload: {
updates: {
sections: updatedSections,
sectionLabels: updatedLabels,
expandedSections: updatedExpandedSections,
},
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [numberOfLevelParts]);
const sectionIds = sections.map((section) => section.sectionId);
const updateModule = useCallback(
(updates: Partial<ModuleState>) => {
dispatch({ type: "UPDATE_MODULE", payload: { updates } });
},
[dispatch]
);
const toggleSection = (sectionId: number) => {
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
toast.error("Include at least one section!");
return;
}
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
};
const ModuleSettings: Record<Module, React.ComponentType> = {
reading: ReadingSettings,
writing: WritingSettings,
speaking: SpeakingSettings,
listening: ListeningSettings,
level: LevelSettings,
};
const Settings = ModuleSettings[currentModule];
const showImport =
importModule && ["reading", "listening", "level"].includes(currentModule);
const updateLevelParts = (parts: number) => {
setNumberOfLevelParts(parts);
};
return (
<>
{showImport ? (
<ImportOrStartFromScratch
module={currentModule}
setNumberOfLevelParts={updateLevelParts}
/>
) : (
<>
{isResetModuleOpen && (
<ResetModule
module={currentModule}
isOpen={isResetModuleOpen}
setIsOpen={setIsResetModuleOpen}
setNumberOfLevelParts={setNumberOfLevelParts}
/>
)}
<div className="flex gap-4 w-full items-center -xl:flex-col">
<div className="flex flex-row gap-3 w-full">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">
Timer
</label>
<Input
type="number"
name="minTimer"
onChange={(e) =>
updateModule({
minTimer: parseInt(e) < 15 ? 15 : parseInt(e),
})
}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 flex-grow">
<label className="font-normal text-base text-mti-gray-dim">
Difficulty
</label>
<Select
isMulti={true}
options={DIFFICULTIES.map((x) => ({
value: x,
label: capitalize(x),
}))}
onChange={(values) => {
const selectedDifficulties = values
? values.map((v) => v.value as Difficulty)
: [];
updateModule({ difficulty: selectedDifficulties });
}}
value={
difficulty
? (Array.isArray(difficulty)
? difficulty
: [difficulty]
).map((d) => ({
value: d,
label: capitalize(d),
}))
: null
}
/>
</div>
</div>
{sectionLabels.length != 0 && currentModule !== "level" ? (
<div className="flex flex-col gap-3 -xl:w-full">
<label className="font-normal text-base text-mti-gray-dim">
{sectionLabels[0].label.split(" ")[0]}
</label>
<div className="flex flex-row gap-8">
{sectionLabels.map(({ id, label }) => (
<span
key={id}
className={clsx(
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
sectionIds.includes(id)
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
: "bg-white border-mti-gray-platinum"
)}
onClick={() => toggleSection(id)}
>
{label}
</span>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-3 w-1/3">
<label className="font-normal text-base text-mti-gray-dim">
Number of Parts
</label>
<Input
type="number"
name="Number of Parts"
min={1}
onChange={(v) => setNumberOfLevelParts(parseInt(v))}
value={numberOfLevelParts}
/>
</div>
)}
</div>
<div className="flex flex-row gap-3 w-64">
<Select
label="Access Type"
options={ACCESSTYPE.map((item) => ({
value: item,
label: capitalize(item),
}))}
onChange={(value) => {
if (value?.value) {
updateModule({ access: value.value! as AccessType });
}
}}
value={{ value: access, label: capitalize(access) }}
/>
</div>
<div className="flex flex-row gap-3 w-full">
<div className="flex flex-col gap-3 flex-grow">
<label className="font-normal text-base text-mti-gray-dim">
Exam Label *
</label>
<Input
type="text"
placeholder="Exam Label"
name="label"
onChange={(text) => updateModule({ examLabel: text })}
roundness="xl"
value={examLabel}
required
/>
</div>
{currentModule === "listening" && <ListeningInstructions />}
<Button
onClick={() => setIsResetModuleOpen(true)}
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
className={`text-white self-end`}
>
Reset Module
</Button>
</div>
<div className="flex flex-row gap-8 -2xl:flex-col">
<Settings />
<div className="flex-grow max-w-[66%] -2xl:max-w-full">
<SectionRenderer />
</div>
</div>
</>
)}
</>
);
};
export default ExamEditor;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,109 +1,165 @@
import { useListSearch } from "@/hooks/useListSearch"
import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, useReactTable } from "@tanstack/react-table"
import clsx from "clsx"
import { useEffect, useState } from "react"
import { BsArrowDown, BsArrowUp } from "react-icons/bs"
import Button from "../Low/Button"
import { useListSearch } from "@/hooks/useListSearch";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
useReactTable,
} from "@tanstack/react-table";
import clsx from "clsx";
import { useEffect, useState } from "react";
import { BsArrowDown, BsArrowUp } from "react-icons/bs";
import Button from "../Low/Button";
interface Props<T> {
data: T[]
columns: ColumnDef<any, any>[]
searchFields: string[][]
size?: number
onDownload?: (rows: T[]) => void
isDownloadLoading?: boolean
searchPlaceholder?: string
data: T[];
columns: ColumnDef<any, any>[];
searchFields: string[][];
size?: number;
onDownload?: (rows: T[]) => void;
isDownloadLoading?: boolean;
searchPlaceholder?: string;
isLoading?: boolean;
}
export default function Table<T>({ data, columns, searchFields, size = 16, onDownload, isDownloadLoading, searchPlaceholder }: Props<T>) {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: size,
})
export default function Table<T>({
data,
columns,
searchFields,
size = 16,
onDownload,
isDownloadLoading,
searchPlaceholder,
isLoading,
}: Props<T>) {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: size,
});
const { rows, renderSearch } = useListSearch<T>(searchFields, data, searchPlaceholder);
const { rows, renderSearch } = useListSearch<T>(
searchFields,
data,
searchPlaceholder
);
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
state: {
pagination
}
});
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
state: {
pagination,
},
});
return (
<div className="w-full flex flex-col gap-2">
<div className="w-full flex gap-2 items-end">
{renderSearch()}
{onDownload && (
<Button isLoading={isDownloadLoading} className="w-full max-w-[200px] mb-1" variant="outline" onClick={() => onDownload(rows)}>
Download
</Button>
)
}
</div>
return (
<div className="w-full flex flex-col gap-2">
<div className="w-full flex gap-2 items-end">
{renderSearch()}
{onDownload && (
<Button
isLoading={isDownloadLoading}
className="w-full max-w-[200px] mb-1"
variant="outline"
onClick={() => onDownload(rows)}
>
Download
</Button>
)}
</div>
<div className="w-full flex gap-2 justify-between items-center">
<div className="flex items-center gap-4 w-fit">
<Button className="w-[200px] h-fit" disabled={!table.getCanPreviousPage()} onClick={() => table.previousPage()}>
Previous Page
</Button>
</div>
<div className="flex items-center gap-4 w-fit">
<span className="flex items-center gap-1">
<div>Page</div>
<strong>
{table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount().toLocaleString()}
</strong>
<div>| Total: {table.getRowCount().toLocaleString()}</div>
</span>
<Button className="w-[200px]" disabled={!table.getCanNextPage()} onClick={() => table.nextPage()}>
Next Page
</Button>
</div>
</div>
<div className="w-full flex gap-2 justify-between items-center">
<div className="flex items-center gap-4 w-fit">
<Button
className="w-[200px] h-fit"
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
>
Previous Page
</Button>
</div>
<div className="flex items-center gap-4 w-fit">
<span className="flex items-center gap-1">
<div>Page</div>
<strong>
{table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount().toLocaleString()}
</strong>
<div>| Total: {table.getRowCount().toLocaleString()}</div>
</span>
<Button
className="w-[200px]"
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
>
Next Page
</Button>
</div>
</div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id} colSpan={header.colSpan}>
<div
className={clsx(header.column.getCanSort() && 'cursor-pointer select-none', 'flex items-center gap-2')}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: <BsArrowUp />,
desc: <BsArrowDown />,
}[header.column.getIsSorted() as string] ?? null}
</div>
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2 w-full">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
className="py-4 px-4 text-left"
key={header.id}
colSpan={header.colSpan}
>
<div
className={clsx(
header.column.getCanSort() &&
"cursor-pointer select-none",
"flex items-center gap-2"
)}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: <BsArrowUp />,
desc: <BsArrowDown />,
}[header.column.getIsSorted() as string] ?? null}
</div>
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2 w-full">
{table.getRowModel().rows.map((row) => (
<tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
{isLoading ? (
<div className="min-h-screen flex justify-center items-start">
<span className="loading loading-infinity w-32" />
</div>
) : (
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>
);
}

View File

@@ -48,6 +48,15 @@ export default function AsyncSelect({
flat,
}: Props & (MultiProps | SingleProps)) {
const [target, setTarget] = useState<HTMLElement>();
const [inputValue, setInputValue] = useState("");
//Implemented a debounce to prevent the API from being called too frequently
useEffect(() => {
const timer = setTimeout(() => {
loadOptions(inputValue);
}, 200);
return () => clearTimeout(timer);
}, [inputValue, loadOptions]);
useEffect(() => {
if (document) setTarget(document.body);
@@ -77,7 +86,7 @@ export default function AsyncSelect({
filterOption={null}
loadingMessage={() => "Loading..."}
onInputChange={(inputValue) => {
loadOptions(inputValue);
setInputValue(inputValue);
}}
options={options}
value={value}

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,8 +14,6 @@ import {
BsPen,
BsXCircle,
} from "react-icons/bs";
import { totalExamsByModule } from "@/utils/stats";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import Button from "@/components/Low/Button";
import { sortByModuleName } from "@/utils/moduleUtils";
import { capitalize } from "lodash";
@@ -24,7 +22,7 @@ import { Variant } from "@/interfaces/exam";
import useSessions, { Session } from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/exam";
import moment from "moment";
import useStats from "../hooks/useStats";
interface Props {
user: User;
@@ -41,7 +39,21 @@ export default function Selection({ user, page, onStart }: Props) {
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const [variant, setVariant] = useState<Variant>("full");
const { data: stats } = useFilterRecordsByUser<Stat[]>(user?.id);
const {
data: {
allStats = [],
moduleCount: { reading, listening, writing, speaking, level } = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
},
},
} = useStats<{
allStats: Stat[];
moduleCount: Record<Module, number>;
}>(user?.id, !user?.id, "byModule");
const { sessions, isLoading, reload } = useSessions(user.id);
const dispatch = useExamStore((state) => state.dispatch);
@@ -77,7 +89,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
),
label: "Reading",
value: totalExamsByModule(stats, "reading"),
value: reading || 0,
tooltip: "The amount of reading exams performed.",
},
{
@@ -85,7 +97,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
),
label: "Listening",
value: totalExamsByModule(stats, "listening"),
value: listening || 0,
tooltip: "The amount of listening exams performed.",
},
{
@@ -93,7 +105,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
),
label: "Writing",
value: totalExamsByModule(stats, "writing"),
value: writing || 0,
tooltip: "The amount of writing exams performed.",
},
{
@@ -101,7 +113,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
),
label: "Speaking",
value: totalExamsByModule(stats, "speaking"),
value: speaking || 0,
tooltip: "The amount of speaking exams performed.",
},
{
@@ -109,7 +121,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
),
label: "Level",
value: totalExamsByModule(stats, "level"),
value: level || 0,
tooltip: "The amount of level exams performed.",
},
]}

View File

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

View File

@@ -0,0 +1,24 @@
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import axios from "axios";
import { useCallback, useEffect, useState } from "react";
export default function useApprovalWorkflows(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 };
}

View File

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

View File

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

View File

@@ -1,21 +1,21 @@
import {Exam} from "@/interfaces/exam";
import { Exam } from "@/interfaces/exam";
import axios from "axios";
import {useEffect, useState} from "react";
import { useEffect, useState } from "react";
export default function useExams() {
const [exams, setExams] = useState<Exam[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [exams, setExams] = useState<Exam[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Exam[]>("/api/exam")
.then((response) => setExams(response.data))
.finally(() => setIsLoading(false));
};
const getData = () => {
setIsLoading(true);
axios
.get<Exam[]>(`/api/exam`)
.then((response) => setExams(response.data))
.finally(() => setIsLoading(false));
};
useEffect(getData, []);
useEffect(getData, []);
return {exams, isLoading, isError, reload: getData};
return { exams, isLoading, isError, reload: getData };
}

View File

@@ -1,60 +1,141 @@
import Button from "@/components/Low/Button";
import {useMemo, useState} from "react";
import {BiChevronLeft} from "react-icons/bi";
import {BsChevronDoubleLeft, BsChevronDoubleRight, BsChevronLeft, BsChevronRight} from "react-icons/bs";
import { useEffect, useMemo, useState } from "react";
import {
BsChevronDoubleLeft,
BsChevronDoubleRight,
BsChevronLeft,
BsChevronRight,
} from "react-icons/bs";
import Select from "../components/Low/Select";
export default function usePagination<T>(list: T[], size = 25) {
const [page, setPage] = useState(0);
const [page, setPage] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState(size);
const items = useMemo(() => list.slice(page * size, (page + 1) * size), [page, size, list]);
const items = useMemo(
() => list.slice(page * itemsPerPage, (page + 1) * itemsPerPage),
[list, page, itemsPerPage]
);
useEffect(() => {
if (page * itemsPerPage >= list.length) setPage(0);
}, [items, itemsPerPage, list.length, page]);
const render = () => (
<div className="w-full flex gap-2 justify-between items-center">
<div className="flex items-center gap-4 w-fit">
<Button className="w-[200px] h-fit" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}>
Previous Page
</Button>
</div>
<div className="flex items-center gap-4 w-fit">
<span className="opacity-80">
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length}
</span>
<Button className="w-[200px]" disabled={(page + 1) * size >= list.length} onClick={() => setPage((prev) => prev + 1)}>
Next Page
</Button>
</div>
</div>
);
const itemsPerPageOptions = [25, 50, 100, 200];
const renderMinimal = () => (
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<button disabled={page === 0} onClick={() => setPage(0)} className="disabled:opacity-60 disabled:cursor-not-allowed">
<BsChevronDoubleLeft />
</button>
<button disabled={page === 0} onClick={() => setPage((prev) => prev - 1)} className="disabled:opacity-60 disabled:cursor-not-allowed">
<BsChevronLeft />
</button>
</div>
<span className="opacity-80 w-32 text-center">
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length}
</span>
<div className="flex gap-2 items-center">
<button
disabled={(page + 1) * size >= list.length}
onClick={() => setPage((prev) => prev + 1)}
className="disabled:opacity-60 disabled:cursor-not-allowed">
<BsChevronRight />
</button>
<button
disabled={(page + 1) * size >= list.length}
onClick={() => setPage(Math.floor(list.length / size))}
className="disabled:opacity-60 disabled:cursor-not-allowed">
<BsChevronDoubleRight />
</button>
</div>
</div>
);
const render = () => (
<div className="w-full flex gap-2 justify-between items-center">
<div className="flex items-center gap-4 w-fit">
<Button
className="w-[200px] h-fit"
disabled={page === 0}
onClick={() => setPage((prev) => prev - 1)}
>
Previous Page
</Button>
</div>
<div className="flex items-center gap-4 w-fit">
<div className="flex flex-row items-center gap-1 w-56">
<Select
value={{
value: itemsPerPage.toString(),
label: (itemsPerPage * page > items.length
? items.length
: itemsPerPage * page
).toString(),
}}
onChange={(value) =>
setItemsPerPage(parseInt(value!.value ?? "25"))
}
options={itemsPerPageOptions.map((size) => ({
label: size.toString(),
value: size.toString(),
}))}
isClearable={false}
styles={{
control: (styles) => ({ ...styles, width: "100px" }),
container: (styles) => ({ ...styles, width: "100px" }),
}}
/>
<span className="opacity-80 w-32 text-center">
{page * itemsPerPage + 1} -{" "}
{itemsPerPage * (page + 1) > list.length
? list.length
: itemsPerPage * (page + 1)}
{list.length}
</span>
</div>
<Button
className="w-[200px]"
disabled={(page + 1) * itemsPerPage >= list.length}
onClick={() => setPage((prev) => prev + 1)}
>
Next Page
</Button>
</div>
</div>
);
return {page, items, setPage, render, renderMinimal};
const renderMinimal = () => (
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<button
disabled={page === 0}
onClick={() => setPage(0)}
className="disabled:opacity-60 disabled:cursor-not-allowed"
>
<BsChevronDoubleLeft />
</button>
<button
disabled={page === 0}
onClick={() => setPage((prev) => prev - 1)}
className="disabled:opacity-60 disabled:cursor-not-allowed"
>
<BsChevronLeft />
</button>
</div>
<div className="flex flex-row items-center gap-1 w-56">
<Select
value={{
value: itemsPerPage.toString(),
label: itemsPerPage.toString(),
}}
onChange={(value) => setItemsPerPage(parseInt(value!.value ?? "25"))}
options={itemsPerPageOptions.map((size) => ({
label: size.toString(),
value: size.toString(),
}))}
isClearable={false}
styles={{
control: (styles) => ({ ...styles, width: "100px" }),
container: (styles) => ({ ...styles, width: "100px" }),
}}
/>
<span className="opacity-80 w-32 text-center">
{page * itemsPerPage + 1} -{" "}
{itemsPerPage * (page + 1) > list.length
? list.length
: itemsPerPage * (page + 1)}
/ {list.length}
</span>
</div>
<div className="flex gap-2 items-center">
<button
disabled={(page + 1) * itemsPerPage >= list.length}
onClick={() => setPage((prev) => prev + 1)}
className="disabled:opacity-60 disabled:cursor-not-allowed"
>
<BsChevronRight />
</button>
<button
disabled={(page + 1) * itemsPerPage >= list.length}
onClick={() => setPage(Math.floor(list.length / itemsPerPage))}
className="disabled:opacity-60 disabled:cursor-not-allowed"
>
<BsChevronDoubleRight />
</button>
</div>
</div>
);
return { page, items, setPage, render, renderMinimal };
}

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

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

View File

@@ -1,26 +1,28 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import axios from "axios";
const useTicketsListener = (userId?: string) => {
const useTicketsListener = (userId?: string, canFetch?: boolean) => {
const [assignedTickets, setAssignedTickets] = useState([]);
const getData = () => {
const getData = useCallback(() => {
axios
.get("/api/tickets/assignedToUser")
.then((response) => setAssignedTickets(response.data));
};
useEffect(() => {
getData();
}, []);
useEffect(() => {
if (!canFetch) return;
getData();
}, [canFetch, getData]);
useEffect(() => {
if (!canFetch) return;
const intervalId = setInterval(() => {
getData();
}, 60 * 1000);
return () => clearInterval(intervalId);
}, [assignedTickets]);
}, [assignedTickets, canFetch, getData]);
if (userId) {
return {

View 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",
};

View File

@@ -1,4 +1,3 @@
import instructions from "@/pages/api/exam/media/instructions";
import { Module } from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
@@ -10,6 +9,9 @@ export type Difficulty = BasicDifficulty | CEFRLevels;
// Left easy, medium and hard to support older exam versions
export type BasicDifficulty = "easy" | "medium" | "hard";
export type CEFRLevels = "A1" | "A2" | "B1" | "B2" | "C1" | "C2";
export const ACCESSTYPE = ["public", "private", "confidential"] as const;
export type AccessType = typeof ACCESSTYPE[number];
export interface ExamBase {
@@ -24,8 +26,9 @@ export interface ExamBase {
shuffle?: boolean;
createdBy?: string; // option as it has been added later
createdAt?: string; // option as it has been added later
private?: boolean;
access: AccessType;
label?: string;
requiresApproval?: boolean;
}
export interface ReadingExam extends ExamBase {
module: "reading";

View File

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

View File

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

View File

@@ -170,4 +170,24 @@ export interface Code {
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate";
export 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;

View 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;
}
}
// prettier-ignore
if (totalCount === 0) { // current behaviour: if no workflow was found skip approval process
await db.collection(examModule).updateOne(
{ id: examId },
{ $set: { id: examId, isDiagnostic: false }},
{ upsert: true }
);
}
return {
successCount,
totalCount,
};
}

View File

@@ -5,7 +5,9 @@ if (!process.env.MONGODB_URI) {
}
const uri = process.env.MONGODB_URI || "";
const options = {};
const options = {
maxPoolSize: 10,
};
let client: MongoClient;

View File

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

View File

@@ -15,170 +15,210 @@ import { findBy, mapBy } from "@/utils";
import useEntitiesCodes from "@/hooks/useEntitiesCodes";
import Table from "@/components/High/Table";
type TableData = Code & { entity?: EntityWithRoles, creator?: User }
type TableData = Code & { entity?: EntityWithRoles; creator?: User };
const columnHelper = createColumnHelper<TableData>();
export default function CodeList({ user, entities, canDeleteCodes }
: { user: User, entities: EntityWithRoles[], canDeleteCodes?: boolean }) {
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
export default function CodeList({
user,
entities,
canDeleteCodes,
}: {
user: User;
entities: EntityWithRoles[];
canDeleteCodes?: boolean;
}) {
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
const entityIDs = useMemo(() => mapBy(entities, 'id'), [entities])
const entityIDs = useMemo(() => mapBy(entities, "id"), [entities]);
const { users } = useUsers();
const { codes, reload } = useEntitiesCodes(isAdmin(user) ? undefined : entityIDs)
const { users } = useUsers();
const { codes, reload, isLoading } = useEntitiesCodes(
isAdmin(user) ? undefined : entityIDs
);
const data: TableData[] = useMemo(() => codes.map((code) => ({
...code,
entity: findBy(entities, 'id', code.entity),
creator: findBy(users, 'id', code.creator)
})) as TableData[], [codes, entities, users])
const data: TableData[] = useMemo(
() =>
codes.map((code) => ({
...code,
entity: findBy(entities, "id", code.entity),
creator: findBy(users, "id", code.creator),
})) as TableData[],
[codes, entities, users]
);
const toggleCode = (id: string) => {
setSelectedCodes((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const toggleCode = (id: string) => {
setSelectedCodes((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
// const toggleAllCodes = (checked: boolean) => {
// if (checked) return setSelectedCodes(visibleRows.filter((x) => !x.userId).map((x) => x.code));
// const toggleAllCodes = (checked: boolean) => {
// if (checked) return setSelectedCodes(visibleRows.filter((x) => !x.userId).map((x) => x.code));
// return setSelectedCodes([]);
// };
// return setSelectedCodes([]);
// };
const deleteCodes = async (codes: string[]) => {
if (!canDeleteCodes) return
if (!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)) return;
const deleteCodes = async (codes: string[]) => {
if (!canDeleteCodes) return;
if (
!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)
)
return;
const params = new URLSearchParams();
codes.forEach((code) => params.append("code", code));
const params = new URLSearchParams();
codes.forEach((code) => params.append("code", code));
axios
.delete(`/api/code?${params.toString()}`)
.then(() => {
toast.success(`Deleted the codes!`);
setSelectedCodes([]);
})
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Code not found!");
return;
}
axios
.delete(`/api/code?${params.toString()}`)
.then(() => {
toast.success(`Deleted the codes!`);
setSelectedCodes([]);
})
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Code not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this code!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this code!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
const deleteCode = async (code: Code) => {
if (!canDeleteCodes) return
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`)) return;
const deleteCode = async (code: Code) => {
if (!canDeleteCodes) return;
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`))
return;
axios
.delete(`/api/code/${code.code}`)
.then(() => toast.success(`Deleted the "${code.code}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Code not found!");
return;
}
axios
.delete(`/api/code/${code.code}`)
.then(() => toast.success(`Deleted the "${code.code}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Code not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this code!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this code!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
const defaultColumns = [
columnHelper.accessor("code", {
id: "codeCheckbox",
enableSorting: false,
header: () => (""),
cell: (info) =>
!info.row.original.userId ? (
<Checkbox isChecked={selectedCodes.includes(info.getValue())} onChange={() => toggleCode(info.getValue())}>
{""}
</Checkbox>
) : null,
}),
columnHelper.accessor("code", {
header: "Code",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("creationDate", {
header: "Creation Date",
cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A"),
}),
columnHelper.accessor("email", {
header: "E-mail",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("creator", {
header: "Creator",
cell: (info) => info.getValue() ? `${info.getValue().name} (${USER_TYPE_LABELS[info.getValue().type]})` : "N/A",
}),
columnHelper.accessor("entity", {
header: "Entity",
cell: (info) => info.getValue()?.label || "N/A",
}),
columnHelper.accessor("userId", {
header: "Availability",
cell: (info) =>
info.getValue() ? (
<span className="flex gap-1 items-center text-mti-green">
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
</span>
) : (
<span className="flex gap-1 items-center text-mti-red">
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
</span>
),
}),
{
header: "",
id: "actions",
cell: ({ row }: { row: { original: Code } }) => {
return (
<div className="flex gap-4">
{canDeleteCodes && !row.original.userId && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteCode(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
];
const defaultColumns = [
columnHelper.accessor("code", {
id: "codeCheckbox",
enableSorting: false,
header: () => "",
cell: (info) =>
!info.row.original.userId ? (
<Checkbox
isChecked={selectedCodes.includes(info.getValue())}
onChange={() => toggleCode(info.getValue())}
>
{""}
</Checkbox>
) : null,
}),
columnHelper.accessor("code", {
header: "Code",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("creationDate", {
header: "Creation Date",
cell: (info) =>
info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A",
}),
columnHelper.accessor("email", {
header: "E-mail",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("creator", {
header: "Creator",
cell: (info) =>
info.getValue()
? `${info.getValue().name} (${
USER_TYPE_LABELS[info.getValue().type]
})`
: "N/A",
}),
columnHelper.accessor("entity", {
header: "Entity",
cell: (info) => info.getValue()?.label || "N/A",
}),
columnHelper.accessor("userId", {
header: "Availability",
cell: (info) =>
info.getValue() ? (
<span className="flex gap-1 items-center text-mti-green">
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
</span>
) : (
<span className="flex gap-1 items-center text-mti-red">
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
</span>
),
}),
{
header: "",
id: "actions",
cell: ({ row }: { row: { original: Code } }) => {
return (
<div className="flex gap-4">
{canDeleteCodes && !row.original.userId && (
<div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteCode(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
];
return (
<>
<div className="flex items-center justify-between pb-4 pt-1">
{canDeleteCodes && (
<div className="flex gap-4 items-center w-full justify-end">
<span>{selectedCodes.length} code(s) selected</span>
<Button
disabled={selectedCodes.length === 0}
variant="outline"
color="red"
className="!py-1 px-10"
onClick={() => deleteCodes(selectedCodes)}>
Delete
</Button>
</div>
)}
</div>
<Table<TableData>
data={data}
columns={defaultColumns}
searchFields={[["code"], ["email"], ["entity", "label"], ["creator", "name"], ['creator', 'type']]}
/>
</>
);
return (
<>
<div className="flex items-center justify-between pb-4 pt-1">
{canDeleteCodes && (
<div className="flex gap-4 items-center w-full justify-end">
<span>{selectedCodes.length} code(s) selected</span>
<Button
disabled={selectedCodes.length === 0}
variant="outline"
color="red"
className="!py-1 px-10"
onClick={() => deleteCodes(selectedCodes)}
>
Delete
</Button>
</div>
)}
</div>
<Table<TableData>
data={data}
columns={defaultColumns}
isLoading={isLoading}
searchFields={[
["code"],
["email"],
["entity", "label"],
["creator", "name"],
["creator", "type"],
]}
/>
</>
);
}

View File

@@ -4,346 +4,391 @@ import useExams from "@/hooks/useExams";
import useUsers from "@/hooks/useUsers";
import { Module } from "@/interfaces";
import { Exam } from "@/interfaces/exam";
import { Type, User } from "@/interfaces/user";
import { User } from "@/interfaces/user";
import useExamStore from "@/stores/exam";
import { getExamById } from "@/utils/exams";
import { countExercises } from "@/utils/moduleUtils";
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios";
import clsx from "clsx";
import { capitalize, uniq } from "lodash";
import { capitalize } from "lodash";
import { useRouter } from "next/router";
import { BsBan, BsBanFill, BsCheck, BsCircle, BsPencil, BsStop, BsTrash, BsUpload, BsX } from "react-icons/bs";
import { BsCheck, BsPencil, BsTrash, BsUpload, BsX } from "react-icons/bs";
import { toast } from "react-toastify";
import { useListSearch } from "@/hooks/useListSearch";
import Modal from "@/components/Modal";
import { checkAccess } from "@/utils/permissions";
import useGroups from "@/hooks/useGroups";
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import Button from "@/components/Low/Button";
import { EntityWithRoles } from "@/interfaces/entity";
import { FiEdit, FiArrowRight } from 'react-icons/fi';
import { HiArrowRight } from "react-icons/hi";
import { BiEdit } from "react-icons/bi";
import { findBy, mapBy } from "@/utils";
const searchFields = [["module"], ["id"], ["createdBy"]];
const CLASSES: { [key in Module]: string } = {
reading: "text-ielts-reading",
listening: "text-ielts-listening",
speaking: "text-ielts-speaking",
writing: "text-ielts-writing",
level: "text-ielts-level",
reading: "text-ielts-reading",
listening: "text-ielts-listening",
speaking: "text-ielts-speaking",
writing: "text-ielts-writing",
level: "text-ielts-level",
};
const columnHelper = createColumnHelper<Exam>();
const ExamOwnerSelector = ({ options, exam, onSave }: { options: User[]; exam: Exam; onSave: (owners: string[]) => void }) => {
const [owners, setOwners] = useState(exam.owners || []);
export default function ExamList({
user,
entities,
}: {
user: User;
entities: EntityWithRoles[];
}) {
const [selectedExam, setSelectedExam] = useState<Exam>();
return (
<div className="w-full flex flex-col gap-4">
<div className="grid grid-cols-4 mt-4">
{options.map((c) => (
<Button
variant={owners.includes(c.id) ? "solid" : "outline"}
onClick={() => setOwners((prev) => (prev.includes(c.id) ? prev.filter((x) => x !== c.id) : [...prev, c.id]))}
className="max-w-[200px] w-full"
key={c.id}>
{c.name}
</Button>
))}
</div>
<Button onClick={() => onSave(owners)} className="w-full max-w-[200px] self-end">
Save
</Button>
</div>
);
};
const canViewConfidentialEntities = useMemo(
() =>
mapBy(
findAllowedEntities(user, entities, "view_confidential_exams"),
"id"
),
[user, entities]
);
export default function ExamList({ user, entities }: { user: User; entities: EntityWithRoles[]; }) {
const [selectedExam, setSelectedExam] = useState<Exam>();
const { exams, reload, isLoading } = useExams();
const { users } = useUsers();
// Pass this permission filter to the backend later
const filteredExams = useMemo(
() =>
["admin", "developer"].includes(user?.type)
? exams
: exams.filter((item) => {
if (
item.access === "confidential" &&
!canViewConfidentialEntities.find((x) =>
(item.entities ?? []).includes(x)
)
)
return false;
return true;
}),
[canViewConfidentialEntities, exams, user?.type]
);
const { exams, reload } = useExams();
const { users } = useUsers();
const { groups } = useGroups({ admin: user?.id, userType: user?.type });
const parsedExams = useMemo(() => {
return filteredExams.map((exam) => {
if (exam.createdBy) {
const user = users.find((u) => u.id === exam.createdBy);
if (!user) return exam;
const filteredExams = useMemo(() => exams.filter((e) => {
if (!e.private) return true
return (e.owners || []).includes(user?.id || "")
}), [exams, user?.id])
return {
...exam,
createdBy: user.type === "developer" ? "system" : user.name,
};
}
const filteredCorporates = useMemo(() => {
const participantsAndAdmins = uniq(groups.flatMap((x) => [...x.participants, x.admin])).filter((x) => x !== user?.id);
return users.filter((x) => participantsAndAdmins.includes(x.id) && x.type === "corporate");
}, [users, groups, user]);
return exam;
});
}, [filteredExams, users]);
const parsedExams = useMemo(() => {
return filteredExams.map((exam) => {
if (exam.createdBy) {
const user = users.find((u) => u.id === exam.createdBy);
if (!user) return exam;
const { rows: filteredRows, renderSearch } = useListSearch<Exam>(
searchFields,
parsedExams
);
return {
...exam,
createdBy: user.type === "developer" ? "system" : user.name,
};
}
const dispatch = useExamStore((state) => state.dispatch);
return exam;
});
}, [filteredExams, users]);
const router = useRouter();
const { rows: filteredRows, renderSearch } = useListSearch<Exam>(searchFields, parsedExams);
const loadExam = async (module: Module, examId: string) => {
const exam = await getExamById(module, examId.trim());
if (!exam) {
toast.error(
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
{
toastId: "invalid-exam-id",
}
);
const dispatch = useExamStore((state) => state.dispatch);
return;
}
dispatch({
type: "INIT_EXAM",
payload: { exams: [exam], modules: [module] },
});
const router = useRouter();
router.push("/exam");
};
const loadExam = async (module: Module, examId: string) => {
const exam = await getExamById(module, examId.trim());
if (!exam) {
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
toastId: "invalid-exam-id",
});
/*
const privatizeExam = async (exam: Exam) => {
if (
!confirm(
`Are you sure you want to make this ${capitalize(exam.module)} exam ${
exam.access
}?`
)
)
return;
return;
}
dispatch({ type: "INIT_EXAM", payload: { exams: [exam], modules: [module] } })
axios
.patch(`/api/exam/${exam.module}/${exam.id}`, { private: !exam.private })
.then(() => toast.success(`Updated the "${exam.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
router.push("/exam");
};
if (reason.response.status === 403) {
toast.error("You do not have permission to update this exam!");
return;
}
const privatizeExam = async (exam: Exam) => {
if (!confirm(`Are you sure you want to make this ${capitalize(exam.module)} exam ${exam.private ? "public" : "private"}?`)) return;
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
*/
axios
.patch(`/api/exam/${exam.module}/${exam.id}`, { private: !exam.private })
.then(() => toast.success(`Updated the "${exam.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
const deleteExam = async (exam: Exam) => {
if (
!confirm(
`Are you sure you want to delete this ${capitalize(exam.module)} exam?`
)
)
return;
if (reason.response.status === 403) {
toast.error("You do not have permission to update this exam!");
return;
}
axios
.delete(`/api/exam/${exam.module}/${exam.id}`)
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this exam!");
return;
}
const updateExam = async (exam: Exam, body: object) => {
if (!confirm(`Are you sure you want to update this ${capitalize(exam.module)} exam?`)) return;
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
axios
.patch(`/api/exam/${exam.module}/${exam.id}`, body)
.then(() => toast.success(`Updated the "${exam.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
const getTotalExercises = (exam: Exam) => {
if (
exam.module === "reading" ||
exam.module === "listening" ||
exam.module === "level"
) {
return countExercises((exam.parts ?? []).flatMap((x) => x.exercises));
}
if (reason.response.status === 403) {
toast.error("You do not have permission to update this exam!");
return;
}
return countExercises(exam.exercises);
};
toast.error("Something went wrong, please try again later.");
})
.finally(reload)
.finally(() => setSelectedExam(undefined));
};
const defaultColumns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("module", {
header: "Module",
cell: (info) => (
<span className={CLASSES[info.getValue()]}>
{capitalize(info.getValue())}
</span>
),
}),
columnHelper.accessor((x) => getTotalExercises(x), {
header: "Exercises",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("minTimer", {
header: "Timer",
cell: (info) => <>{info.getValue()} minute(s)</>,
}),
columnHelper.accessor("access", {
header: "Access",
cell: (info) => <span>{capitalize(info.getValue())}</span>,
}),
columnHelper.accessor("createdAt", {
header: "Created At",
cell: (info) => {
const value = info.getValue();
if (value) {
return new Date(value).toLocaleDateString();
}
const deleteExam = async (exam: Exam) => {
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
return null;
},
}),
columnHelper.accessor("createdBy", {
header: "Created By",
cell: (info) =>
!info.getValue()
? "System"
: findBy(users, "id", info.getValue())?.name || "N/A",
}),
{
header: "",
id: "actions",
cell: ({ row }: { row: { original: Exam } }) => {
return (
<div className="flex gap-4">
{(row.original.owners?.includes(user.id) ||
checkAccess(user, ["admin", "developer"])) && (
<>
{checkAccess(user, [
"admin",
"developer",
"mastercorporate",
]) && (
<button
data-tip="Edit exam"
onClick={() => setSelectedExam(row.original)}
className="cursor-pointer tooltip"
>
<BsPencil />
</button>
)}
</>
)}
<button
data-tip="Load exam"
className="cursor-pointer tooltip"
onClick={async () =>
await loadExam(row.original.module, row.original.id)
}
>
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</button>
{PERMISSIONS.examManagement.delete.includes(user.type) && (
<div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteExam(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
];
axios
.delete(`/api/exam/${exam.module}/${exam.id}`)
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
const table = useReactTable({
data: filteredRows,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this exam!");
return;
}
const handleExamEdit = () => {
router.push(
`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`
);
};
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
return (
<div className="flex flex-col gap-4 w-full h-full">
{renderSearch()}
<Modal
isOpen={!!selectedExam}
onClose={() => setSelectedExam(undefined)}
maxWidth="max-w-xl"
>
{!!selectedExam ? (
<>
<div className="p-6">
<div className="mb-6">
<div className="flex items-center gap-2 mb-4">
<BiEdit className="w-5 h-5 text-gray-600" />
<span className="text-gray-600 font-medium">
Ready to Edit
</span>
</div>
const getTotalExercises = (exam: Exam) => {
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
return countExercises(exam.parts.flatMap((x) => x.exercises));
}
<div className="bg-gray-50 rounded-lg p-4 mb-3">
<p className="font-medium mb-1">Exam ID: {selectedExam.id}</p>
</div>
return countExercises(exam.exercises);
};
<p className="text-gray-500 text-sm">
Click &apos;Next&apos; to proceed to the exam editor.
</p>
</div>
const defaultColumns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("module", {
header: "Module",
cell: (info) => <span className={CLASSES[info.getValue()]}>{capitalize(info.getValue())}</span>,
}),
columnHelper.accessor((x) => getTotalExercises(x), {
header: "Exercises",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("minTimer", {
header: "Timer",
cell: (info) => <>{info.getValue()} minute(s)</>,
}),
columnHelper.accessor("private", {
header: "Private",
cell: (info) => <span className="w-full flex items-center justify-center">{!info.getValue() ? <BsX /> : <BsCheck />}</span>,
}),
columnHelper.accessor("createdAt", {
header: "Created At",
cell: (info) => {
const value = info.getValue();
if (value) {
return new Date(value).toLocaleDateString();
}
return null;
},
}),
columnHelper.accessor("createdBy", {
header: "Created By",
cell: (info) => info.getValue(),
}),
{
header: "",
id: "actions",
cell: ({ row }: { row: { original: Exam } }) => {
return (
<div className="flex gap-4">
{(row.original.owners?.includes(user.id) || checkAccess(user, ["admin", "developer"])) && (
<>
<button
data-tip={row.original.private ? "Set as public" : "Set as private"}
onClick={async () => await privatizeExam(row.original)}
className="cursor-pointer tooltip">
{row.original.private ? <BsCircle /> : <BsBan />}
</button>
{checkAccess(user, ["admin", "developer", "mastercorporate"]) && (
<button data-tip="Edit exam" onClick={() => setSelectedExam(row.original)} className="cursor-pointer tooltip">
<BsPencil />
</button>
)}
</>
)}
<button
data-tip="Load exam"
className="cursor-pointer tooltip"
onClick={async () => await loadExam(row.original.module, row.original.id)}>
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</button>
{PERMISSIONS.examManagement.delete.includes(user.type) && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
];
const table = useReactTable({
data: filteredRows,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const handleExamEdit = () => {
router.push(`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`);
}
return (
<div className="flex flex-col gap-4 w-full h-full">
{renderSearch()}
<Modal isOpen={!!selectedExam} onClose={() => setSelectedExam(undefined)} maxWidth="max-w-xl">
{!!selectedExam ? (
<>
<div className="p-6">
<div className="mb-6">
<div className="flex items-center gap-2 mb-4">
<BiEdit className="w-5 h-5 text-gray-600" />
<span className="text-gray-600 font-medium">Ready to Edit</span>
</div>
<div className="bg-gray-50 rounded-lg p-4 mb-3">
<p className="font-medium mb-1">
Exam ID: {selectedExam.id}
</p>
</div>
<p className="text-gray-500 text-sm">
Click &apos;Next&apos; to proceed to the exam editor.
</p>
</div>
<div className="flex justify-between gap-4 mt-8">
<Button
color="purple"
variant="outline"
onClick={() => setSelectedExam(undefined)}
className="w-32"
>
Cancel
</Button>
<Button
color="purple"
onClick={handleExamEdit}
className="w-32 text-white flex items-center justify-center gap-2"
>
Proceed
</Button>
</div>
</div>
{/*<ExamOwnerSelector options={filteredCorporates} exam={selectedExam} onSave={(owners) => updateExam(selectedExam, { owners })} />*/}
</>
) : (
<div />
)}
</Modal>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
<div className="flex justify-between gap-4 mt-8">
<Button
color="purple"
variant="outline"
onClick={() => setSelectedExam(undefined)}
className="w-32"
>
Cancel
</Button>
<Button
color="purple"
onClick={handleExamEdit}
className="w-32 text-white flex items-center justify-center gap-2"
>
Proceed
</Button>
</div>
</div>
{/*<ExamOwnerSelector options={filteredCorporates} exam={selectedExam} onSave={(owners) => updateExam(selectedExam, { owners })} />*/}
</>
) : (
<div />
)}
</Modal>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
{isLoading ? (
<div className="min-h-screen flex justify-center items-start">
<span className="loading loading-infinity w-32" />
</div>
) : (
filteredRows.length === 0 && (
<div className="w-full flex justify-center items-start">
<span className="text-xl text-gray-500">No data found...</span>
</div>
)
)}
</div>
);
}

View File

@@ -6,7 +6,12 @@ import clsx from "clsx";
import { capitalize } from "lodash";
import moment from "moment";
import { useEffect, useMemo, useState } from "react";
import { BsCheck, BsCheckCircle, BsFillExclamationOctagonFill, BsTrash } from "react-icons/bs";
import {
BsCheck,
BsCheckCircle,
BsFillExclamationOctagonFill,
BsTrash,
} from "react-icons/bs";
import { toast } from "react-toastify";
import { countries, TCountries } from "countries-list";
import countryCodes from "country-codes-list";
@@ -17,433 +22,600 @@ import useFilterStore from "@/stores/listFilterStore";
import { useRouter } from "next/router";
import { mapBy } from "@/utils";
import { exportListToExcel } from "@/utils/users";
import usePermissions from "@/hooks/usePermissions";
import useUserBalance from "@/hooks/useUserBalance";
import useEntitiesUsers from "@/hooks/useEntitiesUsers";
import { WithLabeledEntities } from "@/interfaces/entity";
import Table from "@/components/High/Table";
import useEntities from "@/hooks/useEntities";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { findAllowedEntities } from "@/utils/permissions";
const columnHelper = createColumnHelper<WithLabeledEntities<User>>();
const searchFields = [["name"], ["email"], ["entities", ""]];
export default function UserList({
user,
filters = [],
type,
renderHeader,
user,
filters = [],
type,
renderHeader,
}: {
user: User;
filters?: ((user: User) => boolean)[];
type?: Type;
renderHeader?: (total: number) => JSX.Element;
user: User;
filters?: ((user: User) => boolean)[];
type?: Type;
renderHeader?: (total: number) => JSX.Element;
}) {
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
const [selectedUser, setSelectedUser] = useState<User>();
const [showDemographicInformation, setShowDemographicInformation] =
useState(false);
const [selectedUser, setSelectedUser] = useState<User>();
const { users, reload } = useEntitiesUsers(type)
const { entities } = useEntities()
const { users, isLoading, reload } = useEntitiesUsers(type);
const { entities } = useEntities();
const isAdmin = useMemo(() => ["admin", "developer"].includes(user?.type), [user?.type])
const isAdmin = useMemo(
() => ["admin", "developer"].includes(user?.type),
[user?.type]
);
const entitiesViewStudents = useAllowedEntities(user, entities, "view_students")
const entitiesEditStudents = useAllowedEntities(user, entities, "edit_students")
const entitiesDeleteStudents = useAllowedEntities(user, entities, "delete_students")
const entitiesViewStudents = useAllowedEntities(
user,
entities,
"view_students"
);
const entitiesEditStudents = useAllowedEntities(
user,
entities,
"edit_students"
);
const entitiesDeleteStudents = useAllowedEntities(
user,
entities,
"delete_students"
);
const entitiesViewTeachers = useAllowedEntities(user, entities, "view_teachers")
const entitiesEditTeachers = useAllowedEntities(user, entities, "edit_teachers")
const entitiesDeleteTeachers = useAllowedEntities(user, entities, "delete_teachers")
const entitiesViewTeachers = useAllowedEntities(
user,
entities,
"view_teachers"
);
const entitiesEditTeachers = useAllowedEntities(
user,
entities,
"edit_teachers"
);
const entitiesDeleteTeachers = useAllowedEntities(
user,
entities,
"delete_teachers"
);
const entitiesViewCorporates = useAllowedEntities(user, entities, "view_corporates")
const entitiesEditCorporates = useAllowedEntities(user, entities, "edit_corporates")
const entitiesDeleteCorporates = useAllowedEntities(user, entities, "delete_corporates")
const entitiesViewCorporates = useAllowedEntities(
user,
entities,
"view_corporates"
);
const entitiesEditCorporates = useAllowedEntities(
user,
entities,
"edit_corporates"
);
const entitiesDeleteCorporates = useAllowedEntities(
user,
entities,
"delete_corporates"
);
const entitiesViewMasterCorporates = useAllowedEntities(user, entities, "view_mastercorporates")
const entitiesEditMasterCorporates = useAllowedEntities(user, entities, "edit_mastercorporates")
const entitiesDeleteMasterCorporates = useAllowedEntities(user, entities, "delete_mastercorporates")
const entitiesViewMasterCorporates = useAllowedEntities(
user,
entities,
"view_mastercorporates"
);
const entitiesEditMasterCorporates = useAllowedEntities(
user,
entities,
"edit_mastercorporates"
);
const entitiesDeleteMasterCorporates = useAllowedEntities(
user,
entities,
"delete_mastercorporates"
);
const entitiesDownloadUsers = useAllowedEntities(user, entities, "download_user_list")
const entitiesDownloadUsers = useAllowedEntities(
user,
entities,
"download_user_list"
);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
const expirationDateColor = (date: Date) => {
const momentDate = moment(date);
const today = moment(new Date());
const expirationDateColor = (date: Date) => {
const momentDate = moment(date);
const today = moment(new Date());
if (today.isAfter(momentDate)) return "!text-mti-red-light font-bold line-through";
if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light";
if (today.add(2, "weeks").isAfter(momentDate)) return "!text-mti-rose-light";
if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-light";
};
if (today.isAfter(momentDate))
return "!text-mti-red-light font-bold line-through";
if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light";
if (today.add(2, "weeks").isAfter(momentDate))
return "!text-mti-rose-light";
if (today.add(1, "months").isAfter(momentDate))
return "!text-mti-orange-light";
};
const allowedUsers = useMemo(() => users.filter((u) => {
if (isAdmin) return true
if (u.id === user?.id) return false
const allowedUsers = useMemo(
() =>
users.filter((u) => {
if (isAdmin) return true;
if (u.id === user?.id) return false;
switch (u.type) {
case "student": return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewStudents, 'id').includes(id))
case "teacher": return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewTeachers, 'id').includes(id))
case 'corporate': return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewCorporates, 'id').includes(id))
case 'mastercorporate': return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewMasterCorporates, 'id').includes(id))
default: return false
}
})
, [entitiesViewCorporates, entitiesViewMasterCorporates, entitiesViewStudents, entitiesViewTeachers, isAdmin, user?.id, users])
switch (u.type) {
case "student":
return mapBy(u.entities || [], "id").some((id) =>
mapBy(entitiesViewStudents, "id").includes(id)
);
case "teacher":
return mapBy(u.entities || [], "id").some((id) =>
mapBy(entitiesViewTeachers, "id").includes(id)
);
case "corporate":
return mapBy(u.entities || [], "id").some((id) =>
mapBy(entitiesViewCorporates, "id").includes(id)
);
case "mastercorporate":
return mapBy(u.entities || [], "id").some((id) =>
mapBy(entitiesViewMasterCorporates, "id").includes(id)
);
default:
return false;
}
}),
[
entitiesViewCorporates,
entitiesViewMasterCorporates,
entitiesViewStudents,
entitiesViewTeachers,
isAdmin,
user?.id,
users,
]
);
const displayUsers = useMemo(() =>
filters.length > 0 ? filters.reduce((d, f) => d.filter(f), allowedUsers) : allowedUsers,
[filters, allowedUsers])
const displayUsers = useMemo(
() =>
filters.length > 0
? filters.reduce((d, f) => d.filter(f), allowedUsers)
: allowedUsers,
[filters, allowedUsers]
);
const deleteAccount = (user: User) => {
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return;
const deleteAccount = (user: User) => {
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`))
return;
axios
.delete<{ ok: boolean }>(`/api/user?id=${user.id}`)
.then(() => {
toast.success("User deleted successfully!");
reload()
})
.catch(() => {
toast.error("Something went wrong!", { toastId: "delete-error" });
})
.finally(reload);
};
axios
.delete<{ ok: boolean }>(`/api/user?id=${user.id}`)
.then(() => {
toast.success("User deleted successfully!");
reload();
})
.catch(() => {
toast.error("Something went wrong!", { toastId: "delete-error" });
})
.finally(reload);
};
const verifyAccount = (user: User) => {
axios
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
...user,
isVerified: true,
})
.then(() => {
toast.success("User verified successfully!");
reload();
})
.catch(() => {
toast.error("Something went wrong!", { toastId: "update-error" });
});
};
const verifyAccount = (user: User) => {
axios
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
...user,
isVerified: true,
})
.then(() => {
toast.success("User verified successfully!");
reload();
})
.catch(() => {
toast.error("Something went wrong!", { toastId: "update-error" });
});
};
const toggleDisableAccount = (user: User) => {
if (
!confirm(
`Are you sure you want to ${user.status === "disabled" ? "enable" : "disable"} ${user.name
}'s account? This change is usually related to their payment state.`,
)
)
return;
const toggleDisableAccount = (user: User) => {
if (
!confirm(
`Are you sure you want to ${
user.status === "disabled" ? "enable" : "disable"
} ${
user.name
}'s account? This change is usually related to their payment state.`
)
)
return;
axios
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
...user,
status: user.status === "disabled" ? "active" : "disabled",
})
.then(() => {
toast.success(`User ${user.status === "disabled" ? "enabled" : "disabled"} successfully!`);
reload();
})
.catch(() => {
toast.error("Something went wrong!", { toastId: "update-error" });
});
};
axios
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
...user,
status: user.status === "disabled" ? "active" : "disabled",
})
.then(() => {
toast.success(
`User ${
user.status === "disabled" ? "enabled" : "disabled"
} successfully!`
);
reload();
})
.catch(() => {
toast.error("Something went wrong!", { toastId: "update-error" });
});
};
const getEditPermission = (type: Type) => {
if (type === "student") return entitiesEditStudents
if (type === "teacher") return entitiesEditTeachers
if (type === "corporate") return entitiesEditCorporates
if (type === "mastercorporate") return entitiesEditMasterCorporates
const getEditPermission = (type: Type) => {
if (type === "student") return entitiesEditStudents;
if (type === "teacher") return entitiesEditTeachers;
if (type === "corporate") return entitiesEditCorporates;
if (type === "mastercorporate") return entitiesEditMasterCorporates;
return []
}
return [];
};
const getDeletePermission = (type: Type) => {
if (type === "student") return entitiesDeleteStudents
if (type === "teacher") return entitiesDeleteTeachers
if (type === "corporate") return entitiesDeleteCorporates
if (type === "mastercorporate") return entitiesDeleteMasterCorporates
const getDeletePermission = (type: Type) => {
if (type === "student") return entitiesDeleteStudents;
if (type === "teacher") return entitiesDeleteTeachers;
if (type === "corporate") return entitiesDeleteCorporates;
if (type === "mastercorporate") return entitiesDeleteMasterCorporates;
return []
}
return [];
};
const canEditUser = (u: User) =>
isAdmin || u.entities.some(e => mapBy(getEditPermission(u.type), 'id').includes(e.id))
const canEditUser = (u: User) =>
isAdmin ||
u.entities.some((e) =>
mapBy(getEditPermission(u.type), "id").includes(e.id)
);
const canDeleteUser = (u: User) =>
isAdmin || u.entities.some(e => mapBy(getDeletePermission(u.type), 'id').includes(e.id))
const canDeleteUser = (u: User) =>
isAdmin ||
u.entities.some((e) =>
mapBy(getDeletePermission(u.type), "id").includes(e.id)
);
const actionColumn = ({ row }: { row: { original: User } }) => {
const canEdit = canEditUser(row.original)
const canDelete = canDeleteUser(row.original)
const actionColumn = ({ row }: { row: { original: User } }) => {
const canEdit = canEditUser(row.original);
const canDelete = canDeleteUser(row.original);
return (
<div className="flex gap-4">
{!row.original.isVerified && canEdit && (
<div data-tip="Verify User" className="cursor-pointer tooltip" onClick={() => verifyAccount(row.original)}>
<BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
{canEdit && (
<div
data-tip={row.original.status === "disabled" ? "Enable User" : "Disable User"}
className="cursor-pointer tooltip"
onClick={() => toggleDisableAccount(row.original)}>
{row.original.status === "disabled" ? (
<BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" />
) : (
<BsFillExclamationOctagonFill className="hover:text-mti-purple-light transition ease-in-out duration-300" />
)}
</div>
)}
{canDelete && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteAccount(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
};
return (
<div className="flex gap-4">
{!row.original.isVerified && canEdit && (
<div
data-tip="Verify User"
className="cursor-pointer tooltip"
onClick={() => verifyAccount(row.original)}
>
<BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
{canEdit && (
<div
data-tip={
row.original.status === "disabled"
? "Enable User"
: "Disable User"
}
className="cursor-pointer tooltip"
onClick={() => toggleDisableAccount(row.original)}
>
{row.original.status === "disabled" ? (
<BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" />
) : (
<BsFillExclamationOctagonFill className="hover:text-mti-purple-light transition ease-in-out duration-300" />
)}
</div>
)}
{canDelete && (
<div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteAccount(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
};
const demographicColumns = [
columnHelper.accessor("name", {
header: "Name",
cell: ({ row, getValue }) => (
<div
className={clsx(
canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
)}
onClick={() =>
canEditUser(row.original) ? setSelectedUser(row.original) : null
}>
{getValue()}
</div>
),
}),
columnHelper.accessor("demographicInformation.country", {
header: "Country",
cell: (info) =>
info.getValue()
? `${countryCodes.findOne("countryCode" as any, info.getValue())?.flag} ${countries[info.getValue() as unknown as keyof TCountries]?.name
} (+${countryCodes.findOne("countryCode" as any, info.getValue())?.countryCallingCode})`
: "N/A",
}),
columnHelper.accessor("demographicInformation.phone", {
header: "Phone",
cell: (info) => info.getValue() || "N/A",
enableSorting: true,
}),
columnHelper.accessor(
(x) =>
x.type === "corporate" || x.type === "mastercorporate" ? x.demographicInformation?.position : x.demographicInformation?.employment,
{
id: "employment",
header: "Employment",
cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "N/A",
enableSorting: true,
},
),
columnHelper.accessor("lastLogin", {
header: "Last Login",
cell: (info) => (!!info.getValue() ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") : "N/A"),
}),
columnHelper.accessor("demographicInformation.gender", {
header: "Gender",
cell: (info) => capitalize(info.getValue()) || "N/A",
enableSorting: true,
}),
{
header: (
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
Switch
</span>
),
id: "actions",
cell: actionColumn,
sortable: false
},
];
const demographicColumns = [
columnHelper.accessor("name", {
header: "Name",
cell: ({ row, getValue }) => (
<div
className={clsx(
canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
)}
onClick={() =>
canEditUser(row.original) ? setSelectedUser(row.original) : null
}
>
{getValue()}
</div>
),
}),
columnHelper.accessor("demographicInformation.country", {
header: "Country",
cell: (info) =>
info.getValue()
? `${
countryCodes.findOne("countryCode" as any, info.getValue())?.flag
} ${
countries[info.getValue() as unknown as keyof TCountries]?.name
} (+${
countryCodes.findOne("countryCode" as any, info.getValue())
?.countryCallingCode
})`
: "N/A",
}),
columnHelper.accessor("demographicInformation.phone", {
header: "Phone",
cell: (info) => info.getValue() || "N/A",
enableSorting: true,
}),
columnHelper.accessor(
(x) =>
x.type === "corporate" || x.type === "mastercorporate"
? x.demographicInformation?.position
: x.demographicInformation?.employment,
{
id: "employment",
header: "Employment",
cell: (info) =>
(info.row.original.type === "corporate"
? info.getValue()
: capitalize(info.getValue())) || "N/A",
enableSorting: true,
}
),
columnHelper.accessor("lastLogin", {
header: "Last Login",
cell: (info) =>
!!info.getValue()
? moment(info.getValue()).format("YYYY-MM-DD HH:mm")
: "N/A",
}),
columnHelper.accessor("demographicInformation.gender", {
header: "Gender",
cell: (info) => capitalize(info.getValue()) || "N/A",
enableSorting: true,
}),
{
header: (
<span
className="cursor-pointer"
onClick={() => setShowDemographicInformation((prev) => !prev)}
>
Switch
</span>
),
id: "actions",
cell: actionColumn,
sortable: false,
},
];
const defaultColumns = [
columnHelper.accessor("name", {
header: "Name",
cell: ({ row, getValue }) => (
<div
className={clsx(
canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
)}
onClick={() =>
canEditUser(row.original) ? setSelectedUser(row.original) : null
}>
{getValue()}
</div>
),
}),
columnHelper.accessor("email", {
header: "E-mail",
cell: ({ row, getValue }) => (
<div
className={clsx(
canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
)}
onClick={() => (canEditUser(row.original) ? setSelectedUser(row.original) : null)}>
{getValue()}
</div>
),
}),
columnHelper.accessor("type", {
header: "Type",
cell: (info) => USER_TYPE_LABELS[info.getValue()],
}),
columnHelper.accessor("studentID", {
header: "Student ID",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("entities", {
header: "Entities",
cell: ({ getValue }) => mapBy(getValue(), 'label').join(', '),
}),
columnHelper.accessor("subscriptionExpirationDate", {
header: "Expiration",
cell: (info) => (
<span className={clsx(info.getValue() ? expirationDateColor(moment(info.getValue()).toDate()) : "")}>
{!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")}
</span>
),
}),
columnHelper.accessor("isVerified", {
header: "Verified",
cell: (info) => (
<div className="flex gap-3 items-center text-mti-gray-dim text-sm self-center">
<div
className={clsx(
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
"transition duration-300 ease-in-out",
info.getValue() && "!bg-mti-purple-light ",
)}>
<BsCheck color="white" className="w-full h-full" />
</div>
</div>
),
}),
{
header: (
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
Switch
</span>
),
id: "actions",
cell: actionColumn,
sortable: false
},
];
const defaultColumns = [
columnHelper.accessor("name", {
header: "Name",
cell: ({ row, getValue }) => (
<div
className={clsx(
canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
)}
onClick={() =>
canEditUser(row.original) ? setSelectedUser(row.original) : null
}
>
{getValue()}
</div>
),
}),
columnHelper.accessor("email", {
header: "E-mail",
cell: ({ row, getValue }) => (
<div
className={clsx(
canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
)}
onClick={() =>
canEditUser(row.original) ? setSelectedUser(row.original) : null
}
>
{getValue()}
</div>
),
}),
columnHelper.accessor("type", {
header: "Type",
cell: (info) => USER_TYPE_LABELS[info.getValue()],
}),
columnHelper.accessor("studentID", {
header: "Student ID",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("entities", {
header: "Entities",
cell: ({ getValue }) => mapBy(getValue(), "label").join(", "),
}),
columnHelper.accessor("subscriptionExpirationDate", {
header: "Expiration",
cell: (info) => (
<span
className={clsx(
info.getValue()
? expirationDateColor(moment(info.getValue()).toDate())
: ""
)}
>
{!info.getValue()
? "No expiry date"
: moment(info.getValue()).format("DD/MM/YYYY")}
</span>
),
}),
columnHelper.accessor("isVerified", {
header: "Verified",
cell: (info) => (
<div className="flex gap-3 items-center text-mti-gray-dim text-sm self-center">
<div
className={clsx(
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
"transition duration-300 ease-in-out",
info.getValue() && "!bg-mti-purple-light "
)}
>
<BsCheck color="white" className="w-full h-full" />
</div>
</div>
),
}),
{
header: (
<span
className="cursor-pointer"
onClick={() => setShowDemographicInformation((prev) => !prev)}
>
Switch
</span>
),
id: "actions",
cell: actionColumn,
sortable: false,
},
];
const downloadExcel = (rows: WithLabeledEntities<User>[]) => {
if (entitiesDownloadUsers.length === 0) return toast.error("You are not allowed to download the user list.")
const downloadExcel = async (rows: WithLabeledEntities<User>[]) => {
if (entitiesDownloadUsers.length === 0)
return toast.error("You are not allowed to download the user list.");
const allowedRows = rows.filter(r => mapBy(r.entities, 'id').some(e => mapBy(entitiesDownloadUsers, 'id').includes(e)))
const csv = exportListToExcel(allowedRows);
const allowedRows = rows;
const csv = await exportListToExcel(allowedRows);
const element = document.createElement("a");
const file = new Blob([csv], { type: "text/csv" });
element.href = URL.createObjectURL(file);
element.download = "users.csv";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
};
const element = document.createElement("a");
const file = new Blob([csv], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
element.href = URL.createObjectURL(file);
element.download = "users.xlsx";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
};
const viewStudentFilter = (x: User) => x.type === "student";
const viewTeacherFilter = (x: User) => x.type === "teacher";
const belongsToAdminFilter = (x: User) => x.entities.some(({ id }) => mapBy(selectedUser?.entities || [], 'id').includes(id));
const viewStudentFilter = (x: User) => x.type === "student";
const viewTeacherFilter = (x: User) => x.type === "teacher";
const belongsToAdminFilter = (x: User) =>
x.entities.some(({ id }) =>
mapBy(selectedUser?.entities || [], "id").includes(id)
);
const viewStudentFilterBelongsToAdmin = (x: User) => viewStudentFilter(x) && belongsToAdminFilter(x);
const viewTeacherFilterBelongsToAdmin = (x: User) => viewTeacherFilter(x) && belongsToAdminFilter(x);
const viewStudentFilterBelongsToAdmin = (x: User) =>
viewStudentFilter(x) && belongsToAdminFilter(x);
const viewTeacherFilterBelongsToAdmin = (x: User) =>
viewTeacherFilter(x) && belongsToAdminFilter(x);
const renderUserCard = (selectedUser: User) => {
const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin);
const teachersFromAdmin = users.filter(viewTeacherFilterBelongsToAdmin);
return (
<div className="w-full flex flex-col gap-8">
<UserCard
maxUserAmount={0}
loggedInUser={user}
onViewStudents={
(selectedUser.type === "corporate" || selectedUser.type === "teacher") && studentsFromAdmin.length > 0
? () => {
appendUserFilters({
id: "view-students",
filter: viewStudentFilter,
});
appendUserFilters({
id: "belongs-to-admin",
filter: belongsToAdminFilter,
});
const renderUserCard = (selectedUser: User) => {
const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin);
const teachersFromAdmin = users.filter(viewTeacherFilterBelongsToAdmin);
return (
<div className="w-full flex flex-col gap-8">
<UserCard
maxUserAmount={0}
loggedInUser={user}
onViewStudents={
(selectedUser.type === "corporate" ||
selectedUser.type === "teacher") &&
studentsFromAdmin.length > 0
? () => {
appendUserFilters({
id: "view-students",
filter: viewStudentFilter,
});
appendUserFilters({
id: "belongs-to-admin",
filter: belongsToAdminFilter,
});
router.push("/users");
}
: undefined
}
onViewTeachers={
(selectedUser.type === "corporate" || selectedUser.type === "student") && teachersFromAdmin.length > 0
? () => {
appendUserFilters({
id: "view-teachers",
filter: viewTeacherFilter,
});
appendUserFilters({
id: "belongs-to-admin",
filter: belongsToAdminFilter,
});
router.push("/users");
}
: undefined
}
onViewTeachers={
(selectedUser.type === "corporate" ||
selectedUser.type === "student") &&
teachersFromAdmin.length > 0
? () => {
appendUserFilters({
id: "view-teachers",
filter: viewTeacherFilter,
});
appendUserFilters({
id: "belongs-to-admin",
filter: belongsToAdminFilter,
});
router.push("/users");
}
: undefined
}
onViewCorporate={
selectedUser.type === "teacher" || selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-corporate",
filter: (x: User) => x.type === "corporate",
});
appendUserFilters({
id: "belongs-to-admin",
filter: belongsToAdminFilter
});
router.push("/users");
}
: undefined
}
onViewCorporate={
selectedUser.type === "teacher" || selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-corporate",
filter: (x: User) => x.type === "corporate",
});
appendUserFilters({
id: "belongs-to-admin",
filter: belongsToAdminFilter,
});
router.push("/users");
}
: undefined
}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
user={selectedUser}
/>
</div>
);
};
router.push("/users");
}
: undefined
}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
user={selectedUser}
/>
</div>
);
};
return (
<>
{renderHeader && renderHeader(displayUsers.length)}
<div className="w-full">
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
{selectedUser && renderUserCard(selectedUser)}
</Modal>
<Table<WithLabeledEntities<User>>
data={displayUsers}
columns={(!showDemographicInformation ? defaultColumns : demographicColumns) as any}
searchFields={searchFields}
onDownload={entitiesDownloadUsers.length > 0 ? downloadExcel : undefined}
/>
</div>
</>
);
return (
<>
{renderHeader && renderHeader(displayUsers.length)}
<div className="w-full">
<Modal
isOpen={!!selectedUser}
onClose={() => setSelectedUser(undefined)}
>
{selectedUser && renderUserCard(selectedUser)}
</Modal>
<Table<WithLabeledEntities<User>>
data={displayUsers}
columns={
(!showDemographicInformation
? defaultColumns
: demographicColumns) as any
}
searchFields={searchFields}
onDownload={
entitiesDownloadUsers.length > 0 ? downloadExcel : undefined
}
isLoading={isLoading}
/>
</div>
</>
);
}

View File

@@ -4,7 +4,6 @@ import clsx from "clsx";
import CodeList from "./CodeList";
import DiscountList from "./DiscountList";
import ExamList from "./ExamList";
import GroupList from "./GroupList";
import PackageList from "./PackageList";
import UserList from "./UserList";
import { checkAccess } from "@/utils/permissions";

View File

@@ -1,273 +1,272 @@
import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
import { PERMISSIONS } from "@/constants/userPermissions";
import { CorporateUser, TeacherUser, Type, User } from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user";
import {PERMISSIONS} from "@/constants/userPermissions";
import {CorporateUser, TeacherUser, Type, User} from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user";
import axios from "axios";
import clsx from "clsx";
import { capitalize, uniqBy } from "lodash";
import {capitalize, uniqBy} from "lodash";
import moment from "moment";
import { useEffect, useState } from "react";
import {useEffect, useState} from "react";
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 { PermissionType } from "@/interfaces/permissions";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import {PermissionType} from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions";
import Input from "@/components/Low/Input";
import CountrySelect from "@/components/Low/CountrySelect";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
import { getUserName } from "@/utils/users";
import {getUserName} from "@/utils/users";
import Select from "@/components/Low/Select";
import { EntityWithRoles } from "@/interfaces/entity";
import {EntityWithRoles} from "@/interfaces/entity";
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
import {mapBy} from "@/utils";
const USER_TYPE_PERMISSIONS: {
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
[key in Type]: {perm: PermissionType | undefined; list: Type[]};
} = {
student: {
perm: "createCodeStudent",
list: [],
},
teacher: {
perm: "createCodeTeacher",
list: [],
},
agent: {
perm: "createCodeCountryManager",
list: ["student", "teacher", "corporate", "mastercorporate"],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
mastercorporate: {
perm: undefined,
list: ["student", "teacher", "corporate"],
},
admin: {
perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
},
developer: {
perm: undefined,
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
},
student: {
perm: "createCodeStudent",
list: [],
},
teacher: {
perm: "createCodeTeacher",
list: [],
},
agent: {
perm: "createCodeCountryManager",
list: ["student", "teacher", "corporate", "mastercorporate"],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
mastercorporate: {
perm: undefined,
list: ["student", "teacher", "corporate"],
},
admin: {
perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
},
developer: {
perm: undefined,
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
},
};
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[]
permissions: PermissionType[];
onFinish: () => void;
user: User;
users: User[];
entities: EntityWithRoles[];
permissions: PermissionType[];
onFinish: () => void;
}
export default function UserCreator({ user, users, entities = [], permissions, onFinish }: Props) {
const [name, setName] = useState<string>();
const [email, setEmail] = useState<string>();
const [phone, setPhone] = useState<string>();
const [passportID, setPassportID] = useState<string>();
const [studentID, setStudentID] = useState<string>();
const [country, setCountry] = useState(user?.demographicInformation?.country);
const [group, setGroup] = useState<string | null>();
const [password, setPassword] = useState<string>();
const [confirmPassword, setConfirmPassword] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null,
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [type, setType] = useState<Type>("student");
const [position, setPosition] = useState<string>();
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined)
export default function UserCreator({user, users, entities = [], permissions, onFinish}: Props) {
const [name, setName] = useState<string>();
const [email, setEmail] = useState<string>();
const [phone, setPhone] = useState<string>();
const [passportID, setPassportID] = useState<string>();
const [studentID, setStudentID] = useState<string>();
const [country, setCountry] = useState(user?.demographicInformation?.country);
const [group, setGroup] = useState<string | null>();
const [password, setPassword] = useState<string>();
const [confirmPassword, setConfirmPassword] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null,
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [type, setType] = useState<Type>("student");
const [position, setPosition] = useState<string>();
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
const { groups } = useEntitiesGroups();
const {groups} = useEntitiesGroups();
useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
const createUser = () => {
if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!");
if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!");
if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!");
if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!");
if (password !== confirmPassword) return toast.error("The passwords do not match!");
const createUser = () => {
if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!");
if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!");
if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!");
if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!");
if (password !== confirmPassword) return toast.error("The passwords do not match!");
setIsLoading(true);
setIsLoading(true);
const body = {
name,
email,
password,
groupID: group,
entity,
type,
studentID: type === "student" ? studentID : undefined,
expiryDate,
demographicInformation: {
passport_id: type === "student" ? passportID : undefined,
phone,
country,
position,
},
};
const body = {
name,
email,
password,
groupID: group,
entity,
type,
studentID: type === "student" ? studentID : undefined,
expiryDate,
demographicInformation: {
passport_id: type === "student" ? passportID : undefined,
phone,
country,
position,
},
};
axios
.post("/api/make_user", body)
.then(() => {
toast.success("That user has been created!");
onFinish();
axios
.post("/api/make_user", body)
.then(() => {
toast.success("That user has been created!");
onFinish();
setName("");
setEmail("");
setPhone("");
setPassportID("");
setStudentID("");
setCountry(user?.demographicInformation?.country);
setGroup(null);
setEntity((entities || [])[0]?.id || undefined)
setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null);
setIsExpiryDateEnabled(true);
setType("student");
setPosition(undefined);
})
.catch((error) => {
const data = error?.response?.data;
if (!!data?.message) return toast.error(data.message);
toast.error("Something went wrong! Please try again later!");
})
.finally(() => setIsLoading(false));
};
setName("");
setEmail("");
setPhone("");
setPassportID("");
setStudentID("");
setCountry(user?.demographicInformation?.country);
setGroup(null);
setEntity((entities || [])[0]?.id || undefined);
setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null);
setIsExpiryDateEnabled(true);
setType("student");
setPosition(undefined);
})
.catch((error) => {
const data = error?.response?.data;
if (!!data?.message) return toast.error(data.message);
toast.error("Something went wrong! Please try again later!");
})
.finally(() => setIsLoading(false));
};
return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<div className="grid grid-cols-2 gap-4">
<Input required label="Name" value={name} onChange={setName} type="text" name="name" placeholder="Name" />
<Input label="E-mail" required value={email} onChange={setEmail} type="email" name="email" placeholder="E-mail" />
return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<div className="grid grid-cols-2 gap-4">
<Input required label="Name" value={name} onChange={setName} type="text" name="name" placeholder="Name" />
<Input label="E-mail" required value={email} onChange={setEmail} type="email" name="email" placeholder="E-mail" />
<Input type="password" name="password" label="Password" value={password} onChange={setPassword} placeholder="Password" required />
<Input
type="password"
name="confirmPassword"
label="Confirm Password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="ConfirmPassword"
required
/>
<Input type="password" name="password" label="Password" value={password} onChange={setPassword} placeholder="Password" required />
<Input
type="password"
name="confirmPassword"
label="Confirm Password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="ConfirmPassword"
required
/>
<div className="flex flex-col gap-4">
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} />
</div>
<div className="flex flex-col gap-4">
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} />
</div>
<Input type="tel" name="phone" label="Phone number" value={phone} onChange={setPhone} placeholder="Phone number" required />
<Input type="tel" name="phone" label="Phone number" value={phone} onChange={setPhone} placeholder="Phone number" required />
{type === "student" && (
<>
<Input
type="text"
name="passport_id"
label="Passport/National ID"
onChange={setPassportID}
value={passportID}
placeholder="National ID or Passport number"
required
/>
<Input type="text" name="studentID" label="Student ID" onChange={setStudentID} value={studentID} placeholder="Student ID" />
</>
)}
{type === "student" && (
<>
<Input
type="text"
name="passport_id"
label="Passport/National ID"
onChange={setPassportID}
value={passportID}
placeholder="National ID or Passport number"
required
/>
<Input type="text" name="studentID" label="Student ID" onChange={setStudentID} value={studentID} placeholder="Student ID" />
</>
)}
<div className={clsx("flex flex-col gap-4")}>
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Select
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(e) => setEntity(e?.value || undefined)}
isClearable={checkAccess(user, ["admin", "developer"])}
/>
</div>
<div className={clsx("flex flex-col gap-4")}>
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Select
defaultValue={{value: (entities || [])[0]?.id, label: (entities || [])[0]?.label}}
options={entities.map((e) => ({value: e.id, label: e.label}))}
onChange={(e) => setEntity(e?.value || undefined)}
isClearable={checkAccess(user, ["admin", "developer"])}
/>
</div>
{["corporate", "mastercorporate"].includes(type) && (
<Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" />
)}
{["corporate", "mastercorporate"].includes(type) && (
<Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" />
)}
<div className={clsx("flex flex-col gap-4")}>
<label className="font-normal text-base text-mti-gray-dim">Classroom</label>
<Select
options={groups
.filter((x) => x.entity?.id === entity)
.map((g) => ({ value: g.id, label: g.name }))}
onChange={(e) => setGroup(e?.value || undefined)}
isClearable
/>
</div>
<div className={clsx("flex flex-col gap-4")}>
<label className="font-normal text-base text-mti-gray-dim">Classroom</label>
<Select
options={groups.filter((x) => x.entity?.id === entity).map((g) => ({value: g.id, label: g.name}))}
onChange={(e) => setGroup(e?.value || undefined)}
isClearable
/>
</div>
<div
className={clsx(
"flex flex-col gap-4",
!checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && "col-span-2",
)}>
<label className="font-normal text-base text-mti-gray-dim">Type</label>
{user && (
<select
defaultValue="student"
value={type}
onChange={(e) => setType(e.target.value as Type)}
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
{Object.keys(USER_TYPE_LABELS)
.filter((x) => {
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, getTypesOfUser(list), permissions, perm);
})
.map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
</div>
<div
className={clsx(
"flex flex-col gap-4",
!checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && "col-span-2",
)}>
<label className="font-normal text-base text-mti-gray-dim">Type</label>
{user && (
<select
defaultValue="student"
value={type}
onChange={(e) => setType(e.target.value as Type)}
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
{Object.keys(USER_TYPE_LABELS)
.filter((x) => {
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, getTypesOfUser(list), permissions, perm);
})
.map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
</div>
<div className="flex flex-col gap-4">
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
<Checkbox
isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled}
disabled={!!user?.subscriptionExpirationDate}>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true)
}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
</div>
</div>
<div className="flex flex-col gap-4">
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
<Checkbox
isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled}
disabled={!!user?.subscriptionExpirationDate}>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true)
}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
</div>
</div>
<Button onClick={createUser} isLoading={isLoading} disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}>
Create User
</Button>
</div>
);
<Button onClick={createUser} isLoading={isLoading} disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}>
Create User
</Button>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import { sessionOptions } from "@/lib/session";
import { requestUser } from "@/utils/api";
import { updateApprovalWorkflow } from "@/utils/approval.workflows.be";
import { withIronSessionApiRoute } from "iron-session/next";
import { ObjectId } from "mongodb";
import type { NextApiRequest, NextApiResponse } from "next";
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "PUT") return await put(req, res);
}
async function put(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false });
if (!["admin", "developer", "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();
}
}

View 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));
}
}

View File

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

View File

@@ -0,0 +1,27 @@
// 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(",");
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray));
}

View File

@@ -3,16 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Code, Group, Type } from "@/interfaces/user";
import { PERMISSIONS } from "@/constants/userPermissions";
import { prepareMailer, prepareMailOptions } from "@/email";
import { isAdmin } from "@/utils/users";
import { Code, } from "@/interfaces/user";
import { requestUser } from "@/utils/api";
import { doesEntityAllow } from "@/utils/permissions";
import { getEntity, getEntityWithRoles } from "@/utils/entities.be";
import { findBy } from "@/utils";
import { EntityWithRoles } from "@/interfaces/entity";
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions);
@@ -30,7 +22,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const { entities } = req.query as { entities?: string[] };
if (entities)
return res.status(200).json(await db.collection("codes").find<Code>({ entity: { $in: entities } }).toArray());
return res.status(200).json(await db.collection("codes").find<Code>({ entity: { $in: Array.isArray(entities) ? entities : [entities] } }).toArray());
return res.status(200).json(await db.collection("codes").find<Code>({}).toArray());
}

View File

@@ -32,6 +32,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
}
const { entity } = req.query as { entity?: string };
const snapshot = await db.collection("codes").find(entity ? { entity } : {}).toArray();
res.status(200).json(snapshot);

View File

@@ -1,15 +1,18 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam";
import { getExams } from "@/utils/exams.be";
import { Module } from "@/interfaces";
import { getUserCorporate } from "@/utils/groups.be";
import { requestUser } from "@/utils/api";
import { isAdmin } from "@/utils/users";
import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam";
import { createApprovalWorkflowOnExamCreation } from "@/lib/createWorkflowsOnExamCreation";
import client from "@/lib/mongodb";
import { sessionOptions } from "@/lib/session";
import { mapBy } from "@/utils";
import { requestUser } from "@/utils/api";
import { getApprovalWorkflowsByExamId, updateApprovalWorkflows } from "@/utils/approval.workflows.be";
import { generateExamDifferences } from "@/utils/exam.differences";
import { getExams } from "@/utils/exams.be";
import { isAdmin } from "@/utils/users";
import { access } from "fs";
import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next";
const db = client.db(process.env.MONGODB_DB);
@@ -40,16 +43,17 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
}
async function POST(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res)
const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false });
const { module } = req.query as { module: string };
const session = client.startSession();
const entities = isAdmin(user) ? [] : mapBy(user.entities, 'id')
const entities = isAdmin(user) ? [] : mapBy(user.entities, "id");
try {
const exam = {
access: "public", // default access is public
...req.body,
module: module,
entities,
@@ -57,29 +61,93 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
createdAt: new Date().toISOString(),
};
let responseStatus: number;
let responseMessage: string;
await session.withTransaction(async () => {
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
// 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");
}
if (exam.requiresApproval === true) {
exam.access = "confidential";
}
await db.collection(module).updateOne(
{ id: req.body.id },
{ $set: { id: req.body.id, ...exam } },
{
upsert: true,
session
session,
}
);
// if it doesn't enter the next if condition it means the exam was updated and not created, so we can send this response.
responseStatus = 200;
responseMessage = `Successfully updated exam with ID: "${exam.id}"`;
// create workflow only if exam is being created for the first time
if (docSnap === null) {
try {
if (exam.requiresApproval === false) {
responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to user request.`;
} else if (isAdmin(user)) {
responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to admin rights.`;
} else {
const { successCount, totalCount } = await createApprovalWorkflowOnExamCreation(exam.createdBy, exam.entities, exam.id, module);
if (successCount === totalCount) {
responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow.`;
} else if (successCount > 0) {
responseStatus = 207;
responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to start/find an Approval Workflow for all the author's entities.`;
} else {
responseStatus = 207;
responseMessage = `Successfully created exam with ID: "${exam.id}" but skipping approval process because no approval workflow was found configured for the exam author.`;
}
}
} catch (error) {
console.error("Workflow creation error:", error);
responseStatus = 207;
responseMessage = `Successfully created exam with ID: "${exam.id}" but something went wrong while creating the Approval Workflow(s).`;
}
} else {
// if exam was updated, log the updates
const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id);
if (approvalWorkflows) {
const differences = generateExamDifferences(docSnap as Exam, exam as Exam);
if (differences) {
approvalWorkflows.forEach((workflow) => {
const currentStepIndex = workflow.steps.findIndex((step) => !step.completed || step.rejected);
if (workflow.steps[currentStepIndex].examChanges === undefined) {
workflow.steps[currentStepIndex].examChanges = [...differences];
} else {
workflow.steps[currentStepIndex].examChanges!.push(...differences);
}
});
await updateApprovalWorkflows("active-workflows", approvalWorkflows);
}
}
}
res.status(responseStatus).json({
message: responseMessage,
});
});
res.status(200).json(exam);
} catch (error) {
console.error("Transaction failed: ", error);
res.status(500).json({ ok: false, error: (error as any).message });

View File

@@ -1,11 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import type { NextApiRequest, NextApiResponse } from "next";
import client from "@/lib/mongodb";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {flatten} from "lodash";
import {Exam} from "@/interfaces/exam";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { flatten, map } from "lodash";
import { AccessType, Exam } from "@/interfaces/exam";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { requestUser } from "../../../utils/api";
import { mapBy } from "../../../utils";
const db = client.db(process.env.MONGODB_DB);
@@ -14,17 +16,37 @@ export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await GET(req, res);
res.status(404).json({ok: false});
res.status(404).json({ ok: false });
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
res.status(401).json({ ok: false });
return;
}
const user = await requestUser(req, res)
if (!user)
return res.status(401).json({ ok: false, reason: "You must be logged in!" })
const isAdmin = ["admin", "developer"].includes(user.type)
const { entities = [] } = req.query as { access?: AccessType, entities?: string[] | string };
let entitiesToFetch = Array.isArray(entities) ? entities : entities ? [entities] : []
if (!isAdmin) {
const userEntitiesIDs = mapBy(user.entities || [], 'id')
entitiesToFetch = entities ? entitiesToFetch.filter((entity): entity is string => entity ? userEntitiesIDs.includes(entity) : false) : userEntitiesIDs
if ((entitiesToFetch.length ?? 0) === 0) {
res.status(200).json([])
return
}
}
const moduleExamsPromises = MODULE_ARRAY.map(async (module) => {
const snapshot = await db.collection(module).find<Exam>({ isDiagnostic: false }).toArray();
const snapshot = await db.collection(module).find<Exam>({
isDiagnostic: false, ...(isAdmin && (entitiesToFetch.length ?? 0) === 0 ? {
} : {
entity: { $in: entitiesToFetch }
})
}).toArray();
return snapshot.map((doc) => ({
...doc,

View File

@@ -1,24 +1,20 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import client from "@/lib/mongodb";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import type { NextApiRequest, NextApiResponse } from "next";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { getDetailedStatsByUser } from "../../../../utils/stats.be";
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
res.status(401).json({ ok: false });
return;
}
const {user} = req.query;
const snapshot = await db.collection("stats").aggregate([
{ $match: { user: user } },
{ $sort: { "date": 1 } }
]).toArray();
const { user, query } = req.query as { user: string, query?: string };
const snapshot = await getDetailedStatsByUser(user, query);
res.status(200).json(snapshot);
}

View File

@@ -33,7 +33,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
res.status(401).json({ ok: false });
return;
}
const docs = await db.collection("tickets").find<Ticket>({ assignedTo: req.session.user.id }).toArray();
const docs = await db.collection("tickets").find<Ticket>({ assignedTo: req.session.user.id, status: { $ne: "completed" } }).toArray();
res.status(200).json(docs);
}

View File

@@ -0,0 +1,197 @@
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 = {
id: workflow._id?.toString() ?? "",
name: workflow.name,
entityId: workflow.entityId,
requester: user.id, // should it change to the editor?
startDate: workflow.startDate,
modules: workflow.modules,
status: workflow.status,
steps: editableSteps,
};
setUpdatedWorkflow(editableWorkflow);
}, []);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
if (!updatedWorkflow) {
setIsLoading(false);
return;
}
for (const step of updatedWorkflow.steps) {
if (step.assignees.every(x => !x)) {
toast.warning("There is at least one empty step in the workflow.");
setIsLoading(false);
return;
}
}
const filteredWorkflow: ApprovalWorkflow = {
...updatedWorkflow,
steps: updatedWorkflow.steps.map(step => ({
...step,
assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined)
}))
};
axios
.put(`/api/approval-workflows/${updatedWorkflow.id}/edit`, filteredWorkflow)
.then(() => {
toast.success("Approval Workflow edited successfully.");
setIsLoading(false);
})
.catch((reason) => {
if (reason.response.status === 401) {
toast.error("Not logged in!");
} else if (reason.response.status === 403) {
toast.error("You do not have permission to edit Approval Workflows!");
} else {
toast.error("Something went wrong, please try again later.");
}
setIsLoading(false);
console.log("Submitted Values:", filteredWorkflow);
return;
})
};
const onWorkflowChange = (updatedWorkflow: EditableApprovalWorkflow) => {
setUpdatedWorkflow(updatedWorkflow);
};
return (
<>
<Head>
<title> Edit Workflow | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<section className="flex items-center gap-2">
<Link
href="/approval-workflows"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h1 className="text-2xl font-semibold">{workflow.name}</h1>
</section>
<section className="flex flex-col gap-6">
<div className="flex flex-row gap-6">
<RequestedBy
prefix={getUserTypeLabelShort(user.type)}
name={user.name}
profileImage={user.profilePicture}
/>
<StartedOn
date={workflow.startDate}
/>
<Status
status={workflow.status}
/>
</div>
</section>
<form onSubmit={handleSubmit}>
<LayoutGroup key={workflow.name}>
<motion.div
key="form"
initial={{ opacity: 0, y: -30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 60 }}
transition={{ duration: 0.20 }}
>
{updatedWorkflow &&
<WorkflowForm
workflow={updatedWorkflow}
onWorkflowChange={onWorkflowChange}
entityApprovers={workflowEntityApprovers}
isLoading={isLoading}
/>
}
</motion.div>
</LayoutGroup>
</form>
</>
);
}

View File

@@ -0,0 +1,614 @@
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? Doing so will change the access type of the exam from confidential to private.`)) 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}`, { access: "private" })
.then(() => toast.success(`The exam was successfuly approved and this workflow has been completed.`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to update this exam!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
} else {
handleStepClick(selectedStepIndex + 1, currentWorkflow.steps[selectedStepIndex + 1]);
}
};
const handleRejectStep = () => {
if (!confirm(`Are you sure you want to reject this step? Doing so will terminate this approval workflow.`)) return;
const updatedWorkflow: ApprovalWorkflow = {
...currentWorkflow,
status: "rejected",
steps: currentWorkflow.steps.map((step, index) =>
index === selectedStepIndex ?
{
...step,
completed: true,
completedBy: user.id,
completedDate: Date.now(),
rejected: true,
}
: step
)
};
axios
.put(`/api/approval-workflows/${id}`, updatedWorkflow)
.then(() => {
toast.success("Step rejected successfully.");
reload();
})
.catch((reason) => {
if (reason.response.status === 401) {
toast.error("Not logged in!");
} else if (reason.response.status === 403) {
toast.error("You do not have permission to approve this step!");
} else {
toast.error("Something went wrong, please try again later.");
}
console.log("Submitted Values:", updatedWorkflow);
return;
})
};
const dispatch = useExamStore((store) => store.dispatch);
const handleViewExam = async () => {
setViewExamIsLoading(true);
const examModule = currentWorkflow.modules[0];
const examId = currentWorkflow.examId;
if (examModule && examId) {
const exam = await getExamById(examModule, examId.trim());
if (!exam) {
toast.error(
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
{ toastId: "invalid-exam-id" }
);
setViewExamIsLoading(false);
return;
}
dispatch({
type: "INIT_EXAM",
payload: { exams: [exam], modules: [examModule] },
});
router.push("/exam");
}
}
const handleEditExam = () => {
setEditExamIsLoading(true);
const examModule = currentWorkflow.modules[0];
const examId = currentWorkflow.examId;
router.push(`/generation?id=${examId}&module=${examModule}`);
}
return (
<>
<Head>
<title> Workflow | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<section className="flex items-center gap-2">
<Link
href="/approval-workflows"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h1 className="text-2xl font-semibold">{currentWorkflow.name}</h1>
</section>
<section className="flex flex-col gap-6">
<div className="flex flex-row gap-6">
<RequestedBy
prefix={getUserTypeLabelShort(workflowRequester.type)}
name={workflowRequester.name}
profileImage={workflowRequester.profilePicture}
/>
<StartedOn
date={currentWorkflow.startDate}
/>
<Status
status={currentWorkflow.status}
/>
</div>
<div className="flex flex-row gap-3">
<Button
color="purple"
variant="solid"
onClick={handleViewExam}
disabled={viewExamIsLoading}
padding="px-6 py-2"
className="w-[240px] text-lg flex items-center justify-center gap-2 text-left"
>
{viewExamIsLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<IoDocumentTextOutline />
Load Exam
</>
)}
</Button>
<Button
color="purple"
variant="solid"
onClick={handleEditExam}
padding="px-6 py-2"
disabled={(!currentWorkflow.steps[currentStepIndex].assignees.includes(user.id) && user.type !== "admin" && user.type !== "developer") || editExamIsLoading}
className="w-[240px] text-lg flex items-center justify-center gap-2 text-left"
>
{editExamIsLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<TiEdit size={20} />
Edit Exam
</>
)}
</Button>
</div>
{currentWorkflow.steps.find((step) => !step.completed) === undefined &&
<Tip text="All steps in this instance have been completed." />
}
</section>
<section className="flex flex-col gap-0">
{currentWorkflow.steps.map((step, index) => (
<WorkflowStepComponent
workflowAssignees={workflowAssignees}
key={index}
completed={step.completed}
completedBy={step.completedBy}
rejected={step.rejected}
stepNumber={step.stepNumber}
stepType={step.stepType}
assignees={step.assignees}
finalStep={index === currentWorkflow.steps.length - 1}
currentStep={index === currentStepIndex}
selected={index === selectedStepIndex}
onClick={() => handleStepClick(index, step)}
/>
))}
</section>
{/* Side panel */}
<AnimatePresence mode="wait">
<LayoutGroup key="sidePanel">
<section className={`absolute inset-y-0 right-0 h-full bg-mti-purple-ultralight bg-opacity-50 shadow-xl shadow-mti-purple transition-all duration-300 overflow-hidden ${isPanelOpen ? 'w-[500px]' : 'w-0'}`}>
{isPanelOpen && selectedStep && (
<motion.div
className="p-6"
key={selectedStep.stepNumber}
initial={{ opacity: 0, x: 30 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 30 }}
transition={{ duration: 0.2 }}
>
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
<div className="flex flex-row gap-2">
<p className="text-2xl font-medium text-left align-middle">Step {selectedStepIndex + 1}</p>
<div className="ml-auto flex flex-row">
<button
className="min-w-fit max-h-fit text-lg font-medium flex items-center gap-2 text-left"
onClick={() => setIsPanelOpen(false)}
>
Collapse
<MdOutlineDoubleArrow size={20} />
</button>
</div>
</div>
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
<div>
<div className="my-8 flex flex-row gap-4 items-center text-lg font-medium">
{selectedStep.stepType === "approval-by" ? (
<>
<RiThumbUpLine size={30} />
Approval Step
</>
) : (
<>
<FaWpforms size={30} />
Form Intake Step
</>
)
}
</div>
{selectedStep.completed ? (
<div className={"text-base font-medium text-gray-500 flex flex-col gap-6"}>
{selectedStep.rejected ? "Rejected" : "Approved"} on {new Date(selectedStep.completedDate!).toLocaleString("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).replace(", ", " at ")}
<div className="flex flex-row gap-1 text-sm">
<p className="text-base">{selectedStep.rejected ? "Rejected" : "Approved"} by:</p>
{(() => {
const assignee = workflowAssignees.find(
(assignee) => assignee.id === selectedStep.completedBy
);
return assignee ? (
<UserWithProfilePic
textSize="text-base"
prefix={getUserTypeLabelShort(assignee.type)}
name={assignee.name}
profileImage={assignee.profilePicture}
/>
) : (
"Unknown"
);
})()}
</div>
<p className="text-sm">No additional actions are required.</p>
</div>
) : (
<div className={"text-base font-medium text-gray-500 mb-6"}>
One assignee is required to sign off to complete this step:
<div className="flex flex-col gap-2 mt-3">
{workflowAssignees.filter(user => selectedStep.assignees.includes(user.id)).map(user => (
<span key={user.id}>
<UserWithProfilePic
textSize="text-sm"
prefix={`- ${getUserTypeLabelShort(user.type)}`}
name={user.name}
profileImage={user.profilePicture}
/>
</span>
))}
</div>
</div>
)}
{selectedStepIndex === currentStepIndex && !selectedStep.completed && !selectedStep.rejected &&
<div className="flex flex-row gap-2 ">
<Button
type="submit"
color="purple"
variant="solid"
disabled={(!selectedStep.assignees.includes(user.id) && user.type !== "admin" && user.type !== "developer") || isLoading}
onClick={handleApproveStep}
padding="px-6 py-2"
className="mb-3 w-full text-lg flex items-center justify-center gap-2 text-left"
>
{isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<IoMdCheckmarkCircleOutline size={20} />
Approve Step
</>
)}
</Button>
<Button
type="submit"
color="red"
variant="solid"
disabled={(!selectedStep.assignees.includes(user.id) && user.type !== "admin" && user.type !== "developer") || isLoading}
onClick={handleRejectStep}
padding="px-6 py-2"
className="mb-3 w-1/2 text-lg flex items-center justify-center gap-2 text-left"
>
{isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<RxCrossCircled size={20} />
Reject
</>
)}
</Button>
</div>
}
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
{/* 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-40">
{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">
{change}
</p>
))
) : (
<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-40 p-2 border-2 rounded-xl shadow-lg focus:border-mti-purple focus:outline-none mt-3 resize-none"
/>
<Button
type="submit"
color="purple"
variant="solid"
onClick={handleSaveComments}
disabled={isLoading}
padding="px-6 py-2"
className="mt-6 mb-3 w-full text-lg flex items-center justify-center gap-2 text-left"
>
{isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<FiSave size={20} />
Save Comments
</>
)}
</Button>
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
</div>
</motion.div>
)}
</section>
</LayoutGroup>
</AnimatePresence>
</>
);
}

View File

@@ -0,0 +1,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 isnt removed
const formIntakeStep = wf.steps.find(step => step.stepType === "form-intake");
if (formIntakeStep) {
// Only consider non-null assignees
const validAssignees = formIntakeStep.assignees.filter(
(assignee): assignee is string => !!assignee
);
return acc.concat(validAssignees);
}
return acc;
}, []);
// Now filter out any user from entityApprovers whose id is in the assignedFormIntakers list.
// (The selected one in the current workflow is allowed even if it is in the list.)
const availableFormIntakers = entityApprovers.filter(assignee =>
!assignedFormIntakers.includes(assignee.id)
);
setEntityAvailableFormIntakers(availableFormIntakers);
}
}, [entityId, entityApprovers, workflows, selectedWorkflowId]);
const currentWorkflow = workflows.find(wf => wf.id === selectedWorkflowId);
const ENTITY_OPTIONS = userEntitiesWithLabel.map(entity => ({
label: entity.label,
value: entity.id,
filter: (x: EditableApprovalWorkflow) => x.entityId === entity.id,
}));
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
if (workflows.length === 0) {
setIsLoading(false);
return;
}
for (const workflow of workflows) {
for (const step of workflow.steps) {
if (step.assignees.every(x => !x)) {
toast.warning("There are empty steps in at least one of the configured workflows.");
setIsLoading(false);
return;
}
}
}
const filteredWorkflows: ApprovalWorkflow[] = workflows.map(workflow => ({
...workflow,
steps: workflow.steps.map(step => ({
...step,
assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined)
}))
}));
const requestData = { filteredWorkflows, userEntitiesWithLabel };
axios
.post(`/api/approval-workflows/create`, requestData)
.then(() => {
toast.success("Approval Workflows created successfully.");
setIsRedirecting(true);
router.push("/approval-workflows");
})
.catch((reason) => {
if (reason.response.status === 401) {
toast.error("Not logged in!");
}
else if (reason.response.status === 403) {
toast.error("You do not have permission to create Approval Workflows!");
}
else {
toast.error("Something went wrong, please try again later.");
}
setIsLoading(false);
console.log("Submitted Values:", filteredWorkflows);
return;
})
};
const handleAddNewWorkflow = () => {
if (isAdding) return;
setIsAdding(true);
const newId = uuidv4(); // this id is only used in UI. it is ommited on submission to DB and lets mongo handle unique id.
const newWorkflow: EditableApprovalWorkflow = {
id: newId,
name: "",
entityId: "",
modules: [],
requester: user.id,
startDate: Date.now(),
status: "pending",
steps: [
{ key: 9998, stepType: "form-intake", stepNumber: 1, completed: false, firstStep: true, finalStep: false, assignees: [null] },
{ key: 9999, stepType: "approval-by", stepNumber: 2, completed: false, firstStep: false, finalStep: true, assignees: [null] },
],
};
setWorkflows((prev) => [...prev, newWorkflow]);
handleSelectWorkflow(newId);
setTimeout(() => setIsAdding(false), 300);
};
const onWorkflowChange = (updatedWorkflow: EditableApprovalWorkflow) => {
setWorkflows(prev =>
prev.map(wf => (wf.id === updatedWorkflow.id ? updatedWorkflow : wf))
);
}
const handleSelectWorkflow = (id: string | undefined) => {
setSelectedWorkflowId(id);
const selectedWorkflow = workflows.find(wf => wf.id === id);
if (selectedWorkflow) {
setEntityId(selectedWorkflow.entityId || null);
} else {
setEntityId(null);
}
};
const handleCloneWorkflow = (id: string) => {
const workflowToClone = workflows.find(wf => wf.id === id);
if (!workflowToClone) return;
const newId = uuidv4();
const clonedWorkflow: EditableApprovalWorkflow = {
...workflowToClone,
id: newId,
steps: workflowToClone.steps.map(step => ({
...step,
assignees: step.firstStep ? [null] : [...step.assignees], // we can't have more than one form intaker per teacher per entity
})),
};
setWorkflows(prev => {
const updatedWorkflows = [...prev, clonedWorkflow];
setSelectedWorkflowId(newId);
setEntityId(clonedWorkflow.entityId || null);
return updatedWorkflows;
});
};
const handleDeleteWorkflow = (id: string) => {
if (!confirm(`Are you sure you want to delete this Approval Workflow?`)) return;
const updatedWorkflows = workflows.filter(wf => wf.id !== id);
setWorkflows(updatedWorkflows);
if (selectedWorkflowId === id) {
handleSelectWorkflow(updatedWorkflows.find(wf => wf.id)?.id);
}
};
const handleEntityChange = (wf: EditableApprovalWorkflow, entityId: string) => {
const updatedWorkflow = {
...wf,
entityId: entityId,
steps: wf.steps.map(step => ({
...step,
assignees: step.assignees.map(() => null)
}))
}
onWorkflowChange(updatedWorkflow);
}
return (
<>
<Head>
<title> Configure Workflows | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<section className="flex flex-col">
<div className="flex items-center gap-2">
<Link
href="/approval-workflows"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h1 className="text-2xl font-semibold">{"Configure Approval Workflows"}</h1>
</div>
</section>
<Tip text="Setting a teacher as a Form Intaker means the configured workflow will be instantiated when said teacher publishes an exam. Only one Form Intake per teacher per entity is allowed." />
<section className="flex flex-row gap-6">
<Button
color="purple"
variant="solid"
onClick={handleAddNewWorkflow}
className="min-w-fit max-h-fit text-lg font-medium flex items-center gap-2 text-left"
>
<MdFormatListBulletedAdd className="size-6" />
Add New Workflow
</Button>
{workflows.length !== 0 && <div className="bg-gray-300 w-[1px]"></div>}
<div className="flex flex-wrap gap-2">
{workflows.map((workflow) => (
<Button
key={workflow.id}
color="purple"
variant={
selectedWorkflowId === workflow.id
? "solid"
: "outline"
}
onClick={() => handleSelectWorkflow(workflow.id)}
className="min-w-fit text-lg font-medium flex items-center gap-2 text-left"
>
{workflow.name.trim() === "" ? "Workflow" : workflow.name}
</Button>
))}
</div>
</section>
<form onSubmit={handleSubmit}>
{currentWorkflow && (
<>
<div className="mb-8 flex flex-row gap-6 items-end">
<Input
label="Name:"
type="text"
name={currentWorkflow.name}
placeholder="Enter workflow name"
value={currentWorkflow.name}
onChange={(updatedName) => {
const updatedWorkflow = {
...currentWorkflow,
name: updatedName,
};
onWorkflowChange(updatedWorkflow);
}}
/>
<Select
label="Entity:"
options={ENTITY_OPTIONS}
value={
currentWorkflow.entityId === ""
? null
: ENTITY_OPTIONS.find(option => option.value === currentWorkflow.entityId)
}
onChange={(selectedEntity) => {
if (currentWorkflow.entityId) {
if (!confirm("Clearing or changing entity will clear all the assignees for all steps in this workflow. Are you sure you want to proceed?")) return;
}
if (selectedEntity?.value) {
setEntityId(selectedEntity.value);
handleEntityChange(currentWorkflow, selectedEntity.value);
}
}}
isClearable
placeholder="Enter workflow entity"
/>
<Button
color="gray"
variant="solid"
onClick={() => handleCloneWorkflow(currentWorkflow.id)}
type="button"
className="min-w-fit h-[72px] text-lg font-medium flex items-center gap-2 text-left"
>
Clone Workflow
<FaRegClone className="size-6" />
</Button>
<Button
color="red"
variant="solid"
onClick={() => handleDeleteWorkflow(currentWorkflow.id)}
type="button"
className="min-w-fit h-[72px] text-lg font-medium flex items-center gap-2 text-left"
>
Delete Workflow
<BsTrash className="size-6" />
</Button>
</div>
<AnimatePresence mode="wait">
<LayoutGroup key={currentWorkflow.id}>
<motion.div
key="form"
initial={{ opacity: 0, y: -30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 60 }}
transition={{ duration: 0.20 }}
>
{(!currentWorkflow.name || !currentWorkflow.entityId) && (
<Tip text="Please fill in workflow name and associated entity to start configuring workflow." />
)}
<WorkflowForm
workflow={currentWorkflow}
onWorkflowChange={onWorkflowChange}
entityApprovers={entityApprovers}
entityAvailableFormIntakers={entityAvailableFormIntakers}
isLoading={isLoading}
isRedirecting={isRedirecting}
/>
</motion.div>
</LayoutGroup>
</AnimatePresence>
</>
)}
</form>
</>
);
}

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,5 +1,4 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Tooltip from "@/components/Low/Tooltip";
import { useEntityPermission } from "@/hooks/useEntityPermissions";
import { useListSearch } from "@/hooks/useListSearch";
@@ -19,328 +18,510 @@ import { getEntityUsers, getSpecificUsers } from "@/utils/users.be";
import axios from "axios";
import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next";
import { capitalize } from "lodash";
import { capitalize, last } from "lodash";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { Divider } from "primereact/divider";
import { useEffect, useMemo, useState } from "react";
import { BsBuilding, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsFillPersonVcardFill, BsPlus, BsStopwatchFill, BsTag, BsTrash, BsX } from "react-icons/bs";
import {
BsBuilding,
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsFillPersonVcardFill,
BsPlus,
BsStopwatchFill,
BsTag,
BsTrash,
BsX,
} from "react-icons/bs";
import { toast, ToastContainer } from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
export const getServerSideProps = withIronSessionSsr(
async ({ req, res, params }) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/")
if (shouldRedirectHome(user)) return redirect("/");
const { id } = params as { id: string };
const { id } = params as { id: string };
const group = await getGroup(id);
if (!group || !group.entity) return redirect("/classrooms")
const group = await getGroup(id);
if (!group || !group.entity) return redirect("/classrooms");
const entity = await getEntityWithRoles(group.entity)
if (!entity) return redirect("/classrooms")
const entity = await getEntityWithRoles(group.entity);
if (!entity) return redirect("/classrooms");
const canView = doesEntityAllow(user, entity, "view_classrooms")
if (!canView) return redirect("/")
const canView = doesEntityAllow(user, entity, "view_classrooms");
if (!canView) return redirect("/");
const [linkedUsers, users] = await Promise.all([
getEntityUsers(
entity.id,
0,
{},
{
_id: 0,
id: 1,
name: 1,
email: 1,
corporateInformation: 1,
type: 1,
profilePicture: 1,
subscriptionExpirationDate: 1,
lastLogin: 1,
}
),
getSpecificUsers([...group.participants, group.admin], {
_id: 0,
id: 1,
name: 1,
email: 1,
corporateInformation: 1,
type: 1,
profilePicture: 1,
subscriptionExpirationDate: 1,
lastLogin: 1,
}),
]);
const linkedUsers = await getEntityUsers(entity.id)
const users = await getSpecificUsers([...group.participants, group.admin]);
const groupWithUser = convertToUsers(group, users);
const groupWithUser = convertToUsers(group, users);
return {
props: serialize({ user, group: groupWithUser, users: linkedUsers.filter(x => isAdmin(user) ? true : !isAdmin(x)), entity }),
};
}, sessionOptions);
return {
props: serialize({
user,
group: groupWithUser,
users: linkedUsers.filter((x) => (isAdmin(user) ? true : !isAdmin(x))),
entity,
}),
};
},
sessionOptions
);
interface Props {
user: User;
group: GroupWithUsers;
users: User[];
entity: EntityWithRoles
user: User;
group: GroupWithUsers;
users: User[];
entity: EntityWithRoles;
}
export default function Home({ user, group, users, entity }: Props) {
const [isAdding, setIsAdding] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [isAdding, setIsAdding] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const canAddParticipants = useEntityPermission(user, entity, "add_to_classroom")
const canRemoveParticipants = useEntityPermission(user, entity, "remove_from_classroom")
const canRenameClassroom = useEntityPermission(user, entity, "rename_classrooms")
const canDeleteClassroom = useEntityPermission(user, entity, "delete_classroom")
const canAddParticipants = useEntityPermission(
user,
entity,
"add_to_classroom"
);
const canRemoveParticipants = useEntityPermission(
user,
entity,
"remove_from_classroom"
);
const canRenameClassroom = useEntityPermission(
user,
entity,
"rename_classrooms"
);
const canDeleteClassroom = useEntityPermission(
user,
entity,
"delete_classroom"
);
const nonParticipantUsers = useMemo(
() => users.filter((x) => ![...group.participants.map((g) => g.id), group.admin.id, user.id].includes(x.id)),
[users, group.participants, group.admin.id, user.id],
);
const nonParticipantUsers = useMemo(
() =>
users.filter(
(x) =>
![
...group.participants.map((g) => g.id),
group.admin.id,
user.id,
].includes(x.id)
),
[users, group.participants, group.admin.id, user.id]
);
const { rows, renderSearch } = useListSearch<User>(
[["name"], ["corporateInformation", "companyInformation", "name"]],
isAdding ? nonParticipantUsers : group.participants,
);
const { items, renderMinimal } = usePagination<User>(rows, 20);
const { rows, renderSearch } = useListSearch<User>(
[["name"], ["corporateInformation", "companyInformation", "name"]],
isAdding ? nonParticipantUsers : group.participants
);
const { items, renderMinimal } = usePagination<User>(rows, 20);
const router = useRouter();
const router = useRouter();
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
const toggleUser = (u: User) =>
setSelectedUsers((prev) =>
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
);
const removeParticipants = () => {
if (selectedUsers.length === 0) return;
if (!canRemoveParticipants) return;
if (!confirm(`Are you sure you want to remove ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} from this group?`))
return;
const 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[]),
]
);
setIsLoading(true);
const removeParticipants = () => {
if (selectedUsers.length === 0) return;
if (!canRemoveParticipants) return;
if (
!confirm(
`Are you sure you want to remove ${selectedUsers.length} participant${
selectedUsers.length === 1 ? "" : "s"
} from this group?`
)
)
return;
axios
.patch(`/api/groups/${group.id}`, { participants: group.participants.map((x) => x.id).filter((x) => !selectedUsers.includes(x)) })
.then(() => {
toast.success("The group has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
setIsLoading(true);
const addParticipants = () => {
if (selectedUsers.length === 0) return;
if (!canAddParticipants || !isAdding) return;
if (!confirm(`Are you sure you want to add ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} to this group?`))
return;
axios
.patch(`/api/groups/${group.id}`, {
participants: group.participants
.map((x) => x.id)
.filter((x) => !selectedUsers.includes(x)),
})
.then(() => {
toast.success("The group has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
setIsLoading(true);
const addParticipants = () => {
if (selectedUsers.length === 0) return;
if (!canAddParticipants || !isAdding) return;
if (
!confirm(
`Are you sure you want to add ${selectedUsers.length} participant${
selectedUsers.length === 1 ? "" : "s"
} to this group?`
)
)
return;
axios
.patch(`/api/groups/${group.id}`, { participants: [...group.participants.map((x) => x.id), ...selectedUsers] })
.then(() => {
toast.success("The group has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
setIsLoading(true);
const renameGroup = () => {
if (!canRenameClassroom) return;
axios
.patch(`/api/groups/${group.id}`, {
participants: [
...group.participants.map((x) => x.id),
...selectedUsers,
],
})
.then(() => {
toast.success("The group has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const name = prompt("Rename this classroom:", group.name);
if (!name) return;
const renameGroup = () => {
if (!canRenameClassroom) return;
setIsLoading(true);
axios
.patch(`/api/groups/${group.id}`, { name })
.then(() => {
toast.success("The classroom has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const name = prompt("Rename this classroom:", group.name);
if (!name) return;
const deleteGroup = () => {
if (!canDeleteClassroom) return;
if (!confirm("Are you sure you want to delete this classroom?")) return;
setIsLoading(true);
axios
.patch(`/api/groups/${group.id}`, { name })
.then(() => {
toast.success("The classroom has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
setIsLoading(true);
const deleteGroup = () => {
if (!canDeleteClassroom) return;
if (!confirm("Are you sure you want to delete this classroom?")) return;
axios
.delete(`/api/groups/${group.id}`)
.then(() => {
toast.success("This classroom has been successfully deleted!");
router.replace("/classrooms");
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
setIsLoading(true);
useEffect(() => setSelectedUsers([]), [isAdding]);
axios
.delete(`/api/groups/${group.id}`)
.then(() => {
toast.success("This classroom has been successfully deleted!");
router.replace("/classrooms");
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
return (
<>
<Head>
<title>{group.name} | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user}>
<section className="flex flex-col gap-0">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Link
href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">{group.name}</h2>
</div>
useEffect(() => setSelectedUsers([]), [isAdding]);
{!isAdding && (
<div className="flex items-center gap-2">
<button
onClick={renameGroup}
disabled={isLoading || !canRenameClassroom}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsTag />
<span className="text-xs">Rename Classroom</span>
</button>
<button
onClick={deleteGroup}
disabled={isLoading || !canDeleteClassroom}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsTrash />
<span className="text-xs">Delete Classroom</span>
</button>
</div>
)}
</div>
<div className="flex flex-col gap-2">
<span className="flex items-center gap-2">
<BsBuilding className="text-xl" /> {entity.label}
</span>
<span className="flex items-center gap-2">
<BsFillPersonVcardFill className="text-xl" /> {getUserName(group.admin)}
</span>
</div>
</div>
<Divider />
<div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Participants</span>
{!isAdding && (
<div className="flex items-center gap-2">
<button
onClick={() => setIsAdding(true)}
disabled={isLoading || !canAddParticipants}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsPlus />
<span className="text-xs">Add Participants</span>
</button>
<button
onClick={removeParticipants}
disabled={selectedUsers.length === 0 || isLoading || !canRemoveParticipants}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsTrash />
<span className="text-xs">Remove Participants</span>
</button>
</div>
)}
{isAdding && (
<div className="flex items-center gap-2">
<button
onClick={() => setIsAdding(false)}
disabled={isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsX />
<span className="text-xs">Discard Selection</span>
</button>
<button
onClick={addParticipants}
disabled={selectedUsers.length === 0 || isLoading || !canAddParticipants}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsPlus />
<span className="text-xs">Add Participants</span>
</button>
</div>
)}
</div>
<div className="w-full flex items-center gap-4">
{renderSearch()}
{renderMinimal()}
</div>
<div className="flex items-center gap-2 mt-4">
{['student', 'teacher', 'corporate'].map((type) => (
<button
key={type}
onClick={() => {
const typeUsers = mapBy(filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type), 'id')
if (typeUsers.every((u) => selectedUsers.includes(u))) {
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a)));
} else {
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]);
}
}}
disabled={filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length === 0}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length > 0 &&
filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).every((u) => selectedUsers.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
{capitalize(type)}
</button>
))}
</div>
</section>
return (
<>
<Head>
<title>{group.name} | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<>
<section className="flex flex-col gap-0">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Link
href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">{group.name}</h2>
</div>
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{items.map((u) => (
<button
onClick={() => toggleUser(u)}
disabled={isAdding ? !canAddParticipants : !canRemoveParticipants}
key={u.id}
className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple",
)}>
<div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} />
</div>
<div className="flex flex-col">
<span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span>
</div>
</div>
{!isAdding && (
<div className="flex items-center gap-2">
<button
onClick={renameGroup}
disabled={isLoading || !canRenameClassroom}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTag />
<span className="text-xs">Rename Classroom</span>
</button>
<button
onClick={deleteGroup}
disabled={isLoading || !canDeleteClassroom}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTrash />
<span className="text-xs">Delete Classroom</span>
</button>
</div>
)}
</div>
<div className="flex flex-col gap-2">
<span className="flex items-center gap-2">
<BsBuilding className="text-xl" /> {entity.label}
</span>
<span className="flex items-center gap-2">
<BsFillPersonVcardFill className="text-xl" />{" "}
{getUserName(group.admin)}
</span>
</div>
</div>
<Divider />
<div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Participants</span>
{!isAdding && (
<div className="flex items-center gap-2">
<button
onClick={() => setIsAdding(true)}
disabled={isLoading || !canAddParticipants}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPlus />
<span className="text-xs">Add Participants</span>
</button>
<button
onClick={removeParticipants}
disabled={
selectedUsers.length === 0 ||
isLoading ||
!canRemoveParticipants
}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTrash />
<span className="text-xs">Remove Participants</span>
</button>
</div>
)}
{isAdding && (
<div className="flex items-center gap-2">
<button
onClick={() => setIsAdding(false)}
disabled={isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsX />
<span className="text-xs">Discard Selection</span>
</button>
<button
onClick={addParticipants}
disabled={
selectedUsers.length === 0 ||
isLoading ||
!canAddParticipants
}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPlus />
<span className="text-xs">Add Participants</span>
</button>
</div>
)}
</div>
<div className="w-full flex items-center gap-4">
{renderSearch()}
{renderMinimal()}
</div>
<div className="flex items-center gap-2 mt-4">
{["student", "teacher", "corporate"].map((type) => (
<button
key={type}
onClick={() => {
const typeUsers = mapBy(
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"type",
type
),
"id"
);
if (typeUsers.every((u) => selectedUsers.includes(u))) {
setSelectedUsers((prev) =>
prev.filter((a) => !typeUsers.includes(a))
);
} else {
setSelectedUsers((prev) => [
...prev.filter((a) => !typeUsers.includes(a)),
...typeUsers,
]);
}
}}
disabled={
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"type",
type
).length === 0
}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"type",
type
).length > 0 &&
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"type",
type
).every((u) => selectedUsers.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{capitalize(type)}
</button>
))}
<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>
</section>
<div className="flex flex-col gap-1">
<span className="flex items-center gap-2">
<Tooltip tooltip="E-mail address">
<BsEnvelopeFill />
</Tooltip>
{u.email}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Last Login">
<BsClockFill />
</Tooltip>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
</span>
</div>
</button>
))}
</section>
</Layout>
)}
</>
);
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{items.map((u) => (
<button
onClick={() => toggleUser(u)}
disabled={
isAdding ? !canAddParticipants : !canRemoveParticipants
}
key={u.id}
className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple"
)}
>
<div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} />
</div>
<div className="flex flex-col">
<span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">
{USER_TYPE_LABELS[u.type]}
</span>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="flex items-center gap-2">
<Tooltip tooltip="E-mail address">
<BsEnvelopeFill />
</Tooltip>
{u.email}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate
? moment(u.subscriptionExpirationDate).format(
"DD/MM/YYYY"
)
: "Unlimited"}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Last Login">
<BsClockFill />
</Tooltip>
{u.lastLogin
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
: "N/A"}
</span>
</div>
</button>
))}
</section>
</>
)}
</>
);
}

View File

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

View File

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

View File

@@ -1,191 +1,213 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard";
import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results";
import { Group, Stat, Type, User } from "@/interfaces/user";
import { Stat, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { countEntitiesAssignments, getAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { countGroups, getGroups } from "@/utils/groups.be";
import { countEntitiesAssignments } from "@/utils/assignments.be";
import { getEntities } from "@/utils/entities.be";
import { countGroups } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be";
import { countUsers, getUser, getUsers } from "@/utils/users.be";
import { countUsersByTypes, getUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import Head from "next/head";
import { useRouter } from "next/router";
import { useMemo } from "react";
import {
BsBank,
BsClipboard2Data,
BsEnvelopePaper,
BsPencilSquare,
BsPeople,
BsPeopleFill,
BsPersonFill,
BsPersonFillGear,
BsBank,
BsEnvelopePaper,
BsPencilSquare,
BsPeople,
BsPeopleFill,
BsPersonFill,
BsPersonFillGear,
} from "react-icons/bs";
import { ToastContainer } from "react-toastify";
interface Props {
user: User;
students: User[];
latestStudents: User[]
latestTeachers: User[]
entities: EntityWithRoles[];
usersCount: { [key in Type]: number }
assignmentsCount: number;
stats: Stat[];
groupsCount: number;
user: User;
students: User[];
latestStudents: User[];
latestTeachers: User[];
entities: EntityWithRoles[];
usersCount: { [key in Type]: number };
assignmentsCount: number;
stats: Stat[];
groupsCount: number;
}
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user || !user.isVerified) return redirect("/login")
const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer"])) return redirect("/")
if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
const [
entities,
usersCount,
groupsCount,
students,
latestStudents,
latestTeachers,
] = await Promise.all([
getEntities(undefined, { _id: 0, id: 1, label: 1 }),
countUsersByTypes(["student", "teacher", "corporate", "mastercorporate"]),
countGroups(),
getUsers(
{ type: "student" },
10,
{
averageLevel: -1,
},
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "student" },
10,
{
registrationDate: -1,
},
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "teacher" },
10,
{
registrationDate: -1,
},
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
]);
const [assignmentsCount, stats] = await Promise.all([
countEntitiesAssignments(mapBy(entities, "id"), {
archived: { $ne: true },
}),
getStatsByUsers(mapBy(students, "id")),
]);
const students = await getUsers({ type: 'student' });
const usersCount = {
student: await countUsers({ type: "student" }),
teacher: await countUsers({ type: "teacher" }),
corporate: await countUsers({ type: "corporate" }),
mastercorporate: await countUsers({ type: "mastercorporate" }),
}
const latestStudents = await getUsers({ type: 'student' }, 10, { registrationDate: -1 })
const latestTeachers = await getUsers({ type: 'teacher' }, 10, { registrationDate: -1 })
const entities = await getEntitiesWithRoles();
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, 'id'), { archived: { $ne: true } });
const groupsCount = await countGroups();
const stats = await getStatsByUsers(mapBy(students, 'id'));
return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, stats, groupsCount }) };
return {
props: serialize({
user,
students,
latestStudents,
latestTeachers,
usersCount,
entities,
assignmentsCount,
stats,
groupsCount,
}),
};
}, sessionOptions);
export default function Dashboard({
user,
students,
latestStudents,
latestTeachers,
usersCount,
entities,
assignmentsCount,
stats,
groupsCount
user,
students,
latestStudents,
latestTeachers,
usersCount,
entities,
assignmentsCount,
stats,
groupsCount,
}: Props) {
const router = useRouter();
const router = useRouter();
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard
onClick={() => router.push("/users?type=student")}
Icon={BsPersonFill}
label="Students"
value={usersCount.student}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={usersCount.teacher}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=corporate")}
label="Corporates"
value={usersCount.corporate}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=mastercorporate")}
label="Master Corporates"
value={usersCount.mastercorporate}
color="purple"
/>
<IconCard
Icon={BsPeople}
onClick={() => router.push("/classrooms")}
label="Classrooms"
value={groupsCount}
color="purple"
/>
<IconCard Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={entities.length}
color="purple"
/>
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")}
label="Entity Statistics"
value={entities.length}
color="purple"
/>
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={usersCount.student}
color="purple"
/>
<IconCard
Icon={BsEnvelopePaper}
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignmentsCount}
color="purple"
/>
</section>
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard
onClick={() => router.push("/users?type=student")}
Icon={BsPersonFill}
label="Students"
value={usersCount.student}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={usersCount.teacher}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=corporate")}
label="Corporates"
value={usersCount.corporate}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=mastercorporate")}
label="Master Corporates"
value={usersCount.mastercorporate}
color="purple"
/>
<IconCard
Icon={BsPeople}
onClick={() => router.push("/classrooms")}
label="Classrooms"
value={groupsCount}
color="purple"
/>
<IconCard
Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={entities.length}
color="purple"
/>
<IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")}
label="Entity Statistics"
value={entities.length}
color="purple"
/>
<IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={usersCount.student}
color="purple"
/>
<IconCard
Icon={BsEnvelopePaper}
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignmentsCount}
color="purple"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList
users={latestStudents}
title="Latest Students"
/>
<UserDisplayList
users={latestTeachers}
title="Latest Teachers"
/>
<UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
}
title="Highest exam count students"
/>
</section>
</Layout>
</>
);
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList users={latestStudents} title="Latest Students" />
<UserDisplayList users={latestTeachers} title="Latest Teachers" />
<UserDisplayList users={students} title="Highest level students" />
<UserDisplayList
users={students.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length
)}
title="Highest exam count students"
/>
</section>
</>
</>
);
}

View File

@@ -1,194 +1,266 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard";
import { EntityWithRoles } from "@/interfaces/entity";
import { Stat, StudentUser, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { countEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { countGroupsByEntities } from "@/utils/groups.be";
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { calculateAverageLevel } from "@/utils/score";
import {
checkAccess,
groupAllowedEntitiesByPermissions,
} from "@/utils/permissions";
import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be";
import { countAllowedUsers, filterAllowedUsers, getUsers } from "@/utils/users.be";
import { countAllowedUsers, getUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import moment from "moment";
import Head from "next/head";
import { useRouter } from "next/router";
import { useMemo } from "react";
import {
BsClipboard2Data,
BsClock,
BsEnvelopePaper,
BsPencilSquare,
BsPeople,
BsPeopleFill,
BsPersonFill,
BsPersonFillGear,
BsClock,
BsEnvelopePaper,
BsPencilSquare,
BsPeople,
BsPeopleFill,
BsPersonFill,
BsPersonFillGear,
} from "react-icons/bs";
import { ToastContainer } from "react-toastify";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { isAdmin } from "@/utils/users";
interface Props {
user: User;
students: StudentUser[]
latestStudents: User[]
latestTeachers: User[]
userCounts: { [key in Type]: number }
entities: EntityWithRoles[];
assignmentsCount: number;
stats: Stat[];
groupsCount: number;
user: User;
students: StudentUser[];
latestStudents: User[];
latestTeachers: User[];
userCounts: { [key in Type]: number };
entities: EntityWithRoles[];
assignmentsCount: number;
stats: Stat[];
groupsCount: number;
}
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user || !user.isVerified) return redirect("/login")
const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "corporate"])) return redirect("/")
if (!checkAccess(user, ["admin", "developer", "corporate"]))
return redirect("/");
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(
isAdmin(user) ? undefined : entityIDS
);
const allowedStudentEntities = findAllowedEntities(user, entities, "view_students")
const allowedTeacherEntities = findAllowedEntities(user, entities, "view_teachers")
const {
["view_students"]: allowedStudentEntities,
["view_teachers"]: allowedTeacherEntities,
} = groupAllowedEntitiesByPermissions(user, entities, [
"view_students",
"view_teachers",
]);
const students =
await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { averageLevel: -1 });
const latestStudents =
await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { registrationDate: -1 })
const latestTeachers =
await getUsers({ type: 'teacher', "entities.id": { $in: mapBy(allowedTeacherEntities, 'id') } }, 10, { registrationDate: -1 })
const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
const entitiesIDS = mapBy(entities, "id") || [];
const userCounts = await countAllowedUsers(user, entities)
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } });
const groupsCount = await countGroupsByEntities(mapBy(entities, "id"));
const [
students,
latestStudents,
latestTeachers,
userCounts,
assignmentsCount,
groupsCount,
] = await Promise.all([
getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10,
{ averageLevel: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10,
{ registrationDate: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{
type: "teacher",
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
},
10,
{ registrationDate: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
countAllowedUsers(user, entities),
countEntitiesAssignments(entitiesIDS, {
archived: { $ne: true },
}),
countGroupsByEntities(entitiesIDS),
]);
return { props: serialize({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, groupsCount }) };
return {
props: serialize({
user,
students,
latestStudents,
latestTeachers,
userCounts,
entities,
assignmentsCount,
groupsCount,
}),
};
}, sessionOptions);
export default function Dashboard({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, stats = [], groupsCount }: Props) {
const totalCount = useMemo(() =>
userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts])
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities])
export default function Dashboard({
user,
students,
latestStudents,
latestTeachers,
userCounts,
entities,
assignmentsCount,
stats = [],
groupsCount,
}: Props) {
const totalCount = useMemo(
() =>
userCounts.corporate +
userCounts.mastercorporate +
userCounts.student +
userCounts.teacher,
[userCounts]
);
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics')
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance')
const totalLicenses = useMemo(
() =>
entities.reduce(
(acc, curr) => acc + parseInt(curr.licenses.toString()),
0
),
[entities]
);
const router = useRouter();
const allowedEntityStatistics = useAllowedEntities(
user,
entities,
"view_entity_statistics"
);
const allowedStudentPerformance = useAllowedEntities(
user,
entities,
"view_student_performance"
);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<div className="w-full flex flex-col gap-4">
{entities.length > 0 && (
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b>
</div>
)}
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
<IconCard
onClick={() => router.push("/users?type=student")}
Icon={BsPersonFill}
label="Students"
value={userCounts.student}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={userCounts.teacher}
color="purple"
/>
<IconCard
onClick={() => router.push("/classrooms")}
Icon={BsPeople}
label="Classrooms"
value={groupsCount}
color="purple"
/>
<IconCard Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={`${entities.length} - ${totalCount}/${totalLicenses}`}
color="purple"
/>
{allowedEntityStatistics.length > 0 && (
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")}
label="Entity Statistics"
value={allowedEntityStatistics.length}
color="purple"
/>
)}
{allowedStudentPerformance.length > 0 && (
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={userCounts.student}
color="purple"
/>
)}
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose"
/>
<IconCard
Icon={BsEnvelopePaper}
className="col-span-2"
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignmentsCount}
color="purple"
/>
</section>
</div>
const router = useRouter();
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList
users={latestStudents}
title="Latest Students"
/>
<UserDisplayList
users={latestTeachers}
title="Latest Teachers"
/>
<UserDisplayList
users={students}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
}
title="Highest exam count students"
/>
</section>
</Layout>
</>
);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<>
<div className="w-full flex flex-col gap-4">
{entities.length > 0 && (
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b>
</div>
)}
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
<IconCard
onClick={() => router.push("/users?type=student")}
Icon={BsPersonFill}
label="Students"
value={userCounts.student}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={userCounts.teacher}
color="purple"
/>
<IconCard
onClick={() => router.push("/classrooms")}
Icon={BsPeople}
label="Classrooms"
value={groupsCount}
color="purple"
/>
<IconCard
Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={`${entities.length} - ${totalCount}/${totalLicenses}`}
color="purple"
/>
{allowedEntityStatistics.length > 0 && (
<IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")}
label="Entity Statistics"
value={allowedEntityStatistics.length}
color="purple"
/>
)}
{allowedStudentPerformance.length > 0 && (
<IconCard
Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={userCounts.student}
color="purple"
/>
)}
<IconCard
Icon={BsClock}
label="Expiration Date"
value={
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose"
/>
<IconCard
Icon={BsEnvelopePaper}
className="col-span-2"
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignmentsCount}
color="purple"
/>
</section>
</div>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList users={latestStudents} title="Latest Students" />
<UserDisplayList users={latestTeachers} title="Latest Teachers" />
<UserDisplayList users={students} title="Highest level students" />
<UserDisplayList
users={students.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length
)}
title="Highest exam count students"
/>
</section>
</>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,137 +1,142 @@
import Layout from "@/components/High/Layout";
import Checkbox from "@/components/Low/Checkbox";
import Separator from "@/components/Low/Separator";
import { useEntityPermission } from "@/hooks/useEntityPermissions";
import { EntityWithRoles, Role } from "@/interfaces/entity";
import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { RolePermission } from "@/resources/entityPermissions";
import { findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getEntityWithRoles } from "@/utils/entities.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { doesEntityAllow } from "@/utils/permissions";
import { isAdmin } from "@/utils/users";
import { countEntityUsers } from "@/utils/users.be";
import {useEntityPermission} from "@/hooks/useEntityPermissions";
import {EntityWithRoles, Role} from "@/interfaces/entity";
import {User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {RolePermission} from "@/resources/entityPermissions";
import {findBy, mapBy, redirect, serialize} from "@/utils";
import {requestUser} from "@/utils/api";
import {getEntityWithRoles} from "@/utils/entities.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {doesEntityAllow} from "@/utils/permissions";
import {isAdmin} from "@/utils/users";
import {countEntityUsers} from "@/utils/users.be";
import axios from "axios";
import { withIronSessionSsr } from "iron-session/next";
import {withIronSessionSsr} from "iron-session/next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { Divider } from "primereact/divider";
import { useState } from "react";
import {
BsCheck,
BsChevronLeft,
BsTag,
BsTrash,
} from "react-icons/bs";
import { toast } from "react-toastify";
import {useRouter} from "next/router";
import {Divider} from "primereact/divider";
import {useState} from "react";
import {BsCheck, BsChevronLeft, BsTag, BsTrash} from "react-icons/bs";
import {toast} from "react-toastify";
type PermissionLayout = { label: string, key: RolePermission }
type PermissionLayout = {label: string; key: RolePermission};
const USER_MANAGEMENT: PermissionLayout[] = [
{ label: "View Students", key: "view_students" },
{ label: "View Teachers", key: "view_teachers" },
{ label: "View Corporate Accounts", key: "view_corporates" },
{ label: "View Master Corporate Accounts", key: "view_mastercorporates" },
{ label: "Edit Students", key: "edit_students" },
{ label: "Edit Teachers", key: "edit_teachers" },
{ label: "Edit Corporate Accounts", key: "edit_corporates" },
{ label: "Edit Master Corporate Accounts", key: "edit_mastercorporates" },
{ label: "Delete Students", key: "delete_students" },
{ label: "Delete Teachers", key: "delete_teachers" },
{ label: "Delete Corporate Accounts", key: "delete_corporates" },
{ label: "Delete Master Corporate Accounts", key: "delete_mastercorporates" },
{ label: "Create a Single User", key: "create_user" },
{ label: "Create Users in Batch", key: "create_user_batch" },
{ label: "Create a Single Code", key: "create_code" },
{ label: "Create Codes in Batch", key: "create_code_batch" },
{ label: "Download User List", key: "download_user_list" },
{ label: "View Code List", key: "view_code_list" },
{ label: "Delete Code", key: "delete_code" },
]
{label: "View Students", key: "view_students"},
{label: "View Teachers", key: "view_teachers"},
{label: "View Corporate Accounts", key: "view_corporates"},
{label: "View Master Corporate Accounts", key: "view_mastercorporates"},
{label: "Edit Students", key: "edit_students"},
{label: "Edit Teachers", key: "edit_teachers"},
{label: "Edit Corporate Accounts", key: "edit_corporates"},
{label: "Edit Master Corporate Accounts", key: "edit_mastercorporates"},
{label: "Delete Students", key: "delete_students"},
{label: "Delete Teachers", key: "delete_teachers"},
{label: "Delete Corporate Accounts", key: "delete_corporates"},
{label: "Delete Master Corporate Accounts", key: "delete_mastercorporates"},
{label: "Create a Single User", key: "create_user"},
{label: "Create Users in Batch", key: "create_user_batch"},
{label: "Create a Single Code", key: "create_code"},
{label: "Create Codes in Batch", key: "create_code_batch"},
{label: "Download User List", key: "download_user_list"},
{label: "View Code List", key: "view_code_list"},
{label: "Delete Code", key: "delete_code"},
];
const EXAM_MANAGEMENT: PermissionLayout[] = [
{ label: "View Reading", key: "view_reading" },
{ label: "Generate Reading", key: "generate_reading" },
{ label: "Delete Reading", key: "delete_reading" },
{ label: "View Listening", key: "view_listening" },
{ label: "Generate Listening", key: "generate_listening" },
{ label: "Delete Listening", key: "delete_listening" },
{ label: "View Writing", key: "view_writing" },
{ label: "Generate Writing", key: "generate_writing" },
{ label: "Delete Writing", key: "delete_writing" },
{ label: "View Speaking", key: "view_speaking" },
{ label: "Generate Speaking", key: "generate_speaking" },
{ label: "Delete Speaking", key: "delete_speaking" },
{ label: "View Level", key: "view_level" },
{ label: "Generate Level", key: "generate_level" },
{ label: "Delete Level", key: "delete_level" },
{ label: "View Statistics", key: "view_statistics" },
]
{label: "View Reading", key: "view_reading"},
{label: "Generate Reading", key: "generate_reading"},
{label: "Delete Reading", key: "delete_reading"},
{label: "View Listening", key: "view_listening"},
{label: "Generate Listening", key: "generate_listening"},
{label: "Delete Listening", key: "delete_listening"},
{label: "View Writing", key: "view_writing"},
{label: "Generate Writing", key: "generate_writing"},
{label: "Delete Writing", key: "delete_writing"},
{label: "View Speaking", key: "view_speaking"},
{label: "Generate Speaking", key: "generate_speaking"},
{label: "Delete Speaking", key: "delete_speaking"},
{label: "View Level", key: "view_level"},
{label: "Generate Level", key: "generate_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"},
];
const CLASSROOM_MANAGEMENT: PermissionLayout[] = [
{ label: "View Classrooms", key: "view_classrooms" },
{ label: "Create Classrooms", key: "create_classroom" },
{ label: "Rename Classrooms", key: "rename_classrooms" },
{ label: "Add to Classroom", key: "add_to_classroom" },
{ label: "Upload to Classroom", key: "upload_classroom" },
{ label: "Remove from Classroom", key: "remove_from_classroom" },
{ label: "Delete Classroom", key: "delete_classroom" },
{ label: "View Student Record", key: "view_student_record" },
{ label: "Download Student Report", key: "download_student_record" },
]
{label: "View Classrooms", key: "view_classrooms"},
{label: "Create Classrooms", key: "create_classroom"},
{label: "Rename Classrooms", key: "rename_classrooms"},
{label: "Add to Classroom", key: "add_to_classroom"},
{label: "Upload to Classroom", key: "upload_classroom"},
{label: "Remove from Classroom", key: "remove_from_classroom"},
{label: "Delete Classroom", key: "delete_classroom"},
{label: "View Student Record", key: "view_student_record"},
{label: "Download Student Report", key: "download_student_record"},
];
const ENTITY_MANAGEMENT: PermissionLayout[] = [
{ label: "View Entities", key: "view_entities" },
{ label: "View Entity Statistics", key: "view_entity_statistics" },
{ label: "Rename Entity", key: "rename_entity" },
{ label: "Add to Entity", key: "add_to_entity" },
{ label: "Remove from Entity", key: "remove_from_entity" },
{ label: "Delete Entity", key: "delete_entity" },
{ label: "View Entity Roles", key: "view_entity_roles" },
{ label: "Create Entity Role", key: "create_entity_role" },
{ label: "Rename Entity Role", key: "rename_entity_role" },
{ label: "Edit Role Permissions", key: "edit_role_permissions" },
{ label: "Assign Role to User", key: "assign_to_role" },
{ label: "Delete Entity Role", key: "delete_entity_role" },
{ label: "Download Statistics Report", key: "download_statistics_report" },
{ label: "Edit Grading System", key: "edit_grading_system" },
{ label: "View Student Performance", key: "view_student_performance" },
{ label: "Pay for Entity", key: "pay_entity" },
{ label: "View Payment Record", key: "view_payment_record" }
]
{label: "View Entities", key: "view_entities"},
{label: "View Entity Statistics", key: "view_entity_statistics"},
{label: "Rename Entity", key: "rename_entity"},
{label: "Add to Entity", key: "add_to_entity"},
{label: "Remove from Entity", key: "remove_from_entity"},
{label: "Delete Entity", key: "delete_entity"},
{label: "View Entity Roles", key: "view_entity_roles"},
{label: "Create Entity Role", key: "create_entity_role"},
{label: "Rename Entity Role", key: "rename_entity_role"},
{label: "Edit Role Permissions", key: "edit_role_permissions"},
{label: "Assign Role to User", key: "assign_to_role"},
{label: "Delete Entity Role", key: "delete_entity_role"},
{label: "Download Statistics Report", key: "download_statistics_report"},
{label: "Edit Grading System", key: "edit_grading_system"},
{label: "View Student Performance", key: "view_student_performance"},
{label: "Pay for Entity", key: "pay_entity"},
{label: "View Payment Record", key: "view_payment_record"},
];
const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [
{ label: "View Assignments", key: "view_assignments" },
{ label: "Create Assignments", key: "create_assignment" },
{ label: "Start Assignments", key: "start_assignment" },
{ label: "Edit Assignments", key: "edit_assignment" },
{ label: "Delete Assignments", key: "delete_assignment" },
{ label: "Archive Assignments", key: "archive_assignment" },
]
{label: "View Assignments", key: "view_assignments"},
{label: "Create Assignments", key: "create_assignment"},
{label: "Start Assignments", key: "start_assignment"},
{label: "Edit Assignments", key: "edit_assignment"},
{label: "Delete Assignments", key: "delete_assignment"},
{label: "Archive Assignments", key: "archive_assignment"},
];
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
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"},
];
if (shouldRedirectHome(user)) return redirect("/")
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login");
const { id, role } = params as { id: string, role: string };
if (shouldRedirectHome(user)) return redirect("/");
if (!mapBy(user.entities, 'id').includes(id) && !["admin", "developer"].includes(user.type)) return redirect("/entities")
const {id, role} = params as {id: string; role: string};
if (!mapBy(user.entities, "id").includes(id) && !["admin", "developer"].includes(user.type)) return redirect("/entities");
const entity = await getEntityWithRoles(id);
if (!entity) return redirect("/entities")
if (!entity) return redirect("/entities");
const entityRole = findBy(entity.roles, 'id', role)
if (!entityRole) return redirect(`/entities/${id}/roles`)
const entityRole = findBy(entity.roles, "id", role);
if (!entityRole) return redirect(`/entities/${id}/roles`);
if (!doesEntityAllow(user, entity, "view_entity_roles")) return redirect(`/entities/${id}`)
const disableEdit = !isAdmin(user) && findBy(user.entities, 'id', entity.id)?.role === entityRole.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 userCount = await countEntityUsers(id, { "entities.role": role });
const userCount = await countEntityUsers(id, {"entities.role": role});
return {
props: serialize({
@@ -139,7 +144,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }
entity,
role: entityRole,
userCount,
disableEdit
disableEdit,
}),
};
}, sessionOptions);
@@ -149,19 +154,18 @@ interface Props {
entity: EntityWithRoles;
role: Role;
userCount: number;
disableEdit?: boolean
disableEdit?: boolean;
}
export default function Role({ user, entity, role, userCount, disableEdit }: Props) {
const [permissions, setPermissions] = useState(role.permissions)
export default function EntityRole({user, entity, role, userCount, disableEdit}: Props) {
const [permissions, setPermissions] = useState(role.permissions);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const canEditPermissions = useEntityPermission(user, entity, "edit_role_permissions")
const canRenameRole = useEntityPermission(user, entity, "rename_entity_role")
const canDeleteRole = useEntityPermission(user, entity, "delete_entity_role")
const canEditPermissions = useEntityPermission(user, entity, "edit_role_permissions");
const canRenameRole = useEntityPermission(user, entity, "rename_entity_role");
const canDeleteRole = useEntityPermission(user, entity, "delete_entity_role");
const renameRole = () => {
if (!canRenameRole || disableEdit) return;
@@ -171,7 +175,7 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
setIsLoading(true);
axios
.patch(`/api/roles/${role.id}`, { label })
.patch(`/api/roles/${role.id}`, {label})
.then(() => {
toast.success("The role has been updated successfully!");
router.replace(router.asPath);
@@ -203,12 +207,12 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
};
const editPermissions = () => {
if (!canEditPermissions || disableEdit) return
if (!canEditPermissions || disableEdit) return;
setIsLoading(true);
axios
.patch(`/api/roles/${role.id}`, { permissions })
.patch(`/api/roles/${role.id}`, {permissions})
.then(() => {
toast.success("This role has been successfully updated!");
router.replace(router.asPath);
@@ -218,21 +222,23 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
}
};
const enableCheckbox = (permission: RolePermission) => {
if (!canEditPermissions || disableEdit) return false
return doesEntityAllow(user, entity, permission)
}
if (!canEditPermissions || disableEdit) return false;
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[]) =>
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 (
<>
<Head>
<title>{role.label} | {entity.label} | EnCoach</title>
<title>
{role.label} | {entity.label} | EnCoach
</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
@@ -240,7 +246,7 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout user={user}>
<>
<section className="flex flex-col gap-0">
<div className="flex flex-col gap-3">
<div className="flex items-end justify-between">
@@ -250,7 +256,9 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">{role.label} Role ({userCount} users)</h2>
<h2 className="font-bold text-2xl">
{role.label} Role ({userCount} users)
</h2>
</div>
</div>
<div className="flex items-center justify-between w-full">
@@ -287,16 +295,19 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
<b>User Management</b>
<Checkbox
disabled={!canEditPermissions || disableEdit}
isChecked={mapBy(USER_MANAGEMENT, 'key').every(k => permissions.includes(k))}
onChange={() => toggleMultiplePermissions(mapBy(USER_MANAGEMENT, 'key').filter(enableCheckbox))}
>
isChecked={mapBy(USER_MANAGEMENT, "key").every((k) => permissions.includes(k))}
onChange={() => toggleMultiplePermissions(mapBy(USER_MANAGEMENT, "key").filter(enableCheckbox))}>
Select all
</Checkbox>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
{USER_MANAGEMENT.map(({ label, key }) => (
<Checkbox disabled={!enableCheckbox(key)} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
{USER_MANAGEMENT.map(({label, key}) => (
<Checkbox
disabled={!enableCheckbox(key)}
key={key}
isChecked={permissions.includes(key)}
onChange={() => togglePermissions(key)}>
{label}
</Checkbox>
))}
@@ -308,16 +319,19 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
<b>Exam Management</b>
<Checkbox
disabled={!canEditPermissions || disableEdit}
isChecked={mapBy(EXAM_MANAGEMENT, 'key').every(k => permissions.includes(k))}
onChange={() => toggleMultiplePermissions(mapBy(EXAM_MANAGEMENT, 'key').filter(enableCheckbox))}
>
isChecked={mapBy(EXAM_MANAGEMENT, "key").every((k) => permissions.includes(k))}
onChange={() => toggleMultiplePermissions(mapBy(EXAM_MANAGEMENT, "key").filter(enableCheckbox))}>
Select all
</Checkbox>
</div>
<Separator />
<div className="grid grid-cols-3 gap-4">
{EXAM_MANAGEMENT.map(({ label, key }) => (
<Checkbox disabled={!enableCheckbox(key)} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
{EXAM_MANAGEMENT.map(({label, key}) => (
<Checkbox
disabled={!enableCheckbox(key)}
key={key}
isChecked={permissions.includes(key)}
onChange={() => togglePermissions(key)}>
{label}
</Checkbox>
))}
@@ -329,16 +343,19 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
<b>Clasroom Management</b>
<Checkbox
disabled={!canEditPermissions || disableEdit}
isChecked={mapBy(CLASSROOM_MANAGEMENT, 'key').every(k => permissions.includes(k))}
onChange={() => toggleMultiplePermissions(mapBy(CLASSROOM_MANAGEMENT, 'key').filter(enableCheckbox))}
>
isChecked={mapBy(CLASSROOM_MANAGEMENT, "key").every((k) => permissions.includes(k))}
onChange={() => toggleMultiplePermissions(mapBy(CLASSROOM_MANAGEMENT, "key").filter(enableCheckbox))}>
Select all
</Checkbox>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
{CLASSROOM_MANAGEMENT.map(({ label, key }) => (
<Checkbox disabled={!enableCheckbox(key)} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
{CLASSROOM_MANAGEMENT.map(({label, key}) => (
<Checkbox
disabled={!enableCheckbox(key)}
key={key}
isChecked={permissions.includes(key)}
onChange={() => togglePermissions(key)}>
{label}
</Checkbox>
))}
@@ -350,16 +367,19 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
<b>Entity Management</b>
<Checkbox
disabled={!canEditPermissions || disableEdit}
isChecked={mapBy(ENTITY_MANAGEMENT, 'key').every(k => permissions.includes(k))}
onChange={() => toggleMultiplePermissions(mapBy(ENTITY_MANAGEMENT, 'key').filter(enableCheckbox))}
>
isChecked={mapBy(ENTITY_MANAGEMENT, "key").every((k) => permissions.includes(k))}
onChange={() => toggleMultiplePermissions(mapBy(ENTITY_MANAGEMENT, "key").filter(enableCheckbox))}>
Select all
</Checkbox>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
{ENTITY_MANAGEMENT.map(({ label, key }) => (
<Checkbox disabled={!enableCheckbox(key)} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
{ENTITY_MANAGEMENT.map(({label, key}) => (
<Checkbox
disabled={!enableCheckbox(key)}
key={key}
isChecked={permissions.includes(key)}
onChange={() => togglePermissions(key)}>
{label}
</Checkbox>
))}
@@ -371,16 +391,43 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
<b>Assignment Management</b>
<Checkbox
disabled={!canEditPermissions || disableEdit}
isChecked={mapBy(ASSIGNMENT_MANAGEMENT, 'key').every(k => permissions.includes(k))}
onChange={() => toggleMultiplePermissions(mapBy(ASSIGNMENT_MANAGEMENT, 'key').filter(enableCheckbox))}
>
isChecked={mapBy(ASSIGNMENT_MANAGEMENT, "key").every((k) => permissions.includes(k))}
onChange={() => toggleMultiplePermissions(mapBy(ASSIGNMENT_MANAGEMENT, "key").filter(enableCheckbox))}>
Select all
</Checkbox>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
{ASSIGNMENT_MANAGEMENT.map(({ label, key }) => (
<Checkbox disabled={!enableCheckbox(key)} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
{ASSIGNMENT_MANAGEMENT.map(({label, 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}
</Checkbox>
))}
@@ -388,7 +435,7 @@ export default function Role({ user, entity, role, userCount, disableEdit }: Pro
</div>
</section>
</section>
</Layout>
</>
</>
);
}

View File

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

View File

@@ -1,19 +1,16 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import Tooltip from "@/components/Low/Tooltip";
import { useListSearch } from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import { Entity, EntityWithRoles } from "@/interfaces/entity";
import { Entity } from "@/interfaces/entity";
import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { USER_TYPE_LABELS } from "@/resources/user";
import { mapBy, redirect, serialize } from "@/utils";
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
import { redirect, serialize } from "@/utils";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getUserName } from "@/utils/users";
import { getLinkedUsers, getUsers } from "@/utils/users.be";
import { getUsers } from "@/utils/users.be";
import axios from "axios";
import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next";
@@ -23,162 +20,218 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { Divider } from "primereact/divider";
import { useState } from "react";
import { BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill } from "react-icons/bs";
import {
BsCheck,
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsStopwatchFill,
} from "react-icons/bs";
import { toast, ToastContainer } from "react-toastify";
import { requestUser } from "@/utils/api";
import { findAllowedEntities } from "@/utils/permissions";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/")
if (!["admin", "developer"].includes(user.type)) return redirect("/entities")
if (shouldRedirectHome(user)) return redirect("/");
if (!["admin", "developer"].includes(user.type)) return redirect("/entities");
const users = await getUsers()
const users = await getUsers(
{ id: { $ne: user.id } },
0,
{},
{
_id: 0,
id: 1,
name: 1,
type: 1,
profilePicture: 1,
email: 1,
lastLogin: 1,
subscriptionExpirationDate: 1,
}
);
return {
props: serialize({ user, users: users.filter((x) => x.id !== user.id) }),
};
return {
props: serialize({ user, users }),
};
}, sessionOptions);
interface Props {
user: User;
users: User[];
user: User;
users: User[];
}
export default function Home({ user, users }: Props) {
const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [label, setLabel] = useState("");
const [licenses, setLicenses] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [label, setLabel] = useState("");
const [licenses, setLicenses] = useState(0);
const { rows, renderSearch } = useListSearch<User>([["name"], ["corporateInformation", "companyInformation", "name"]], users);
const { items, renderMinimal } = usePagination<User>(rows, 16);
const { rows, renderSearch } = useListSearch<User>(
[["name"], ["corporateInformation", "companyInformation", "name"]],
users
);
const { items, renderMinimal } = usePagination<User>(rows, 16);
const router = useRouter();
const router = useRouter();
const createGroup = () => {
if (!label.trim()) return;
if (!confirm(`Are you sure you want to create this entity with ${selectedUsers.length} members?`)) return;
const createGroup = () => {
if (!label.trim()) return;
if (
!confirm(
`Are you sure you want to create this entity with ${selectedUsers.length} members?`
)
)
return;
setIsLoading(true);
setIsLoading(true);
axios
.post<Entity>(`/api/entities`, { label, licenses, members: selectedUsers })
.then((result) => {
toast.success("Your entity has been created successfully!");
router.replace(`/entities/${result.data.id}`);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
axios
.post<Entity>(`/api/entities`, {
label,
licenses,
members: selectedUsers,
})
.then((result) => {
toast.success("Your entity has been created successfully!");
router.replace(`/entities/${result.data.id}`);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
const toggleUser = (u: User) =>
setSelectedUsers((prev) =>
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
);
return (
<>
<Head>
<title>Create Entity | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<section className="flex flex-col gap-0">
<div className="flex gap-3 justify-between">
<div className="flex items-center gap-2">
<Link
href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">Create Entity</h2>
</div>
<div className="flex items-center gap-4">
<button
onClick={createGroup}
disabled={!label.trim() || licenses <= 0 || isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsCheck />
<span className="text-xs">Create Entity</span>
</button>
</div>
</div>
<Divider />
<div className="w-full grid grid-cols-2 gap-4">
<div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Entity Label:</span>
<Input name="name" onChange={setLabel} type="text" placeholder="Entity A" />
</div>
return (
<>
<Head>
<title>Create Entity | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<>
<section className="flex flex-col gap-0">
<div className="flex gap-3 justify-between">
<div className="flex items-center gap-2">
<Link
href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">Create Entity</h2>
</div>
<div className="flex items-center gap-4">
<button
onClick={createGroup}
disabled={!label.trim() || licenses <= 0 || isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsCheck />
<span className="text-xs">Create Entity</span>
</button>
</div>
</div>
<Divider />
<div className="w-full grid grid-cols-2 gap-4">
<div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Entity Label:</span>
<Input
name="name"
onChange={setLabel}
type="text"
placeholder="Entity A"
/>
</div>
<div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Licenses:</span>
<Input name="licenses" min={0} onChange={(v) => setLicenses(parseInt(v))} type="number" placeholder="12" />
</div>
</div>
<Divider />
<div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Members ({selectedUsers.length} selected):</span>
</div>
<div className="w-full flex items-center gap-4">
{renderSearch()}
{renderMinimal()}
</div>
</section>
<div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Licenses:</span>
<Input
name="licenses"
min={0}
onChange={(v) => setLicenses(parseInt(v))}
type="number"
placeholder="12"
/>
</div>
</div>
<Divider />
<div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">
Members ({selectedUsers.length} selected):
</span>
</div>
<div className="w-full flex items-center gap-4">
{renderSearch()}
{renderMinimal()}
</div>
</section>
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{items.map((u) => (
<button
onClick={() => toggleUser(u)}
disabled={isLoading}
key={u.id}
className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple",
)}>
<div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} />
</div>
<div className="flex flex-col">
<span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span>
</div>
</div>
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{items.map((u) => (
<button
onClick={() => toggleUser(u)}
disabled={isLoading}
key={u.id}
className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple"
)}
>
<div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} />
</div>
<div className="flex flex-col">
<span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">
{USER_TYPE_LABELS[u.type]}
</span>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="flex items-center gap-2">
<Tooltip tooltip="E-mail address">
<BsEnvelopeFill />
</Tooltip>
{u.email}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Last Login">
<BsClockFill />
</Tooltip>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
</span>
</div>
</button>
))}
</section>
</Layout>
</>
);
<div className="flex flex-col gap-1">
<span className="flex items-center gap-2">
<Tooltip tooltip="E-mail address">
<BsEnvelopeFill />
</Tooltip>
{u.email}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
: "Unlimited"}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Last Login">
<BsClockFill />
</Tooltip>
{u.lastLogin
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
: "N/A"}
</span>
</div>
</button>
))}
</section>
</>
</>
);
}

View File

@@ -3,15 +3,12 @@ import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
import { GroupWithUsers, User } from "@/interfaces/user";
import { User } from "@/interfaces/user";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getUserName, isAdmin } from "@/utils/users";
import { convertToUsers, getGroupsForUser } from "@/utils/groups.be";
import { countEntityUsers, getEntityUsers, getSpecificUsers, getUsers } from "@/utils/users.be";
import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions";
import { countEntityUsers, getEntityUsers } from "@/utils/users.be";
import { findAllowedEntities } from "@/utils/permissions";
import Link from "next/link";
import { uniq } from "lodash";
import { BsBank, BsPlus } from "react-icons/bs";
import CardList from "@/components/High/CardList";
import { getEntitiesWithRoles } from "@/utils/entities.be";
@@ -20,99 +17,144 @@ import Separator from "@/components/Low/Separator";
import { requestUser } from "@/utils/api";
import { mapBy, redirect, serialize } from "@/utils";
type EntitiesWithCount = { entity: EntityWithRoles; users: User[]; count: number };
type EntitiesWithCount = {
entity: EntityWithRoles;
users: User[];
count: number;
};
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/")
if (shouldRedirectHome(user)) return redirect("/");
const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(["admin", "developer"].includes(user.type) ? undefined : entityIDs);
const allowedEntities = findAllowedEntities(user, entities, 'view_entities')
const entityIDs = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(
["admin", "developer"].includes(user.type) ? undefined : entityIDs
);
const allowedEntities = findAllowedEntities(user, entities, "view_entities");
const entitiesWithCount = await Promise.all(
allowedEntities.map(async (e) => ({
entity: e,
count: await countEntityUsers(e.id, { type: { $in: ["student", "teacher", "corporate", "mastercorporate"] } }),
users: await getEntityUsers(e.id, 5, { type: { $in: ["student", "teacher", "corporate", "mastercorporate"] } })
})),
);
const [counts, users] = await Promise.all([
await Promise.all(
allowedEntities.map(async (e) =>
countEntityUsers(e.id, {
type: { $in: ["student", "teacher", "corporate", "mastercorporate"] },
})
)
),
await Promise.all(
allowedEntities.map(async (e) =>
getEntityUsers(
e.id,
5,
{
type: {
$in: ["student", "teacher", "corporate", "mastercorporate"],
},
},
{ name: 1 }
)
)
),
]);
return {
props: serialize({ user, entities: entitiesWithCount }),
};
const entitiesWithCount = allowedEntities.map<{
entity: EntityWithRoles;
users: User[];
count: number;
}>((e, i) => ({ entity: e, users: users[i], count: counts[i] }));
return {
props: serialize({ user, entities: entitiesWithCount }),
};
}, sessionOptions);
const SEARCH_FIELDS: string[][] = [["entity", "label"]];
interface Props {
user: User;
entities: EntitiesWithCount[];
user: User;
entities: EntitiesWithCount[];
}
export default function Home({ user, entities }: Props) {
const renderCard = ({ entity, users, count }: EntitiesWithCount) => (
<Link
href={`/entities/${entity.id}`}
key={entity.id}
className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer">
<div className="flex flex-col gap-2 w-full">
<span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Entity</span>
{entity.label}
</span>
<span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Members</span>
<span className="bg-mti-purple-light/50 px-2">{count}{isAdmin(user) && ` / ${entity.licenses || 0}`}</span>
</span>
<span>
{users.map(getUserName).join(", ")}{' '}
{count > 5 ? <span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">and {count - 5} more</span> : ""}
</span>
</div>
<div className="w-fit">
<BsBank className="w-full h-20 -translate-y-[15%] group-hover:text-mti-purple transition ease-in-out duration-300" />
</div>
</Link>
);
const renderCard = ({ entity, users, count }: EntitiesWithCount) => (
<Link
href={`/entities/${entity.id}`}
key={entity.id}
className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer"
>
<div className="flex flex-col gap-2 w-full">
<span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">
Entity
</span>
{entity.label}
</span>
<span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">
Members
</span>
<span className="bg-mti-purple-light/50 px-2">
{count}
{isAdmin(user) && ` / ${entity.licenses || 0}`}
</span>
</span>
<span>
{users.map(getUserName).join(", ")}{" "}
{count > 5 ? (
<span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">
and {count - 5} more
</span>
) : (
""
)}
</span>
</div>
<div className="w-fit">
<BsBank className="w-full h-20 -translate-y-[15%] group-hover:text-mti-purple transition ease-in-out duration-300" />
</div>
</Link>
);
const firstCard = () => (
<Link
href={`/entities/create`}
className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer">
<BsPlus size={40} />
<span className="font-semibold">Create Entity</span>
</Link>
);
const firstCard = () => (
<Link
href={`/entities/create`}
className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer"
>
<BsPlus size={40} />
<span className="font-semibold">Create Entity</span>
</Link>
);
return (
<>
<Head>
<title>Entities | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user} className="!gap-4">
<section className="flex flex-col gap-4 w-full h-full">
<div className="flex flex-col gap-4">
<h2 className="font-bold text-2xl">Entities</h2>
<Separator />
</div>
return (
<>
<Head>
<title>Entities | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<>
<section className="flex flex-col gap-4 w-full h-full">
<div className="flex flex-col gap-4">
<h2 className="font-bold text-2xl">Entities</h2>
<Separator />
</div>
<CardList<EntitiesWithCount>
list={entities}
searchFields={SEARCH_FIELDS}
renderCard={renderCard}
firstCard={["admin", "developer"].includes(user.type) ? firstCard : undefined}
/>
</section>
</Layout>
</>
);
<CardList<EntitiesWithCount>
list={entities}
searchFields={SEARCH_FIELDS}
renderCard={renderCard}
firstCard={
["admin", "developer"].includes(user.type) ? firstCard : undefined
}
/>
</section>
</>
</>
);
}

View File

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

View File

@@ -21,92 +21,100 @@ import { getSessionByAssignment } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions";
import moment from "moment";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
const user = await requestUser(req, res)
const destination = Buffer.from(req.url || "/").toString("base64")
if (!user) return redirect(`/login?destination=${destination}`)
export const getServerSideProps = withIronSessionSsr(
async ({ req, res, query }) => {
const user = await requestUser(req, res);
const destination = Buffer.from(req.url || "/").toString("base64");
if (!user) return redirect(`/login?destination=${destination}`);
if (shouldRedirectHome(user)) return redirect("/")
if (shouldRedirectHome(user)) return redirect("/");
const { assignment: assignmentID } = query as { assignment?: string }
const { assignment: assignmentID } = query as { assignment?: string };
if (assignmentID) {
const assignment = await getAssignment(assignmentID)
if (assignmentID) {
const assignment = await getAssignment(assignmentID);
if (!assignment) return redirect("/exam")
if (!["admin", "developer"].includes(user.type) && !assignment.assignees.includes(user.id)) return redirect("/exercises")
if (!assignment) return redirect("/exam");
if (
!["admin", "developer"].includes(user.type) &&
!assignment.assignees.includes(user.id)
)
return redirect("/exercises");
if (
filterBy(assignment.results, "user", user.id) ||
moment(assignment.startDate).isBefore(moment()) ||
moment(assignment.endDate).isAfter(moment())
)
return redirect("/exam");
const [exams, session] = await Promise.all([
getExamsByIds(uniqBy(assignment.exams, "id")),
getSessionByAssignment(assignmentID),
]);
const exams = await getExamsByIds(uniqBy(assignment.exams, "id"))
const session = await getSessionByAssignment(assignmentID)
return {
props: serialize({ user, assignment, exams, session }),
};
}
if (
filterBy(assignment.results, 'user', user.id) ||
moment(assignment.startDate).isBefore(moment()) ||
moment(assignment.endDate).isAfter(moment())
)
return redirect("/exam")
return {
props: serialize({ user, assignment, exams, session })
}
}
return {
props: serialize({ user }),
};
}, sessionOptions);
return {
props: serialize({ user }),
};
},
sessionOptions
);
interface Props {
user: User;
assignment?: Assignment
exams?: Exam[]
session?: Session
user: User;
assignment?: Assignment;
exams?: Exam[];
session?: Session;
}
export default function Page({ user, assignment, exams = [], session }: Props) {
const router = useRouter()
const router = useRouter();
const { assignment: storeAssignment, dispatch } = useExamStore()
const { assignment: storeAssignment, dispatch } = useExamStore();
useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !session) {
dispatch({
type: "INIT_EXAM", payload: {
exams: exams.sort(sortByModule),
modules: exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
assignment
}
})
useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !session) {
dispatch({
type: "INIT_EXAM",
payload: {
exams: exams.sort(sortByModule),
modules: exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
assignment,
},
});
router.replace(router.asPath)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session])
router.replace(router.asPath);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]);
useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
dispatch({ type: "SET_SESSION", payload: { session } });
useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
dispatch({ type: "SET_SESSION", payload: { session } });
router.replace(router.asPath)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session])
router.replace(router.asPath);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]);
return (
<>
<Head>
<title>Exams | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ExamPage page="exams" user={user} hideSidebar={!!assignment} />
</>
);
return (
<>
<Head>
<title>Exams | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ExamPage page="exams" user={user} hideSidebar={!!assignment} />
</>
);
}

View File

@@ -1,41 +1,40 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { toast, ToastContainer } from "react-toastify";
import Layout from "@/components/High/Layout";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { Radio, RadioGroup } from "@headlessui/react";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {ToastContainer} from "react-toastify";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {Radio, RadioGroup} from "@headlessui/react";
import clsx from "clsx";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { capitalize } from "lodash";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {capitalize} from "lodash";
import Input from "@/components/Low/Input";
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { User } from "@/interfaces/user";
import {findAllowedEntities} from "@/utils/permissions";
import {User} from "@/interfaces/user";
import useExamEditorStore from "@/stores/examEditor";
import ExamEditorStore from "@/stores/examEditor/types";
import ExamEditor from "@/components/ExamEditor";
import MultipleAudioUploader from "@/components/ExamEditor/Shared/AudioEdit";
import { mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { Module } from "@/interfaces";
import { getExam, getExams } from "@/utils/exams.be";
import { Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise } from "@/interfaces/exam";
import { useEffect, useState } from "react";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { isAdmin } from "@/utils/users";
import {mapBy, redirect, serialize} from "@/utils";
import {requestUser} from "@/utils/api";
import {Module} from "@/interfaces";
import {getExam} from "@/utils/exams.be";
import {Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise} from "@/interfaces/exam";
import {useEffect, useState} from "react";
import {getEntitiesWithRoles} from "@/utils/entities.be";
import {isAdmin} from "@/utils/users";
import axios from "axios";
import {EntityWithRoles} from "@/interfaces/entity";
type Permission = { [key in Module]: boolean }
type Permission = {[key in Module]: boolean};
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/")
if (shouldRedirectHome(user)) return redirect("/");
const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs)
const entityIDs = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs);
const permissions: Permission = {
reading: findAllowedEntities(user, entities, `generate_reading`).length > 0,
@@ -43,29 +42,46 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, query })
writing: findAllowedEntities(user, entities, `generate_writing`).length > 0,
speaking: findAllowedEntities(user, entities, `generate_speaking`).length > 0,
level: findAllowedEntities(user, entities, `generate_level`).length > 0,
}
};
if (Object.keys(permissions).every(p => !permissions[p as Module])) return redirect("/")
const entitiesAllowEditPrivacy = findAllowedEntities(user, entities, "update_exam_privacy");
console.log(entitiesAllowEditPrivacy);
const { id, module: examModule } = query as { id?: string, module?: Module }
if (!id || !examModule) return { props: serialize({ user, permissions }) };
if (Object.keys(permissions).every((p) => !permissions[p as Module])) return redirect("/");
const {id, module: examModule} = query as {id?: string; module?: Module};
if (!id || !examModule) return {props: serialize({user, permissions})};
//if (!permissions[module]) return redirect("/generation")
const exam = await getExam(examModule, id)
if (!exam) return redirect("/generation")
const exam = await getExam(examModule, id);
if (!exam) return redirect("/generation");
return {
props: serialize({ id, user, exam, examModule, permissions }),
props: serialize({id, user, exam, examModule, permissions, entitiesAllowEditPrivacy}),
};
}, sessionOptions);
export default function Generation({ id, user, exam, examModule, permissions }: { id: string, user: User; exam?: Exam, examModule?: Module, permissions: Permission }) {
const { title, currentModule, modules, dispatch } = useExamEditorStore();
export default function Generation({
id,
user,
exam,
examModule,
permissions,
entitiesAllowEditPrivacy,
}: {
id: string;
user: User;
exam?: Exam;
examModule?: Module;
permissions: Permission;
entitiesAllowEditPrivacy: EntityWithRoles[];
}) {
const {title, currentModule, modules, dispatch} = useExamEditorStore();
const [examLevelParts, setExamLevelParts] = useState<number | undefined>(undefined);
const updateRoot = (updates: Partial<ExamEditorStore>) => {
dispatch({ type: 'UPDATE_ROOT', payload: { updates } });
dispatch({type: "UPDATE_ROOT", payload: {updates}});
};
useEffect(() => {
@@ -73,16 +89,16 @@ export default function Generation({ id, user, exam, examModule, permissions }:
if (examModule === "level" && exam.module === "level") {
setExamLevelParts(exam.parts.length);
}
updateRoot({currentModule: examModule})
dispatch({ type: "INIT_EXAM_EDIT", payload: { exam, id, examModule } })
updateRoot({currentModule: examModule});
dispatch({type: "INIT_EXAM_EDIT", payload: {exam, id, examModule}});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, exam, module])
}, [id, exam, module]);
useEffect(() => {
const fetchAvatars = async () => {
const response = await axios.get("/api/exam/avatars");
updateRoot({ speakingAvatars: response.data });
updateRoot({speakingAvatars: response.data});
};
fetchAvatars();
@@ -98,48 +114,61 @@ export default function Generation({ id, user, exam, examModule, permissions }:
URL.revokeObjectURL(state.writing.academic_url);
}
state.listening.sections.forEach(section => {
state.listening.sections.forEach((section) => {
const listeningPart = section.state as ListeningPart;
if (listeningPart.audio?.source) {
URL.revokeObjectURL(listeningPart.audio.source);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId: section.sectionId, module: "listening", field: "state", value: { ...listeningPart, audio: undefined }
}
})
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId: section.sectionId,
module: "listening",
field: "state",
value: {...listeningPart, audio: undefined},
},
});
}
});
if (state.listening.instructionsState.customInstructionsURL.startsWith('blob:')) {
if (state.listening.instructionsState.customInstructionsURL.startsWith("blob:")) {
URL.revokeObjectURL(state.listening.instructionsState.customInstructionsURL);
}
state.speaking.sections.forEach(section => {
state.speaking.sections.forEach((section) => {
const sectionState = section.state as Exercise;
if (sectionState.type === 'speaking') {
if (sectionState.type === "speaking") {
const speakingExercise = sectionState as SpeakingExercise;
URL.revokeObjectURL(speakingExercise.video_url);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId: section.sectionId, module: "listening", field: "state", value: { ...speakingExercise, video_url: undefined }
}
})
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId: section.sectionId,
module: "listening",
field: "state",
value: {...speakingExercise, video_url: undefined},
},
});
}
if (sectionState.type === 'interactiveSpeaking') {
if (sectionState.type === "interactiveSpeaking") {
const interactiveSpeaking = sectionState as InteractiveSpeakingExercise;
interactiveSpeaking.prompts.forEach(prompt => {
interactiveSpeaking.prompts.forEach((prompt) => {
URL.revokeObjectURL(prompt.video_url);
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId: section.sectionId, module: "listening", field: "state", value: {
...interactiveSpeaking, prompts: interactiveSpeaking.prompts.map((p) => ({ ...p, video_url: undefined }))
}
}
})
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId: section.sectionId,
module: "listening",
field: "state",
value: {
...interactiveSpeaking,
prompts: interactiveSpeaking.prompts.map((p) => ({...p, video_url: undefined})),
},
},
});
}
});
dispatch({ type: 'FULL_RESET' });
dispatch({type: "FULL_RESET"});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -157,7 +186,7 @@ export default function Generation({ id, user, exam, examModule, permissions }:
</Head>
<ToastContainer />
{user && (
<Layout user={user} className="gap-6">
<>
<h1 className="text-2xl font-semibold">Exam Editor</h1>
<div className="flex flex-col gap-3">
<Input
@@ -165,7 +194,7 @@ export default function Generation({ id, user, exam, examModule, permissions }:
placeholder="Insert a title here"
name="title"
label="Title"
onChange={(title) => updateRoot({ title })}
onChange={(title) => updateRoot({title})}
roundness="xl"
value={title}
defaultValue={title}
@@ -174,45 +203,47 @@ export default function Generation({ id, user, exam, examModule, permissions }:
<label className="font-normal text-base text-mti-gray-dim">Module</label>
<RadioGroup
value={currentModule}
onChange={(currentModule) => updateRoot({ currentModule })}
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
{[...MODULE_ARRAY].filter(m => permissions[m]).map((x) => (
<Radio value={x} key={x}>
{({ checked }) => (
<span
className={clsx(
"px-6 py-4 w-64 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
x === "reading" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-reading/70 border-ielts-reading text-white"),
x === "listening" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-listening/70 border-ielts-listening text-white"),
x === "writing" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-writing/70 border-ielts-writing text-white"),
x === "speaking" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
x === "level" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-level/70 border-ielts-level text-white"),
)}>
{capitalize(x)}
</span>
)}
</Radio>
))}
onChange={(currentModule) => updateRoot({currentModule})}
className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between">
{[...MODULE_ARRAY]
.filter((m) => permissions[m])
.map((x) => (
<Radio value={x} key={x}>
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-64 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
x === "reading" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-reading/70 border-ielts-reading text-white"),
x === "listening" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-listening/70 border-ielts-listening text-white"),
x === "writing" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-writing/70 border-ielts-writing text-white"),
x === "speaking" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
x === "level" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-level/70 border-ielts-level text-white"),
)}>
{capitalize(x)}
</span>
)}
</Radio>
))}
</RadioGroup>
</div>
<ExamEditor levelParts={examLevelParts} />
</Layout>
<ExamEditor levelParts={examLevelParts} entitiesAllowEditPrivacy={entitiesAllowEditPrivacy} />
</>
)}
</>
);

View File

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

View File

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

View File

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

View File

@@ -1,188 +1,216 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import {useEffect, useState} from "react";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {Permission, PermissionType} from "@/interfaces/permissions";
import {getPermissionDoc} from "@/utils/permissions.be";
import {User} from "@/interfaces/user";
import Layout from "@/components/High/Layout";
import {getUsers} from "@/utils/users.be";
import {BsTrash} from "react-icons/bs";
import React, { useEffect, useState } from "react";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { Permission, PermissionType } from "@/interfaces/permissions";
import { getPermissionDoc } from "@/utils/permissions.be";
import { User } from "@/interfaces/user";
import { LayoutContext } from "@/components/High/Layout";
import { getUsers } from "@/utils/users.be";
import { BsTrash } from "react-icons/bs";
import Select from "@/components/Low/Select";
import Button from "@/components/Low/Button";
import axios from "axios";
import {toast, ToastContainer} from "react-toastify";
import {Type as UserType} from "@/interfaces/user";
import {getGroups} from "@/utils/groups.be";
import { toast, ToastContainer } from "react-toastify";
import { Type as UserType } from "@/interfaces/user";
import { getGroups } from "@/utils/groups.be";
import { requestUser } from "@/utils/api";
import { redirect } from "@/utils";
import { G } from "@react-pdf/renderer";
interface BasicUser {
id: string;
name: string;
type: UserType;
id: string;
name: string;
type: UserType;
}
interface PermissionWithBasicUsers {
id: string;
type: PermissionType;
users: BasicUser[];
id: string;
type: PermissionType;
users: BasicUser[];
}
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
export const getServerSideProps = withIronSessionSsr(
async ({ req, res, params }) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/")
if (shouldRedirectHome(user)) return redirect("/");
if (!params?.id) return redirect("/permissions")
if (!params?.id) return redirect("/permissions");
// Fetch data from external API
const permission: Permission = await getPermissionDoc(params.id as string);
// Fetch data from external API
const [permission, users, groups] = await Promise.all([
getPermissionDoc(params.id as string),
getUsers({}, 0, {}, { _id: 0, id: 1, name: 1, type: 1 }),
getGroups(),
]);
const allUserData: User[] = await getUsers();
const groups = await getGroups();
const userGroups = groups.filter((x) => x.admin === user.id);
const userGroupsParticipants = userGroups.flatMap((x) => x.participants);
const filteredGroups =
user.type === "corporate"
? userGroups
: user.type === "mastercorporate"
? groups.filter((x) => userGroupsParticipants.includes(x.admin))
: groups;
const filteredGroupsParticipants = filteredGroups.flatMap(
(g) => g.participants
);
const filteredUsers = ["mastercorporate", "corporate"].includes(user.type)
? users.filter((u) => filteredGroupsParticipants.includes(u.id))
: users;
const userGroups = groups.filter((x) => x.admin === user.id);
const filteredGroups =
user.type === "corporate"
? userGroups
: user.type === "mastercorporate"
? groups.filter((x) => userGroups.flatMap((y) => y.participants).includes(x.admin))
: groups;
// const res = await fetch("api/permissions");
// const permissions: Permission[] = await res.json();
// Pass data to the page via props
const usersData: BasicUser[] = permission.users.reduce(
(acc: BasicUser[], userId) => {
const user = filteredUsers.find((u) => u.id === userId) as BasicUser;
if (!!user) acc.push(user);
return acc;
},
[]
);
const users = allUserData.map((u) => ({
id: u.id,
name: u.name,
type: u.type,
})) as BasicUser[];
const filteredUsers = ["mastercorporate", "corporate"].includes(user.type)
? users.filter((u) => filteredGroups.flatMap((g) => g.participants).includes(u.id))
: users;
// const res = await fetch("api/permissions");
// const permissions: Permission[] = await res.json();
// Pass data to the page via props
const usersData: BasicUser[] = permission.users.reduce((acc: BasicUser[], userId) => {
const user = filteredUsers.find((u) => u.id === userId) as BasicUser;
if (!!user) acc.push(user);
return acc;
}, []);
return {
props: {
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
permission: {
...permission,
id: params.id,
users: usersData,
},
user,
users: filteredUsers,
},
};
}, sessionOptions);
return {
props: {
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
permission: {
...permission,
id: params.id,
users: usersData,
},
user,
users: filteredUsers,
},
};
},
sessionOptions
);
interface Props {
permission: PermissionWithBasicUsers;
user: User;
users: BasicUser[];
permission: PermissionWithBasicUsers;
user: User;
users: BasicUser[];
}
export default function Page(props: Props) {
const {permission, user, users} = props;
const { permission, user, users } = props;
const [selectedUsers, setSelectedUsers] = useState<string[]>(() => permission.users.map((u) => u.id));
const [selectedUsers, setSelectedUsers] = useState<string[]>(() =>
permission.users.map((u) => u.id)
);
const onChange = (value: any) => {
setSelectedUsers((prev) => {
if (value?.value) {
return [...prev, value?.value];
}
return prev;
});
};
const removeUser = (id: string) => {
setSelectedUsers((prev) => prev.filter((u) => u !== id));
};
const onChange = (value: any) => {
setSelectedUsers((prev) => {
if (value?.value) {
return [...prev, value?.value];
}
return prev;
});
};
const removeUser = (id: string) => {
setSelectedUsers((prev) => prev.filter((u) => u !== id));
};
const update = async () => {
try {
await axios.patch(`/api/permissions/${permission.id}`, {
users: selectedUsers,
});
toast.success("Permission updated");
} catch (err) {
toast.error("Failed to update permission");
}
};
const update = async () => {
try {
await axios.patch(`/api/permissions/${permission.id}`, {
users: selectedUsers,
});
toast.success("Permission updated");
} catch (err) {
toast.error("Failed to update permission");
}
};
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user} className="gap-6">
<div className="flex flex-col gap-6 w-full h-[88vh] overflow-y-scroll scrollbar-hide rounded-xl">
<h1 className="text-2xl font-semibold">Permission: {permission.type as string}</h1>
<div className="flex gap-3">
<Select
value={null}
options={users
.filter((u) => !selectedUsers.includes(u.id))
.map((u) => ({
label: `${u?.type}-${u?.name}`,
value: u.id,
}))}
onChange={onChange}
/>
<Button onClick={update}>Update</Button>
</div>
<div className="flex flex-row justify-between">
<div className="flex flex-col gap-3">
<h2>Blacklisted Users</h2>
<div className="flex gap-3 flex-wrap">
{selectedUsers.map((userId) => {
const user = users.find((u) => u.id === userId);
return (
<div className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" key={userId}>
<span className="text-base first-letter:uppercase">
{user?.type}-{user?.name}
</span>
<BsTrash style={{cursor: "pointer"}} onClick={() => removeUser(userId)} size={20} />
</div>
);
})}
</div>
</div>
<div className="flex flex-col gap-3">
<h2>Whitelisted Users</h2>
<div className="flex flex-col gap-3 flex-wrap">
{users
.filter((user) => !selectedUsers.includes(user.id))
.map((user) => {
return (
<div className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" key={user.id}>
<span className="text-base first-letter:uppercase">
{user?.type}-{user?.name}
</span>
</div>
);
})}
</div>
</div>
</div>
</div>
</Layout>
</>
);
const { setClassName } = React.useContext(LayoutContext);
useEffect(() => {
setClassName("gap-6");
return () => setClassName("");
}, [setClassName]);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<>
<div className="flex flex-col gap-6 w-full h-[88vh] overflow-y-scroll scrollbar-hide rounded-xl">
<h1 className="text-2xl font-semibold">
Permission: {permission.type as string}
</h1>
<div className="flex gap-3">
<Select
value={null}
options={users.reduce<{ label: string; value: string }[]>(
(acc, u) => {
if (!selectedUsers.includes(u.id))
acc.push({ label: `${u?.type}-${u?.name}`, value: u.id });
return acc;
},
[]
)}
onChange={onChange}
/>
<Button onClick={update}>Update</Button>
</div>
<div className="flex flex-row justify-between">
<div className="flex flex-col gap-3">
<h2>Blacklisted Users</h2>
<div className="flex gap-3 flex-wrap">
{selectedUsers.map((userId) => {
const user = users.find((u) => u.id === userId);
return (
<div
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
key={userId}
>
<span className="text-base first-letter:uppercase">
{user?.type}-{user?.name}
</span>
<BsTrash
style={{ cursor: "pointer" }}
onClick={() => removeUser(userId)}
size={20}
/>
</div>
);
})}
</div>
</div>
<div className="flex flex-col gap-3">
<h2>Whitelisted Users</h2>
<div className="flex flex-col gap-3 flex-wrap">
{users.map((user) => {
if (!selectedUsers.includes(user.id))
return (
<div
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
key={user.id}
>
<span className="text-base first-letter:uppercase">
{user?.type}-{user?.name}
</span>
</div>
);
return null;
})}
</div>
</div>
</div>
</div>
</>
</>
);
}

View File

@@ -1,73 +1,85 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {Permission} from "@/interfaces/permissions";
import {getPermissionDocs} from "@/utils/permissions.be";
import {User} from "@/interfaces/user";
import Layout from "@/components/High/Layout";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { Permission } from "@/interfaces/permissions";
import { getPermissionDocs } from "@/utils/permissions.be";
import { User } from "@/interfaces/user";
import { LayoutContext } from "@/components/High/Layout";
import PermissionList from "@/components/PermissionList";
import { requestUser } from "@/utils/api";
import { redirect } from "@/utils";
import React from "react";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/")
if (shouldRedirectHome(user)) return redirect("/");
// Fetch data from external API
const permissions: Permission[] = await getPermissionDocs();
const filteredPermissions = permissions.filter((p) => {
const permissionType = p.type.toString().toLowerCase();
// Fetch data from external API
const permissions: Permission[] = await getPermissionDocs();
const filteredPermissions = permissions.filter((p) => {
const permissionType = p.type.toString().toLowerCase();
if (user.type === "corporate") return !permissionType.includes("corporate") && !permissionType.includes("admin");
if (user.type === "mastercorporate") return !permissionType.includes("mastercorporate") && !permissionType.includes("admin");
if (user.type === "corporate")
return (
!permissionType.includes("corporate") &&
!permissionType.includes("admin")
);
if (user.type === "mastercorporate")
return (
!permissionType.includes("mastercorporate") &&
!permissionType.includes("admin")
);
return true;
});
return true;
});
// const res = await fetch("api/permissions");
// const permissions: Permission[] = await res.json();
// Pass data to the page via props
return {
props: {
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
permissions: filteredPermissions.map((p) => {
const {users, ...rest} = p;
return rest;
}),
user,
},
};
// const res = await fetch("api/permissions");
// const permissions: Permission[] = await res.json();
// Pass data to the page via props
return {
props: {
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
permissions: filteredPermissions.map((p) => {
const { users, ...rest } = p;
return rest;
}),
user,
},
};
}, sessionOptions);
interface Props {
permissions: Permission[];
user: User;
permissions: Permission[];
user: User;
}
export default function Page(props: Props) {
const {permissions, user} = props;
const { permissions, user } = props;
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout user={user} className="gap-6">
<h1 className="text-2xl font-semibold">Permissions</h1>
<div className="flex gap-3 flex-wrap overflow-y-scroll scrollbar-hide h-[80vh] rounded-xl">
<PermissionList permissions={permissions} />
</div>
</Layout>
</>
);
const { setClassName } = React.useContext(LayoutContext);
React.useEffect(() => setClassName("gap-6"), [setClassName]);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<>
<h1 className="text-2xl font-semibold">Permissions</h1>
<div className="flex gap-3 flex-wrap overflow-y-scroll scrollbar-hide h-[80vh] rounded-xl">
<PermissionList permissions={permissions} />
</div>
</>
</>
);
}

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