Compare commits

..

74 Commits

Author SHA1 Message Date
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
Joao Correia
a40ae04aa3 Add workflow table name filter 2025-01-27 12:50:18 +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
Joao Correia
73610dc273 implement edit workflow 2025-01-25 19:45:39 +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
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
97 changed files with 11067 additions and 5671 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,32 @@
import Image from "next/image";
import React from "react";
import { FaRegUser } from "react-icons/fa";
interface Props {
prefix: string;
name: string;
profileImage: string;
}
export default function RequestedBy({ prefix, name, profileImage }: Props) {
return (
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
<FaRegUser className="text-mti-purple-dark size-5"/>
</div>
<div>
<p className="text-sm font-medium text-gray-800">Requested by</p>
<div className="flex items-center space-x-2">
<p className="text-xs font-medium text-gray-800">{prefix} {name}</p>
<img
src={profileImage ? profileImage : "/defaultAvatar.png"}
alt={name}
width={24}
height={24}
className="w-6 h-6 rounded-full border-[1px] border-gray-400 border-opacity-50"
/>
</div>
</div>
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -95,7 +95,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
}, [updateLocalAndScheduleGlobal]);
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

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 = () => {
@@ -194,7 +195,7 @@ const LevelSettings: React.FC = () => {
category: s.settings.category
};
}).filter(part => part.exercises.length > 0),
isDiagnostic: false,
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
minTimer,
module: "level",
id: title,
@@ -204,7 +205,12 @@ const LevelSettings: React.FC = () => {
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);

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();
@@ -137,7 +138,7 @@ const ListeningSettings: React.FC = () => {
category: s.settings.category
};
}),
isDiagnostic: false,
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
minTimer,
module: "listening",
id: title,
@@ -149,7 +150,12 @@ const ListeningSettings: React.FC = () => {
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.');

View File

@@ -5,24 +5,36 @@ 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;
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 ReadingComponents: React.FC<Props> = ({
localSettings,
updateLocalAndScheduleGlobal,
currentSection,
levelId,
level = false,
generatePassageDisabled = false,
}) => {
const { currentModule } = useExamEditorStore();
const {
focusedSection,
difficulty,
} = useExamEditorStore(state => state.modules[currentModule]);
const { focusedSection, difficulty } = useExamEditorStore(
(state) => state.modules[currentModule]
);
const generatePassage = useCallback(() => {
generate(
@@ -30,25 +42,32 @@ const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndSchedu
"reading",
"passage",
{
method: 'GET',
method: "GET",
queryParams: {
difficulty,
...(localSettings.readingTopic && { topic: localSettings.readingTopic })
}
...(localSettings.readingTopic && {
topic: localSettings.readingTopic,
}),
},
(data: any) => [{
},
(data: any) => [
{
title: data.title,
text: data.text
}],
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) => {
const onTopicChange = useCallback(
(readingTopic: string) => {
updateLocalAndScheduleGlobal({ readingTopic });
}, [updateLocalAndScheduleGlobal]);
},
[updateLocalAndScheduleGlobal]
);
return (
<>
@@ -56,13 +75,19 @@ const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndSchedu
title="Generate Passage"
module="reading"
open={localSettings.isPassageOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
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-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>
<label className="font-normal text-base text-mti-gray-dim">
Topic (Optional)
</label>
<Input
key={`section-${focusedSection}`}
type="text"
@@ -88,14 +113,26 @@ const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndSchedu
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 === ""}
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 }}
extraArgs={{
text:
currentSection === undefined || currentSection.text === undefined
? ""
: currentSection.text.content,
}}
levelSectionId={focusedSection}
level={level}
/>

View File

@@ -12,6 +12,7 @@ import axios from "axios";
import { playSound } from "@/utils/sound";
import { toast } from "react-toastify";
import ReadingComponents from "./components";
import { getExamById } from "@/utils/exams";
const ReadingSettings: React.FC = () => {
const router = useRouter();
@@ -75,7 +76,7 @@ const ReadingSettings: React.FC = () => {
category: localSettings.category
};
}),
isDiagnostic: false,
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
minTimer,
module: "reading",
id: title,
@@ -88,7 +89,12 @@ const ReadingSettings: React.FC = () => {
axios.post(`/api/exam/reading`, exam)
.then((result) => {
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) => {
console.log(error);

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;
@@ -180,7 +181,7 @@ const SpeakingSettings: React.FC = () => {
minTimer,
module: "speaking",
id: title,
isDiagnostic: false,
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
variant: undefined,
difficulty,
instructorGender: "varied",
@@ -189,7 +190,12 @@ const SpeakingSettings: React.FC = () => {
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);

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();
@@ -129,7 +131,7 @@ const WritingSettings: React.FC = () => {
minTimer,
module: "writing",
id: title,
isDiagnostic: false,
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
variant: undefined,
difficulty,
private: isPrivate,
@@ -138,7 +140,12 @@ const WritingSettings: React.FC = () => {
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

@@ -20,21 +20,18 @@ 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 ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: EntityWithRoles[]}> = ({
levelParts = 0,
entitiesAllowEditPrivacy = [],
}) => {
const {currentModule, dispatch} = useExamEditorStore();
const {
sections,
minTimer,
expandedSections,
examLabel,
isPrivate,
difficulty,
sectionLabels,
importModule
} = useExamEditorStore(state => state.modules[currentModule]);
const {sections, minTimer, expandedSections, examLabel, isPrivate, difficulty, sectionLabels, importModule} = useExamEditorStore(
(state) => state.modules[currentModule],
);
const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1);
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
@@ -44,34 +41,34 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
if (levelParts !== 0) {
setNumberOfLevelParts(levelParts);
dispatch({
type: 'UPDATE_MODULE',
type: "UPDATE_MODULE",
payload: {
updates: {
sectionLabels: Array.from({length: levelParts}).map((_, i) => ({
id: i + 1,
label: `Part ${i + 1}`
}))
label: `Part ${i + 1}`,
})),
},
module: "level"
}
})
module: "level",
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelParts])
}, [levelParts]);
useEffect(() => {
const currentSections = sections;
const currentLabels = sectionLabels;
let updatedSections: SectionState[];
let updatedLabels: any;
if (currentModule === "level" && currentSections.length !== currentLabels.length || numberOfLevelParts !== currentSections.length) {
if ((currentModule === "level" && currentSections.length !== currentLabels.length) || numberOfLevelParts !== currentSections.length) {
const newSections = [...currentSections];
const 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}`
label: `Part ${i + 1}`,
});
}
updatedSections = newSections;
@@ -83,35 +80,36 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
return;
}
const updatedExpandedSections = expandedSections.filter(
sectionId => updatedSections.some(section => section.sectionId === sectionId)
);
const updatedExpandedSections = expandedSections.filter((sectionId) => updatedSections.some((section) => section.sectionId === sectionId));
dispatch({
type: 'UPDATE_MODULE',
type: "UPDATE_MODULE",
payload: {
updates: {
sections: updatedSections,
sectionLabels: updatedLabels,
expandedSections: updatedExpandedSections
}
}
expandedSections: updatedExpandedSections,
},
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [numberOfLevelParts]);
const sectionIds = sections.map((section) => section.sectionId)
const sectionIds = sections.map((section) => section.sectionId);
const updateModule = useCallback((updates: Partial<ModuleState>) => {
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
}, [dispatch]);
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 } });
dispatch({type: "TOGGLE_SECTION", payload: {sectionId}});
};
const ModuleSettings: Record<Module, React.ComponentType> = {
@@ -119,7 +117,7 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
writing: WritingSettings,
speaking: SpeakingSettings,
listening: ListeningSettings,
level: LevelSettings
level: LevelSettings,
};
const Settings = ModuleSettings[currentModule];
@@ -127,20 +125,34 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
const updateLevelParts = (parts: number) => {
setNumberOfLevelParts(parts);
}
};
return (
<>
{showImport ? <ImportOrStartFromScratch module={currentModule} setNumberOfLevelParts={updateLevelParts} /> : (
{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">
{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) })}
onChange={(e) =>
updateModule({
minTimer: parseInt(e) < 15 ? 15 : parseInt(e),
})
}
value={minTimer}
className="max-w-[300px]"
/>
@@ -151,24 +163,25 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
isMulti={true}
options={DIFFICULTIES.map((x) => ({
value: x,
label: capitalize(x)
label: capitalize(x),
}))}
onChange={(values) => {
const selectedDifficulties = values ? values.map(v => v.value as Difficulty) : [];
const selectedDifficulties = values ? values.map((v) => v.value as Difficulty) : [];
updateModule({difficulty: selectedDifficulties});
}}
value={
difficulty
? difficulty.map(d => ({
? difficulty.map((d) => ({
value: d,
label: capitalize(d)
label: capitalize(d),
}))
: null
}
/>
</div>
{(sectionLabels.length != 0 && currentModule !== "level") ? (
<div className="flex flex-col gap-3">
</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}) => (
@@ -179,25 +192,32 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
"transition duration-300 ease-in-out",
sectionIds.includes(id)
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
: "bg-white border-mti-gray-platinum"
: "bg-white border-mti-gray-platinum",
)}
onClick={() => toggleSection(id)}
>
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} />
<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 })}>
<Checkbox
isChecked={isPrivate}
onChange={(checked) => updateModule({isPrivate: checked})}
disabled={entitiesAllowEditPrivacy.length === 0}>
Privacy (Only available for Assignments)
</Checkbox>
</div>
@@ -219,14 +239,13 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
<Button
onClick={() => setIsResetModuleOpen(true)}
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
className={`text-white self-end`}
>
className={`text-white self-end`}>
Reset Module
</Button>
</div>
<div className="flex flex-row gap-8">
<div className="flex flex-row gap-8 -2xl:flex-col">
<Settings />
<div className="flex-grow max-w-[66%]">
<div className="flex-grow max-w-[66%] -2xl:max-w-full">
<SectionRenderer />
</div>
</div>

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,27 +14,38 @@ 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()
const hasBeenSubmitted = useMemo(() => assignment.results.map((r) => r.user).includes(user.id), [assignment.results, user.id])
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]
);
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",
assignment.results.map((r) => r.user).includes(user.id) &&
"border-mti-green-light"
)}
key={assignment.id}>
key={assignment.id}
>
<div className="flex flex-col gap-1">
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
<h3 className="text-mti-black/90 text-xl font-semibold">
{assignment.name}
</h3>
<span className="flex justify-between gap-1 text-lg">
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span>
@@ -45,7 +59,11 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
.map((e) => e.module)
.sort(sortByModuleName)
.map((module) => (
<ModuleBadge className="scale-110 w-full" key={module} module={module} />
<ModuleBadge
className="scale-110 w-full"
key={module}
module={module}
/>
))}
</div>
{futureAssignmentFilter(assignment) && !hasBeenSubmitted && (
@@ -53,7 +71,8 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
color="rose"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
disabled
variant="outline">
variant="outline"
>
Not yet started
</Button>
)}
@@ -61,7 +80,8 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
<>
<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">
data-tip="Your screen size is too small to perform an assignment"
>
<Button className="h-full w-full !rounded-xl" variant="outline">
Start
</Button>
@@ -71,12 +91,14 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
data-tip="You have already started this assignment!"
className={clsx(
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
!!session && "tooltip",
)}>
!!session && "tooltip"
)}
>
<Button
className={clsx("w-full h-full !rounded-xl")}
onClick={() => startAssignment(assignment)}
variant="outline">
variant="outline"
>
Start
</Button>
</div>
@@ -85,12 +107,14 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
<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">
variant="outline"
>
Resume
</Button>
</div>
@@ -102,11 +126,12 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
color="green"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
disabled
variant="outline">
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,27 +1,49 @@
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>) {
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,
@@ -31,8 +53,8 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
state: {
pagination
}
pagination,
},
});
return (
@@ -40,16 +62,24 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
<div className="w-full flex gap-2 items-end">
{renderSearch()}
{onDownload && (
<Button isLoading={isDownloadLoading} className="w-full max-w-[200px] mb-1" variant="outline" onClick={() => onDownload(rows)}>
<Button
isLoading={isDownloadLoading}
className="w-full max-w-[200px] mb-1"
variant="outline"
onClick={() => onDownload(rows)}
>
Download
</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()}>
<Button
className="w-[200px] h-fit"
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
>
Previous Page
</Button>
</div>
@@ -57,12 +87,16 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
<span className="flex items-center gap-1">
<div>Page</div>
<strong>
{table.getState().pagination.pageIndex + 1} of{' '}
{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()}>
<Button
className="w-[200px]"
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
>
Next Page
</Button>
</div>
@@ -73,9 +107,17 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
{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}>
<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')}
className={clsx(
header.column.getCanSort() &&
"cursor-pointer select-none",
"flex items-center gap-2"
)}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
@@ -94,7 +136,10 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
</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}>
<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())}
@@ -104,6 +149,11 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
))}
</tbody>
</table>
{isLoading && (
<div className="min-h-screen flex justify-center items-start">
<span className="loading loading-infinity w-32" />
</div>
)
)}
</div>
);
}

View File

@@ -13,6 +13,7 @@ import {
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";
@@ -149,6 +150,7 @@ export default function Sidebar({
viewSettings: true,
viewPaymentRecord: true,
viewGeneration: true,
viewApprovalWorkflows: true,
};
}
const sidebarPermissions: { [key: string]: boolean } = {
@@ -160,6 +162,7 @@ export default function Sidebar({
viewSettings: false,
viewPaymentRecord: false,
viewGeneration: false,
viewApprovalWorkflows: false,
};
if (!user || !user?.type) return sidebarPermissions;
@@ -197,6 +200,7 @@ export default function Sidebar({
(entitiesAllowGeneration.length > 0 || isAdmin)
) {
sidebarPermissions["viewGeneration"] = true;
sidebarPermissions["viewApprovalWorkflows"] = true;
}
if (
getTypesOfUser(["agent"]).includes(user.type) &&
@@ -260,7 +264,7 @@ export default function Sidebar({
<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",
isMinimized ? "w-fit" : "-xl:w-20 w-1/6",
className
)}
>
@@ -364,6 +368,17 @@ export default function Sidebar({
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
@@ -432,6 +447,16 @@ export default function Sidebar({
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">

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

@@ -89,7 +89,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
),
label: "Reading",
value: reading,
value: reading || 0,
tooltip: "The amount of reading exams performed.",
},
{
@@ -97,7 +97,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
),
label: "Listening",
value: listening,
value: listening || 0,
tooltip: "The amount of listening exams performed.",
},
{
@@ -105,7 +105,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
),
label: "Writing",
value: writing,
value: writing || 0,
tooltip: "The amount of writing exams performed.",
},
{
@@ -113,7 +113,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
),
label: "Speaking",
value: speaking,
value: speaking || 0,
tooltip: "The amount of speaking exams performed.",
},
{
@@ -121,7 +121,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
),
label: "Level",
value: 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() {
const [workflows, setWorkflows] = useState<ApprovalWorkflow[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = useCallback(() => {
setIsLoading(true);
axios
.get<ApprovalWorkflow[]>(`/api/approval-workflows`)
.then((response) => setWorkflows(response.data))
.catch((error) => {
setIsError(true);
})
.finally(() => setIsLoading(false));
}, []);
useEffect(getData, [getData]);
return { workflows, isLoading, isError, reload: getData };
}

View File

@@ -1,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

@@ -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,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

@@ -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,34 @@
import { Module } from "@/interfaces";
import { getApprovalWorkflowByFormIntaker, createApprovalWorkflow } from "@/utils/approval.workflows.be";
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,
};
}

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,13 +5,23 @@ 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";
@@ -27,30 +37,149 @@ const areStepsOverlapped = (steps: Step[]) => {
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
entities: Entity[];
mutate: () => void;
}
export default function CorporateGradingSystem({ user, entitiesGrading = [], entities = [], mutate }: Props) {
const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined)
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[]>([])
const [otherEntities, setOtherEntities] = useState<string[]>([]);
useEffect(() => {
if (entity) {
const entitySteps = entitiesGrading.find(e => e.entity === entity)!.steps
setSteps(entitySteps || [])
const entitySteps = entitiesGrading.find(
(e) => e.entity === entity
)!.steps;
setSteps(entitySteps || []);
}
}, [entitiesGrading, entity])
}, [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.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);
@@ -68,8 +197,12 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent
};
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.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);
@@ -77,24 +210,51 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent
)
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 })
.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));
};
const addRow = useCallback((index: number) => {
setSteps((prev) => {
const item = {
min: prev[index === 0 ? 0 : index - 1].max + 1,
max: prev[index + 1].min - 1,
label: "",
};
return [
...prev.slice(0, index + 1),
item,
...prev.slice(index + 1, prev.length),
];
});
}, []);
return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<label className="font-normal text-base text-mti-gray-dim">Grading System</label>
<label className="font-normal text-base text-mti-gray-dim">
Grading System
</label>
<div className={clsx("flex flex-col gap-4")}>
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
<label className="font-normal text-base text-mti-gray-dim">
Entity
</label>
<Select
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
defaultValue={{
value: (entities || [])[0]?.id,
label: (entities || [])[0]?.label,
}}
options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(e) => setEntity(e?.value || undefined)}
isClearable={checkAccess(user, ["admin", "developer"])}
@@ -104,20 +264,33 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent
{entities.length > 1 && (
<>
<Separator />
<label className="font-normal text-base text-mti-gray-dim">Apply this grading system to other entities</label>
<label className="font-normal text-base text-mti-gray-dim">
Apply this grading system to other entities
</label>
<Select
options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(e) => !e ? setOtherEntities([]) : setOtherEntities(e.map(o => o.value!))}
onChange={(e) =>
!e
? setOtherEntities([])
: setOtherEntities(e.map((o) => o.value!))
}
isMulti
/>
<Button onClick={applyToOtherEntities} isLoading={isLoading} disabled={isLoading || otherEntities.length === 0} variant="outline">
<Button
onClick={applyToOtherEntities}
isLoading={isLoading}
disabled={isLoading || otherEntities.length === 0}
variant="outline"
>
Apply to {otherEntities.length} other entities
</Button>
<Separator />
</>
)}
<label className="font-normal text-base text-mti-gray-dim">Preset Systems</label>
<label className="font-normal text-base text-mti-gray-dim">
Preset Systems
</label>
<div className="grid grid-cols-4 gap-4">
<Button variant="outline" onClick={() => setSteps(CEFR_STEPS)}>
CEFR
@@ -134,61 +307,25 @@ export default function CorporateGradingSystem({ user, entitiesGrading = [], ent
</div>
{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"
<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}
/>
<Input
label="Grade"
value={step.label}
type="text"
disabled={isLoading}
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, label: e } : x)))}
name="min"
/>
<Input
label="Max. Percentage"
value={step.max}
type="number"
disabled={index === steps.length - 1 || isLoading}
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, max: parseInt(e) } : x)))}
name="max"
/>
</div>
{index !== 0 && index !== steps.length - 1 && (
<button
disabled={isLoading}
className="pt-9 text-xl group"
onClick={() => setSteps((prev) => prev.filter((_, i) => i !== index))}>
<div className="w-full h-full flex items-center justify-center group-hover:bg-neutral-200 rounded-full p-3 transition ease-in-out duration-300">
<BsTrash />
</div>
</button>
)}
</div>
{index < steps.length - 1 && (
<Button
className="w-full flex items-center justify-center"
disabled={isLoading}
onClick={() => {
const item = { min: steps[index === 0 ? 0 : index - 1].max + 1, max: steps[index + 1].min - 1, label: "" };
setSteps((prev) => [...prev.slice(0, index + 1), item, ...prev.slice(index + 1, steps.length)]);
}}>
<BsPlusCircle />
</Button>
)}
</>
))}
<Button onClick={saveGradingSystem} isLoading={isLoading} disabled={isLoading} className="mt-8">
<Button
onClick={saveGradingSystem}
isLoading={isLoading}
disabled={isLoading}
className="mt-8"
>
Save Grading System
</Button>
</div>

View File

@@ -4,16 +4,15 @@ 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 axios from "axios";
import clsx from "clsx";
import {capitalize, uniq} from "lodash";
import {useRouter} from "next/router";
import { BsBan, BsBanFill, BsCheck, BsCircle, BsPencil, BsStop, BsTrash, BsUpload, BsX } from "react-icons/bs";
import {BsBan, BsCheck, BsCircle, BsPencil, BsTrash, BsUpload, BsX} from "react-icons/bs";
import {toast} from "react-toastify";
import {useListSearch} from "@/hooks/useListSearch";
import Modal from "@/components/Modal";
@@ -21,9 +20,9 @@ import { checkAccess } from "@/utils/permissions";
import useGroups from "@/hooks/useGroups";
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";
import {getUserName} from "@/utils/users";
const searchFields = [["module"], ["id"], ["createdBy"]];
@@ -37,45 +36,20 @@ const CLASSES: { [key in Module]: string } = {
const columnHelper = createColumnHelper<Exam>();
const ExamOwnerSelector = ({ options, exam, onSave }: { options: User[]; exam: Exam; onSave: (owners: string[]) => void }) => {
const [owners, setOwners] = useState(exam.owners || []);
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>
);
};
export default function ExamList({ user, entities }: { user: User; entities: EntityWithRoles[]; }) {
export default function ExamList({user, entities}: {user: User; entities: EntityWithRoles[]}) {
const [selectedExam, setSelectedExam] = useState<Exam>();
const {exams, reload} = useExams();
const {users} = useUsers();
const { groups } = useGroups({ admin: user?.id, userType: user?.type });
const filteredExams = useMemo(() => exams.filter((e) => {
if (!e.private) return true
return (e.owners || []).includes(user?.id || "")
}), [exams, user?.id])
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]);
const filteredExams = useMemo(
() =>
exams.filter((e) => {
if (!e.private) return true;
return (e.entities || []).some((ent) => mapBy(user.entities, "id").includes(ent));
}),
[exams, user?.entities],
);
const parsedExams = useMemo(() => {
return filteredExams.map((exam) => {
@@ -108,7 +82,7 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
return;
}
dispatch({ type: "INIT_EXAM", payload: { exams: [exam], modules: [module] } })
dispatch({type: "INIT_EXAM", payload: {exams: [exam], modules: [module]}});
router.push("/exam");
};
@@ -135,29 +109,6 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
.finally(reload);
};
const updateExam = async (exam: Exam, body: object) => {
if (!confirm(`Are you sure you want to update this ${capitalize(exam.module)} exam?`)) return;
axios
.patch(`/api/exam/${exam.module}/${exam.id}`, body)
.then(() => toast.success(`Updated the "${exam.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to update this exam!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload)
.finally(() => setSelectedExam(undefined));
};
const deleteExam = async (exam: Exam) => {
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
@@ -222,7 +173,7 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
}),
columnHelper.accessor("createdBy", {
header: "Created By",
cell: (info) => info.getValue(),
cell: (info) => (!info.getValue() ? "System" : findBy(users, "id", info.getValue())?.name || "N/A"),
}),
{
header: "",
@@ -270,7 +221,7 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
const handleExamEdit = () => {
router.push(`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`);
}
};
return (
<div className="flex flex-col gap-4 w-full h-full">
@@ -286,30 +237,17 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
</div>
<div className="bg-gray-50 rounded-lg p-4 mb-3">
<p className="font-medium mb-1">
Exam ID: {selectedExam.id}
</p>
<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>
<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"
>
<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"
>
<Button color="purple" onClick={handleExamEdit} className="w-32 text-white flex items-center justify-center gap-2">
Proceed
</Button>
</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";
@@ -24,7 +29,6 @@ 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", ""]];
@@ -40,31 +44,87 @@ export default function UserList({
type?: Type;
renderHeader?: (total: number) => JSX.Element;
}) {
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
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();
@@ -73,38 +133,70 @@ export default function UserList({
const momentDate = moment(date);
const today = moment(new Date());
if (today.isAfter(momentDate)) return "!text-mti-red-light font-bold line-through";
if (today.isAfter(momentDate))
return "!text-mti-red-light font-bold line-through";
if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light";
if (today.add(2, "weeks").isAfter(momentDate)) return "!text-mti-rose-light";
if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-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
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])
}),
[
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;
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()
reload();
})
.catch(() => {
toast.error("Something went wrong!", { toastId: "delete-error" });
@@ -130,8 +222,11 @@ export default function UserList({
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.`,
`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;
@@ -142,7 +237,11 @@ export default function UserList({
status: user.status === "disabled" ? "active" : "disabled",
})
.then(() => {
toast.success(`User ${user.status === "disabled" ? "enabled" : "disabled"} successfully!`);
toast.success(
`User ${
user.status === "disabled" ? "enabled" : "disabled"
} successfully!`
);
reload();
})
.catch(() => {
@@ -151,45 +250,60 @@ export default function UserList({
};
const getEditPermission = (type: Type) => {
if (type === "student") return entitiesEditStudents
if (type === "teacher") return entitiesEditTeachers
if (type === "corporate") return entitiesEditCorporates
if (type === "mastercorporate") return entitiesEditMasterCorporates
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
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))
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))
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 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)}>
<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"}
data-tip={
row.original.status === "disabled"
? "Enable User"
: "Disable User"
}
className="cursor-pointer tooltip"
onClick={() => toggleDisableAccount(row.original)}>
onClick={() => toggleDisableAccount(row.original)}
>
{row.original.status === "disabled" ? (
<BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" />
) : (
@@ -198,7 +312,11 @@ export default function UserList({
</div>
)}
{canDelete && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteAccount(row.original)}>
<div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteAccount(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
@@ -213,11 +331,12 @@ export default function UserList({
<div
className={clsx(
canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
)}
onClick={() =>
canEditUser(row.original) ? setSelectedUser(row.original) : null
}>
}
>
{getValue()}
</div>
),
@@ -226,8 +345,14 @@ export default function UserList({
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})`
? `${
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", {
@@ -237,17 +362,25 @@ export default function UserList({
}),
columnHelper.accessor(
(x) =>
x.type === "corporate" || x.type === "mastercorporate" ? x.demographicInformation?.position : x.demographicInformation?.employment,
x.type === "corporate" || x.type === "mastercorporate"
? x.demographicInformation?.position
: x.demographicInformation?.employment,
{
id: "employment",
header: "Employment",
cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "N/A",
cell: (info) =>
(info.row.original.type === "corporate"
? info.getValue()
: capitalize(info.getValue())) || "N/A",
enableSorting: true,
},
}
),
columnHelper.accessor("lastLogin", {
header: "Last Login",
cell: (info) => (!!info.getValue() ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") : "N/A"),
cell: (info) =>
!!info.getValue()
? moment(info.getValue()).format("YYYY-MM-DD HH:mm")
: "N/A",
}),
columnHelper.accessor("demographicInformation.gender", {
header: "Gender",
@@ -256,13 +389,16 @@ export default function UserList({
}),
{
header: (
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
<span
className="cursor-pointer"
onClick={() => setShowDemographicInformation((prev) => !prev)}
>
Switch
</span>
),
id: "actions",
cell: actionColumn,
sortable: false
sortable: false,
},
];
@@ -273,11 +409,12 @@ export default function UserList({
<div
className={clsx(
canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
)}
onClick={() =>
canEditUser(row.original) ? setSelectedUser(row.original) : null
}>
}
>
{getValue()}
</div>
),
@@ -288,9 +425,12 @@ export default function UserList({
<div
className={clsx(
canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
)}
onClick={() => (canEditUser(row.original) ? setSelectedUser(row.original) : null)}>
onClick={() =>
canEditUser(row.original) ? setSelectedUser(row.original) : null
}
>
{getValue()}
</div>
),
@@ -305,13 +445,21 @@ export default function UserList({
}),
columnHelper.accessor("entities", {
header: "Entities",
cell: ({ getValue }) => mapBy(getValue(), 'label').join(', '),
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
className={clsx(
info.getValue()
? expirationDateColor(moment(info.getValue()).toDate())
: ""
)}
>
{!info.getValue()
? "No expiry date"
: moment(info.getValue()).format("DD/MM/YYYY")}
</span>
),
}),
@@ -323,8 +471,9 @@ export default function UserList({
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 ",
)}>
info.getValue() && "!bg-mti-purple-light "
)}
>
<BsCheck color="white" className="w-full h-full" />
</div>
</div>
@@ -332,20 +481,28 @@ export default function UserList({
}),
{
header: (
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
<span
className="cursor-pointer"
onClick={() => setShowDemographicInformation((prev) => !prev)}
>
Switch
</span>
),
id: "actions",
cell: actionColumn,
sortable: false
sortable: false,
},
];
const downloadExcel = (rows: WithLabeledEntities<User>[]) => {
if (entitiesDownloadUsers.length === 0) return toast.error("You are not allowed to download the user list.")
if (entitiesDownloadUsers.length === 0)
return toast.error("You are not allowed to download the user list.");
const allowedRows = rows.filter(r => mapBy(r.entities, 'id').some(e => mapBy(entitiesDownloadUsers, 'id').includes(e)))
const allowedRows = rows.filter((r) =>
mapBy(r.entities, "id").some((e) =>
mapBy(entitiesDownloadUsers, "id").includes(e)
)
);
const csv = exportListToExcel(allowedRows);
const element = document.createElement("a");
@@ -359,10 +516,15 @@ export default function UserList({
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 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);
@@ -373,7 +535,9 @@ export default function UserList({
maxUserAmount={0}
loggedInUser={user}
onViewStudents={
(selectedUser.type === "corporate" || selectedUser.type === "teacher") && studentsFromAdmin.length > 0
(selectedUser.type === "corporate" ||
selectedUser.type === "teacher") &&
studentsFromAdmin.length > 0
? () => {
appendUserFilters({
id: "view-students",
@@ -389,7 +553,9 @@ export default function UserList({
: undefined
}
onViewTeachers={
(selectedUser.type === "corporate" || selectedUser.type === "student") && teachersFromAdmin.length > 0
(selectedUser.type === "corporate" ||
selectedUser.type === "student") &&
teachersFromAdmin.length > 0
? () => {
appendUserFilters({
id: "view-teachers",
@@ -413,7 +579,7 @@ export default function UserList({
});
appendUserFilters({
id: "belongs-to-admin",
filter: belongsToAdminFilter
filter: belongsToAdminFilter,
});
router.push("/users");
@@ -434,14 +600,24 @@ export default function UserList({
<>
{renderHeader && renderHeader(displayUsers.length)}
<div className="w-full">
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
<Modal
isOpen={!!selectedUser}
onClose={() => setSelectedUser(undefined)}
>
{selectedUser && renderUserCard(selectedUser)}
</Modal>
<Table<WithLabeledEntities<User>>
data={displayUsers}
columns={(!showDemographicInformation ? defaultColumns : demographicColumns) as any}
columns={
(!showDemographicInformation
? defaultColumns
: demographicColumns) as any
}
searchFields={searchFields}
onDownload={entitiesDownloadUsers.length > 0 ? downloadExcel : undefined}
onDownload={
entitiesDownloadUsers.length > 0 ? downloadExcel : undefined
}
isLoading={isLoading}
/>
</div>
</>

View File

@@ -279,7 +279,7 @@ export default function ExamPage({
};
userSolutions.forEach((x) => {
if (x.isPractice === isPractice) {
if (isPractice ? x.isPractice : !x.isPractice) {
const examModule =
x.module ||
(x.type === "writing"

View File

@@ -12,8 +12,6 @@ import useExamStore from "@/stores/exam";
import usePreferencesStore from "@/stores/preferencesStore";
import Layout from "../components/High/Layout";
import useEntities from "../hooks/useEntities";
import UserProfileSkeleton from "../components/Medium/UserProfileSkeleton";
export default function App({ Component, pageProps }: AppProps) {
const [loading, setLoading] = useState(false);
@@ -61,7 +59,14 @@ export default function App({ Component, pageProps }: AppProps) {
return pageProps?.user ? (
<Layout user={pageProps.user} entities={entities} refreshPage={loading}>
{loading ? <UserProfileSkeleton /> : <Component {...pageProps} />}
{loading ? (
// TODO: Change this later to a better loading screen (example: skeletons for each page)
<div className="min-h-screen flex justify-center items-start">
<span className="loading loading-infinity w-32" />
</div>
) : (
<Component entities={entities} {...pageProps} />
)}
</Layout>
) : (
<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,23 @@
// 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 });
}
return res.status(200).json(await getApprovalWorkflows("active-workflows"));
}

View File

@@ -1,15 +1,17 @@
// 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 { createApprovalWorkflowsOnExamCreation } 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 { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next";
const db = client.db(process.env.MONGODB_DB);
@@ -40,13 +42,13 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
}
async function POST(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res)
const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false });
const { module } = req.query as { module: string };
const session = client.startSession();
const entities = isAdmin(user) ? [] : mapBy(user.entities, 'id')
const entities = isAdmin(user) ? [] : mapBy(user.entities, "id"); // might need to change this with new approval workflows logic.. if an admin creates an exam no workflow is started because workflows must have entities configured.
try {
const exam = {
@@ -57,14 +59,20 @@ 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");
}
@@ -73,13 +81,59 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
{ $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}"`;
// TODO maybe find a way to start missing approval workflows in case they were only configured after exam creation.
// create workflow only if exam is being created for the first time
if (docSnap === null) {
try {
const { successCount, totalCount } = await createApprovalWorkflowsOnExamCreation(exam.createdBy, exam.entities, exam.id, module);
if (successCount === totalCount) {
responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow(s)`;
} 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 was not able to find any configured Approval Workflow for the author.`;
}
} catch (error) {
console.error("Workflow creation error:", error);
responseStatus = 207;
responseMessage = `Successfully created exam with ID: "${exam.id}" but something went wrong while creating the Approval Workflow(s).`;
}
} else { // if exam was updated, log the updates
const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id);
if (approvalWorkflows) {
const differences = generateExamDifferences(docSnap as Exam, exam as Exam);
if (differences) {
approvalWorkflows.forEach((workflow) => {
const currentStepIndex = workflow.steps.findIndex(step => !step.completed || step.rejected);
if (workflow.steps[currentStepIndex].examChanges === undefined) {
workflow.steps[currentStepIndex].examChanges = [...differences];
} else {
workflow.steps[currentStepIndex].examChanges!.push(...differences);
}
});
await updateApprovalWorkflows("active-workflows", approvalWorkflows);
}
}
}
res.status(200).json(exam);
res.status(responseStatus).json({
message: responseMessage,
});
});
} catch (error) {
console.error("Transaction failed: ", error);
res.status(500).json({ ok: false, error: (error as any).message });

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,608 @@
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 => step.assignees)
.flat()
)
];
return {
props: serialize({
user,
initialWorkflow: workflow,
id,
workflowAssignees: await getSpecificUsers(allAssigneeIds),
workflowRequester: await getUser(workflow.requester),
}),
};
}, sessionOptions);
interface Props {
user: User,
initialWorkflow: ApprovalWorkflow,
id: string,
workflowAssignees: User[],
workflowRequester: User,
}
export default function Home({ user, initialWorkflow, id, workflowAssignees, workflowRequester }: Props) {
const { workflow, reload, isLoading } = useApprovalWorkflow(id);
const currentWorkflow = workflow || initialWorkflow;
let currentStepIndex = currentWorkflow.steps.findIndex(step => !step.completed || step.rejected);
if (currentStepIndex === -1)
currentStepIndex = currentWorkflow.steps.length - 1;
const [selectedStepIndex, setSelectedStepIndex] = useState<number>(currentStepIndex);
const [selectedStep, setSelectedStep] = useState<WorkflowStep>(currentWorkflow.steps[selectedStepIndex]);
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [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 approve the exam.`)) return;
}
const updatedWorkflow: ApprovalWorkflow = {
...currentWorkflow,
status: selectedStepIndex === currentWorkflow.steps.length - 1 ? "approved" : "pending",
steps: currentWorkflow.steps.map((step, index) =>
index === selectedStepIndex ?
{
...step,
completed: true,
completedBy: user.id,
completedDate: Date.now(),
}
: step
)
};
axios
.put(`/api/approval-workflows/${id}`, updatedWorkflow)
.then(() => {
toast.success("Step approved successfully.");
reload();
})
.catch((reason) => {
if (reason.response.status === 401) {
toast.error("Not logged in!");
} else if (reason.response.status === 403) {
toast.error("You do not have permission to approve this step!");
} else {
toast.error("Something went wrong, please try again later.");
}
console.log("Submitted Values:", updatedWorkflow);
return;
})
if (isLastStep) {
setIsPanelOpen(false);
const examModule = currentWorkflow.modules[0];
const examId = currentWorkflow.examId;
axios
.patch(`/api/exam/${examModule}/${examId}`, { isDiagnostic: false })
.then(() => toast.success(`The exam was successfuly approved and this workflow has been completed.`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to update this exam!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
} else {
handleStepClick(selectedStepIndex + 1, currentWorkflow.steps[selectedStepIndex + 1]);
}
};
const handleRejectStep = () => {
if (!confirm(`Are you sure you want to reject this step? Doing so will terminate this approval workflow.`)) return;
const updatedWorkflow: ApprovalWorkflow = {
...currentWorkflow,
status: "rejected",
steps: currentWorkflow.steps.map((step, index) =>
index === selectedStepIndex ?
{
...step,
completed: true,
completedBy: user.id,
completedDate: Date.now(),
rejected: true,
}
: step
)
};
axios
.put(`/api/approval-workflows/${id}`, updatedWorkflow)
.then(() => {
toast.success("Step rejected successfully.");
reload();
})
.catch((reason) => {
if (reason.response.status === 401) {
toast.error("Not logged in!");
} else if (reason.response.status === 403) {
toast.error("You do not have permission to approve this step!");
} else {
toast.error("Something went wrong, please try again later.");
}
console.log("Submitted Values:", updatedWorkflow);
return;
})
};
const dispatch = useExamStore((store) => store.dispatch);
const handleViewExam = async () => {
setViewExamIsLoading(true);
const examModule = currentWorkflow.modules[0];
const examId = currentWorkflow.examId;
if (examModule && examId) {
const exam = await getExamById(examModule, examId.trim());
if (!exam) {
toast.error(
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
{ toastId: "invalid-exam-id" }
);
setViewExamIsLoading(false);
return;
}
dispatch({
type: "INIT_EXAM",
payload: { exams: [exam], modules: [examModule] },
});
router.push("/exam");
}
}
const handleEditExam = () => {
setEditExamIsLoading(true);
const examModule = currentWorkflow.modules[0];
const examId = currentWorkflow.examId;
router.push(`/generation?id=${examId}&module=${examModule}`);
}
return (
<>
<Head>
<title> Workflow | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<section className="flex items-center gap-2">
<Link
href="/approval-workflows"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h1 className="text-2xl font-semibold">{currentWorkflow.name}</h1>
</section>
<section className="flex flex-col gap-6">
<div className="flex flex-row gap-6">
<RequestedBy
prefix={getUserTypeLabelShort(workflowRequester.type)}
name={workflowRequester.name}
profileImage={workflowRequester.profilePicture}
/>
<StartedOn
date={currentWorkflow.startDate}
/>
<Status
status={currentWorkflow.status}
/>
</div>
<div className="flex flex-row gap-3">
<Button
color="purple"
variant="solid"
onClick={handleViewExam}
disabled={viewExamIsLoading}
padding="px-6 py-2"
className="w-[240px] text-lg flex items-center justify-center gap-2 text-left"
>
{viewExamIsLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<IoDocumentTextOutline />
Load Exam
</>
)}
</Button>
<Button
color="purple"
variant="solid"
onClick={handleEditExam}
padding="px-6 py-2"
disabled={(!currentWorkflow.steps[currentStepIndex].assignees.includes(user.id) && user.type !== "admin" && user.type !== "developer") || editExamIsLoading}
className="w-[240px] text-lg flex items-center justify-center gap-2 text-left"
>
{editExamIsLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<TiEdit size={20} />
Edit Exam
</>
)}
</Button>
</div>
{currentWorkflow.steps.find((step) => !step.completed) === undefined &&
<Tip text="All steps in this instance have been completed." />
}
</section>
<section className="flex flex-col gap-0">
{currentWorkflow.steps.map((step, index) => (
<WorkflowStepComponent
workflowAssignees={workflowAssignees}
key={index}
completed={step.completed}
completedBy={step.completedBy}
rejected={step.rejected}
stepNumber={step.stepNumber}
stepType={step.stepType}
assignees={step.assignees}
finalStep={index === currentWorkflow.steps.length - 1}
currentStep={index === currentStepIndex}
selected={index === selectedStepIndex}
onClick={() => handleStepClick(index, step)}
/>
))}
</section>
{/* Side panel */}
<AnimatePresence mode="wait">
<LayoutGroup key="sidePanel">
<section className={`absolute inset-y-0 right-0 h-full bg-mti-purple-ultralight bg-opacity-50 shadow-xl shadow-mti-purple transition-all duration-300 overflow-hidden ${isPanelOpen ? 'w-[500px]' : 'w-0'}`}>
{isPanelOpen && selectedStep && (
<motion.div
className="p-6"
key={selectedStep.stepNumber}
initial={{ opacity: 0, x: 30 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 30 }}
transition={{ duration: 0.2 }}
>
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
<div className="flex flex-row gap-2">
<p className="text-2xl font-medium text-left align-middle">Step {selectedStepIndex + 1}</p>
<div className="ml-auto flex flex-row">
<button
className="min-w-fit max-h-fit text-lg font-medium flex items-center gap-2 text-left"
onClick={() => setIsPanelOpen(false)}
>
Collapse
<MdOutlineDoubleArrow size={20} />
</button>
</div>
</div>
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
<div>
<div className="my-8 flex flex-row gap-4 items-center text-lg font-medium">
{selectedStep.stepType === "approval-by" ? (
<>
<RiThumbUpLine size={30} />
Approval Step
</>
) : (
<>
<FaWpforms size={30} />
Form Intake Step
</>
)
}
</div>
{selectedStep.completed ? (
<div className={"text-base font-medium text-gray-500 flex flex-col gap-6"}>
{selectedStep.rejected ? "Rejected" : "Approved"} on {new Date(selectedStep.completedDate!).toLocaleString("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).replace(", ", " at ")}
<div className="flex flex-row gap-1 text-sm">
<p className="text-base">{selectedStep.rejected ? "Rejected" : "Approved"} by:</p>
{(() => {
const assignee = workflowAssignees.find(
(assignee) => assignee.id === selectedStep.completedBy
);
return assignee ? (
<UserWithProfilePic
textSize="text-base"
prefix={getUserTypeLabelShort(assignee.type)}
name={assignee.name}
profileImage={assignee.profilePicture}
/>
) : (
"Unknown"
);
})()}
</div>
<p className="text-sm">No additional actions are required.</p>
</div>
) : (
<div className={"text-base font-medium text-gray-500 mb-6"}>
One assignee is required to sign off to complete this step:
<div className="flex flex-col gap-2 mt-3">
{workflowAssignees.filter(user => selectedStep.assignees.includes(user.id)).map(user => (
<span key={user.id}>
<UserWithProfilePic
textSize="text-sm"
prefix={`- ${getUserTypeLabelShort(user.type)}`}
name={user.name}
profileImage={user.profilePicture}
/>
</span>
))}
</div>
</div>
)}
{selectedStepIndex === currentStepIndex && !selectedStep.completed && !selectedStep.rejected &&
<div className="flex flex-row gap-2 ">
<Button
type="submit"
color="purple"
variant="solid"
disabled={(!selectedStep.assignees.includes(user.id) && user.type !== "admin" && user.type !== "developer") || isLoading}
onClick={handleApproveStep}
padding="px-6 py-2"
className="mb-3 w-full text-lg flex items-center justify-center gap-2 text-left"
>
{isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<IoMdCheckmarkCircleOutline size={20} />
Approve Step
</>
)}
</Button>
<Button
type="submit"
color="red"
variant="solid"
disabled={(!selectedStep.assignees.includes(user.id) && user.type !== "admin" && user.type !== "developer") || isLoading}
onClick={handleRejectStep}
padding="px-6 py-2"
className="mb-3 w-1/2 text-lg flex items-center justify-center gap-2 text-left"
>
{isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<RxCrossCircled size={20} />
Reject
</>
)}
</Button>
</div>
}
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
{/* 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="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,401 @@
import Tip from "@/components/ApprovalWorkflows/Tip";
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import useApprovalWorkflows from "@/hooks/useApprovalWorkflows";
import { useAllowedEntities, useAllowedEntitiesSomePermissions, useEntityPermission } from "@/hooks/useEntityPermissions";
import { Module, ModuleTypeLabels } from "@/interfaces";
import { ApprovalWorkflow, ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel, StepTypeLabel } from "@/interfaces/approval.workflow";
import { Entity, EntityWithRoles } from "@/interfaces/entity";
import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getApprovalWorkflows } from "@/utils/approval.workflows.be";
import { getEntities, 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 } 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 workflows = await getApprovalWorkflows("active-workflows");
const allAssigneeIds: string[] = [
...new Set(
workflows
.map(workflow => workflow.steps
.map(step => step.assignees)
.flat()
).flat()
)
];
const entityIDS = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const allowedEntities = findAllowedEntities(user, entities, "view_workflows");
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 { workflows, reload } = useApprovalWorkflows();
const currentWorkflows = workflows || initialWorkflows;
const [filteredWorkflows, setFilteredWorkflows] = useState<ApprovalWorkflow[]>([]);
const [statusFilter, setStatusFilter] = useState<CustomStatus>(undefined);
const [entityFilter, setEntityFilter] = useState<CustomEntity>(undefined);
const [nameFilter, setNameFilter] = useState<string>("");
const 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">
{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 table = useReactTable({
data: filteredWorkflows,
columns: columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<>
<Head>
<title>Approval Workflows Panel | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<h1 className="text-2xl font-semibold">Approval Workflows</h1>
<div className="flex flex-row">
<Link href={"/approval-workflows/create"}>
<Button color="purple" variant="solid" className="min-w-fit text-lg font-medium flex items-center gap-2 text-left">
<IoIosAddCircleOutline className="size-6" />
Configure Workflows
</Button>
</Link>
</div>
<div className="flex w-full items-center gap-4">
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Name</label>
<Input name="nameFilter" type="text" value={nameFilter} onChange={handleNameFilterChange} placeholder="Filter by name..." />
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Status</label>
<Select
options={STATUS_OPTIONS}
value={STATUS_OPTIONS.find((x) => x.value === statusFilter)}
onChange={(value) => setStatusFilter((value?.value as ApprovalWorkflowStatus) ?? undefined)}
isClearable
placeholder="Filter by status..."
/>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Entity</label>
<Select
options={ENTITY_OPTIONS}
value={ENTITY_OPTIONS.find((x) => x.value === entityFilter)}
onChange={(value) => setEntityFilter((value?.value as CustomEntity) ?? undefined)}
isClearable
placeholder="Filter by entity..."
/>
</div>
</div>
<Tip text="An exam submission will instantiate the approval workflow configured for the exam author. The exam will be valid only when all the steps of the workflow have been approved."></Tip>
<div className="px-6 pb-4 bg-mti-purple-ultralight rounded-2xl border-2 border-mti-purple-light border-opacity-40">
<table className="w-full table-auto border-separate border-spacing-y-2" style={{ tableLayout: "auto" }}>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-3 py-2 text-left text-mti-purple-ultradark">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
onClick={() => (window.location.href = `/approval-workflows/${row.original._id?.toString()}`)}
style={{ cursor: "pointer" }}
className="bg-purple-50">
{row.getVisibleCells().map((cell, cellIndex) => {
const lastCellIndex = row.getVisibleCells().length - 1;
let cellClasses = "pl-3 pr-4 py-2 border-y-2 border-mti-purple-light border-opacity-60";
if (cellIndex === 0) {
cellClasses += " border-l-2 rounded-l-2xl";
}
if (cellIndex === lastCellIndex) {
cellClasses += " border-r-2 rounded-r-2xl";
}
return (
<td key={cellIndex} className={cellClasses}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</>
);
}

View File

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

View File

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

View File

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

View File

@@ -28,59 +28,137 @@ 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")
if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]))
return redirect("/")
const user = await requestUser(req, res);
if (!user) return redirect("/login");
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 (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS));
const entities = await (isAdmin
? getEntitiesWithRoles()
: getEntitiesWithRoles(entityIDS));
const allowedEntities = findAllowedEntities(user, entities, "view_assignments")
const allowedEntities = findAllowedEntities(
user,
entities,
"view_assignments"
);
const [users, assignments] = await Promise.all([
await (isAdmin
? getUsers({}, 0, {}, { _id: 0, id: 1, name: 1 })
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
_id: 0,
id: 1,
name: 1,
})),
await (isAdmin
? getAssignments()
: getEntitiesAssignments(mapBy(allowedEntities, "id"))),
]);
const users =
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[]
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 { 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 (
<>
@@ -96,7 +174,10 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<>
<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">
<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>
@@ -107,35 +188,56 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<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)}
<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>
<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} />
<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>
<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">
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>
@@ -143,9 +245,9 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<AssignmentCard
{...a}
users={users}
entityObj={findBy(entities, 'id', a.entity)}
entityObj={findBy(entities, "id", a.entity)}
onClick={
mapBy(entitiesAllowEdit, 'id').includes(a.entity || "")
mapBy(entitiesAllowEdit, "id").includes(a.entity || "")
? () => router.push(`/assignments/creator/${a.id}`)
: () => router.push(`/assignments/${a.id}`)
}
@@ -156,7 +258,9 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Past Assignments ({pastAssignments.length})</h2>
<h2 className="text-2xl font-semibold">
Past Assignments ({pastAssignments.length})
</h2>
<div className="w-full flex items-center gap-4">
{renderPast()}
{paginationPast()}
@@ -166,18 +270,22 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<AssignmentCard
{...a}
users={users}
entityObj={findBy(entities, 'id', a.entity)}
entityObj={findBy(entities, "id", a.entity)}
onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id}
allowDownload
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")}
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>
<h2 className="text-2xl font-semibold">
Assignments start expired ({startExpiredAssignments.length})
</h2>
<div className="w-full flex items-center gap-4">
{renderExpired()}
{paginationExpired()}
@@ -187,18 +295,22 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<AssignmentCard
{...a}
users={users}
entityObj={findBy(entities, 'id', a.entity)}
entityObj={findBy(entities, "id", a.entity)}
onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id}
allowDownload
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")}
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>
<h2 className="text-2xl font-semibold">
Archived Assignments ({archivedAssignments.length})
</h2>
<div className="w-full flex items-center gap-4">
{renderArchived()}
{paginationArchived()}
@@ -210,7 +322,7 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
users={users}
onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id}
entityObj={findBy(entities, 'id', a.entity)}
entityObj={findBy(entities, "id", a.entity)}
allowDownload
allowUnarchive
allowExcelDownload

View File

@@ -18,47 +18,93 @@ 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 group = await getGroup(id);
if (!group || !group.entity) return redirect("/classrooms")
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);
return {
props: serialize({ user, group: groupWithUser, users: linkedUsers.filter(x => isAdmin(user) ? true : !isAdmin(x)), entity }),
props: serialize({
user,
group: groupWithUser,
users: linkedUsers.filter((x) => (isAdmin(user) ? true : !isAdmin(x))),
entity,
}),
};
}, sessionOptions);
},
sessionOptions
);
interface Props {
user: User;
group: GroupWithUsers;
users: User[];
entity: EntityWithRoles
entity: EntityWithRoles;
}
export default function Home({ user, group, users, entity }: Props) {
@@ -66,36 +112,73 @@ export default function Home({ user, group, users, entity }: Props) {
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],
() =>
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,
isAdding ? nonParticipantUsers : group.participants
);
const { items, renderMinimal } = usePagination<User>(rows, 20);
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?`))
if (
!confirm(
`Are you sure you want to remove ${selectedUsers.length} participant${
selectedUsers.length === 1 ? "" : "s"
} from this group?`
)
)
return;
setIsLoading(true);
axios
.patch(`/api/groups/${group.id}`, { participants: group.participants.map((x) => x.id).filter((x) => !selectedUsers.includes(x)) })
.patch(`/api/groups/${group.id}`, {
participants: group.participants
.map((x) => x.id)
.filter((x) => !selectedUsers.includes(x)),
})
.then(() => {
toast.success("The group has been updated successfully!");
router.replace(router.asPath);
@@ -110,13 +193,24 @@ export default function Home({ user, group, users, entity }: Props) {
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?`))
if (
!confirm(
`Are you sure you want to add ${selectedUsers.length} participant${
selectedUsers.length === 1 ? "" : "s"
} to this group?`
)
)
return;
setIsLoading(true);
axios
.patch(`/api/groups/${group.id}`, { participants: [...group.participants.map((x) => x.id), ...selectedUsers] })
.patch(`/api/groups/${group.id}`, {
participants: [
...group.participants.map((x) => x.id),
...selectedUsers,
],
})
.then(() => {
toast.success("The group has been updated successfully!");
router.replace(router.asPath);
@@ -189,7 +283,8 @@ export default function Home({ user, group, users, entity }: Props) {
<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">
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>
@@ -200,14 +295,16 @@ export default function Home({ user, group, users, entity }: Props) {
<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">
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">
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>
@@ -219,7 +316,8 @@ export default function Home({ user, group, users, entity }: Props) {
<BsBuilding className="text-xl" /> {entity.label}
</span>
<span className="flex items-center gap-2">
<BsFillPersonVcardFill className="text-xl" /> {getUserName(group.admin)}
<BsFillPersonVcardFill className="text-xl" />{" "}
{getUserName(group.admin)}
</span>
</div>
</div>
@@ -231,14 +329,20 @@ export default function Home({ user, group, users, entity }: Props) {
<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">
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">
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>
@@ -249,14 +353,20 @@ export default function Home({ user, group, users, entity }: Props) {
<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">
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">
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>
@@ -268,26 +378,53 @@ export default function Home({ user, group, users, entity }: Props) {
{renderMinimal()}
</div>
<div className="flex items-center gap-2 mt-4">
{['student', 'teacher', 'corporate'].map((type) => (
{["student", "teacher", "corporate"].map((type) => (
<button
key={type}
onClick={() => {
const typeUsers = mapBy(filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type), 'id')
const typeUsers = mapBy(
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"type",
type
),
"id"
);
if (typeUsers.every((u) => selectedUsers.includes(u))) {
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a)));
setSelectedUsers((prev) =>
prev.filter((a) => !typeUsers.includes(a))
);
} else {
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]);
setSelectedUsers((prev) => [
...prev.filter((a) => !typeUsers.includes(a)),
...typeUsers,
]);
}
}}
disabled={filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length === 0}
disabled={
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"type",
type
).length === 0
}
className={clsx(
"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",
)}>
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>
))}
@@ -298,20 +435,25 @@ export default function Home({ user, group, users, entity }: Props) {
{items.map((u) => (
<button
onClick={() => toggleUser(u)}
disabled={isAdding ? !canAddParticipants : !canRemoveParticipants}
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",
)}>
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>
<span className="opacity-80 text-sm">
{USER_TYPE_LABELS[u.type]}
</span>
</div>
</div>
@@ -326,13 +468,19 @@ export default function Home({ user, group, users, entity }: Props) {
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
{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"}
{u.lastLogin
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
: "N/A"}
</span>
</div>
</button>

View File

@@ -22,24 +22,57 @@ 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 {
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")
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)}),
props: serialize({
user,
entities: allowedEntities,
users: users,
}),
};
}, sessionOptions);
@@ -55,27 +88,48 @@ export default function Home({user, users, entities}: Props) {
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
[
["name"],
["type"],
["corporateInformation", "companyInformation", "name"],
],
entityUsers
);
const { items, renderMinimal } = usePagination<User>(rows, 16);
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;
if (
!confirm(
`Are you sure you want to create this group with ${selectedUsers.length} participants?`
)
)
return;
setIsLoading(true);
axios
.post<{id: string}>(`/api/groups`, {name, participants: selectedUsers, admin: user.id, entity})
.post<{ id: string }>(`/api/groups`, {
name,
participants: selectedUsers,
admin: user.id,
entity,
})
.then((result) => {
toast.success("Your group has been created successfully!");
router.replace(`/classrooms/${result.data.id}`);
@@ -87,7 +141,10 @@ export default function Home({user, users, entities}: Props) {
.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 (
<>
@@ -107,7 +164,8 @@ export default function Home({user, users, entities}: Props) {
<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">
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>
@@ -116,7 +174,8 @@ export default function Home({user, users, entities}: Props) {
<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">
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>
@@ -126,46 +185,67 @@ export default function Home({user, users, entities}: Props) {
<div className="grid grid-cols-2 gap-4 place-items-end">
<div className="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" />
<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}}
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>
<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) => (
{["student", "teacher", "corporate"].map((type) => (
<button
key={type}
onClick={() => {
const typeUsers = mapBy(filterBy(entityUsers, 'type', type), 'id')
const typeUsers = mapBy(
filterBy(entityUsers, "type", type),
"id"
);
if (typeUsers.every((u) => selectedUsers.includes(u))) {
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a)));
setSelectedUsers((prev) =>
prev.filter((a) => !typeUsers.includes(a))
);
} else {
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]);
setSelectedUsers((prev) => [
...prev.filter((a) => !typeUsers.includes(a)),
...typeUsers,
]);
}
}}
disabled={filterBy(entityUsers, 'type', type).length === 0}
disabled={filterBy(entityUsers, "type", type).length === 0}
className={clsx(
"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",
)}>
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>
))}
@@ -181,15 +261,18 @@ export default function Home({user, users, entities}: Props) {
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",
)}>
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>
<span className="opacity-80 text-sm">
{USER_TYPE_LABELS[u.type]}
</span>
</div>
</div>
@@ -204,13 +287,17 @@ export default function Home({user, users, entities}: Props) {
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
{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"}
{u.lastLogin
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
: "N/A"}
</span>
</div>
</button>

View File

@@ -27,22 +27,41 @@ 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 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"));
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 }),
props: serialize({
user,
groups: groupsWithUsers,
entities: allowedEntities,
}),
};
}, sessionOptions);
@@ -59,39 +78,60 @@ const SEARCH_FIELDS = [
interface Props {
user: User;
groups: GroupWithUsers[];
entities: EntityWithRoles[]
entities: EntityWithRoles[];
}
export default function Home({ user, groups, entities }: Props) {
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_classroom');
const entitiesAllowCreate = useAllowedEntities(
user,
entities,
"create_classroom"
);
const [showImport, setShowImport] = useState(false);
const 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">
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>
<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>
<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 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 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> : ""}
{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">
@@ -103,7 +143,8 @@ export default function Home({ user, groups, entities }: Props) {
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">
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>
@@ -123,24 +164,33 @@ export default function Home({ user, groups, entities }: Props) {
<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
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
{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",
"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>

View File

@@ -6,18 +6,13 @@ import { Stat, 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 { countEntitiesAssignments } from "@/utils/assignments.be";
import { getEntities } from "@/utils/entities.be";
import { countGroups } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions";
import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be";
import {
countUsersByTypes,
getUsers,
} from "@/utils/users.be";
import { countUsersByTypes, getUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next";
import Head from "next/head";
import { useRouter } from "next/router";
@@ -49,50 +44,48 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
const students = await getUsers(
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: 1, name: 1, email: 1, profilePicture: 1 }
);
const usersCount = await countUsersByTypes([
"student",
"teacher",
"corporate",
"mastercorporate",
]);
const latestStudents = await getUsers(
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "student" },
10,
{
registrationDate: -1,
},
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const latestTeachers = await getUsers(
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "teacher" },
10,
{
registrationDate: -1,
},
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const entities = await getEntities(undefined, { _id: 0, id: 1, label: 1 });
const assignmentsCount = await countEntitiesAssignments(
mapBy(entities, "id"),
{ archived: { $ne: true } }
);
const groupsCount = await countGroups();
const stats = await getStatsByUsers(mapBy(students, "id"));
{ _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")),
]);
return {
props: serialize({

View File

@@ -14,10 +14,7 @@ import {
groupAllowedEntitiesByPermissions,
} from "@/utils/permissions";
import { groupByExam } from "@/utils/stats";
import {
countAllowedUsers,
getUsers,
} from "@/utils/users.be";
import { countAllowedUsers, getUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next";
import moment from "moment";
import Head from "next/head";
@@ -71,37 +68,41 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
const entitiesIDS = mapBy(entities, "id") || [];
const students = await getUsers(
const [
students,
latestStudents,
latestTeachers,
userCounts,
assignmentsCount,
groupsCount,
] = await Promise.all([
getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10,
{ averageLevel: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const latestStudents = await getUsers(
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10,
{ registrationDate: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const latestTeachers = await getUsers(
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{
type: "teacher",
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
},
10,
{ registrationDate: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const userCounts = await countAllowedUsers(user, entities);
const assignmentsCount = await countEntitiesAssignments(
entitiesIDS,
{ archived: { $ne: true } }
);
const groupsCount = await countGroupsByEntities(entitiesIDS);
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
countAllowedUsers(user, entities),
countEntitiesAssignments(entitiesIDS, {
archived: { $ne: true },
}),
countGroupsByEntities(entitiesIDS),
]);
return {
props: serialize({

View File

@@ -6,17 +6,12 @@ import { Stat, 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 { countEntitiesAssignments } from "@/utils/assignments.be";
import { getEntities } from "@/utils/entities.be";
import { countGroups } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions";
import { groupByExam } from "@/utils/stats";
import {
countUsersByTypes,
getUsers,
} from "@/utils/users.be";
import { countUsersByTypes, getUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next";
import Head from "next/head";
import { useRouter } from "next/router";
@@ -49,45 +44,41 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
const students = await getUsers(
const [
students,
latestStudents,
latestTeachers,
usersCount,
entities,
groupsCount,
] = await Promise.all([
getUsers(
{ type: "student" },
10,
{
averageLevel: -1,
},
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const usersCount = await countUsersByTypes([
"student",
"teacher",
"corporate",
"mastercorporate",
]);
const latestStudents = await getUsers(
{ averageLevel: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "student" },
10,
{
registrationDate: -1,
},
{id:1, name: 1, email: 1, profilePicture: 1 }
);
const latestTeachers = await getUsers(
{ registrationDate: -1 },
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "teacher" },
10,
{
registrationDate: -1,
},
{ id:1,name: 1, email: 1, profilePicture: 1 }
);
{ 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 entities = await getEntities(undefined, { _id: 0, id: 1, label: 1 });
const assignmentsCount = await countEntitiesAssignments(
mapBy(entities, "id"),
{ archived: { $ne: true } }
);
const groupsCount = await countGroups();
return {
props: serialize({

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

@@ -70,37 +70,39 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const entitiesIDS = mapBy(entities, "id") || [];
const students = await getUsers(
const [
students,
latestStudents,
latestTeachers,
userCounts,
assignmentsCount,
groupsCount,
] = await Promise.all([
getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10,
{ averageLevel: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const latestStudents = await getUsers(
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10,
{ registrationDate: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const latestTeachers = await getUsers(
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
getUsers(
{
type: "teacher",
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
},
10,
{ registrationDate: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 }
);
const userCounts = await countAllowedUsers(user, entities);
const assignmentsCount = await countEntitiesAssignments(entitiesIDS, {
archived: { $ne: true },
});
const groupsCount = await countGroupsByEntities(entitiesIDS);
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
),
countAllowedUsers(user, entities),
countEntitiesAssignments(entitiesIDS, { archived: { $ne: true } }),
countGroupsByEntities(entitiesIDS),
]);
return {
props: serialize({
@@ -127,6 +129,7 @@ export default function Dashboard({
stats = [],
groupsCount,
}: Props) {
const totalCount = useMemo(
() =>
userCounts.corporate +

View File

@@ -5,7 +5,7 @@ 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";
@@ -16,7 +16,6 @@ import useExamStore from "@/stores/exam";
import { findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getAssignmentsForStudent } from "@/utils/assignments.be";
import { getEntities } from "@/utils/entities.be";
import { getExamsByIds } from "@/utils/exams.be";
import { getGradingSystemByEntity } from "@/utils/grading.be";
import {
@@ -34,6 +33,7 @@ import { capitalize, uniqBy } from "lodash";
import moment from "moment";
import Head from "next/head";
import { useRouter } from "next/router";
import { useMemo } from "react";
import {
BsBook,
BsClipboard,
@@ -65,44 +65,49 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
return redirect("/");
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntities(entityIDS, { _id: 0, label: 1 });
const currentDate = moment().toISOString();
const assignments = await getAssignmentsForStudent(user.id, currentDate);
const stats = await getDetailedStatsByUser(user.id, "stats");
const assignmentsIDs = mapBy(assignments, "id");
const sessions = await getSessionsByUser(user.id, 10, {
["assignment.id"]: { $in: assignmentsIDs },
});
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.flatMap((a) =>
a.exams.map((e: { module: string; id: string }) => ({
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 exams = examIDs.length > 0 ? await getExamsByIds(examIDs) : [];
return {
props: serialize({
user,
entities,
assignments,
stats,
stats: stats ,
exams,
sessions,
invites: formattedInvites,
@@ -145,6 +150,11 @@ export default function Dashboard({
}
};
const entitiesLabels = useMemo(
() => (entities.length > 0 ? mapBy(entities, "label")?.join(", ") : ""),
[entities]
);
return (
<>
<Head>
@@ -160,7 +170,7 @@ export default function Dashboard({
<>
{entities.length > 0 && (
<div className="rounded-lg bg-neutral-200 px-2 py-1 ">
<b>{mapBy(entities, "label")?.join(", ")}</b>
<b>{entitiesLabels}</b>
</div>
)}
@@ -171,7 +181,7 @@ export default function Dashboard({
icon: (
<BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
),
value: stats.fullExams,
value: stats?.fullExams || 0,
label: "Exams",
tooltip: "Number of all conducted completed exams",
},
@@ -179,7 +189,7 @@ export default function Dashboard({
icon: (
<BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
),
value: stats.uniqueModules,
value: stats?.uniqueModules || 0,
label: "Modules",
tooltip:
"Number of all exam modules performed including Level Test",

View File

@@ -27,6 +27,7 @@ import { requestUser } from "@/utils/api";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { getEntitiesUsers } from "@/utils/users.be";
import { isAdmin } from "@/utils/users";
import { useMemo } from "react";
interface Props {
user: User;
@@ -52,7 +53,8 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const filteredEntities = findAllowedEntities(user, entities, "view_students");
const students = await getEntitiesUsers(
const [students, assignments, groups] = await Promise.all([
getEntitiesUsers(
mapBy(filteredEntities, "id"),
{
type: "student",
@@ -67,14 +69,13 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
levels: 1,
registrationDate: 1,
}
);
const assignments = await getEntitiesAssignments(entityIDS);
),
getEntitiesAssignments(entityIDS),
getGroupsByEntities(entityIDS),
]);
const stats = await getStatsByUsers(students.map((u) => u.id));
const groups = await getGroupsByEntities(entityIDS);
return {
props: serialize({ user, students, entities, assignments, stats, groups }),
};
@@ -100,6 +101,10 @@ export default function Dashboard({
entities,
"view_student_performance"
);
const entitiesLabels = useMemo(
() => mapBy(entities, "label")?.join(", "),
[entities]
);
return (
<>
@@ -117,7 +122,7 @@ export default function Dashboard({
<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>
<b>{entitiesLabels}</b>
</div>
)}
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">

View File

@@ -14,7 +14,12 @@ import { getEntityWithRoles } from "@/utils/entities.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { doesEntityAllow } from "@/utils/permissions";
import { getUserName, isAdmin } from "@/utils/users";
import { filterAllowedUsers, getEntitiesUsers, getEntityUsers, getUsers } from "@/utils/users.be";
import {
filterAllowedUsers,
getEntitiesUsers,
getEntityUsers,
getUsers,
} from "@/utils/users.be";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
@@ -42,14 +47,18 @@ import {
BsX,
} from "react-icons/bs";
import { toast } from "react-toastify";
import entities from "../../api/entities";
const expirationDateColor = (date: Date) => {
const momentDate = moment(date);
const today = moment(new Date());
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
if (today.add(1, "days").isAfter(momentDate))
return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(3, "days").isAfter(momentDate))
return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate))
return "!bg-mti-orange-ultralight border-mti-orange-light";
};
const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
@@ -57,26 +66,62 @@ const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
label,
}));
export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => {
const USER_DATA_SCHEMA = {
_id: 0,
id: 1,
name: 1,
type: 1,
profilePicture: 1,
email: 1,
lastLogin: 1,
subscriptionExpirationDate: 1,
entities: 1,
corporateInformation: 1,
};
export const getServerSideProps = withIronSessionSsr(
async ({ req, params }) => {
const user = req.session.user as User;
if (!user) return redirect("/login")
if (shouldRedirectHome(user)) return redirect("/")
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/");
const { id } = params as { id: string };
const entity = await getEntityWithRoles(id);
if (!entity) return redirect("/entities")
if (!entity) return redirect("/entities");
if (!doesEntityAllow(user, entity, "view_entities")) return redirect(`/entities`)
const linkedUsers = await (isAdmin(user) ? getUsers() : getEntitiesUsers(mapBy(user.entities, 'id'),
{ $and: [{ type: { $ne: "developer" } }, { type: { $ne: "admin" } }] }))
const entityUsers = await (isAdmin(user) ? getEntityUsers(id) : filterAllowedUsers(user, [entity]));
if (!doesEntityAllow(user, entity, "view_entities"))
return redirect(`/entities`);
const [linkedUsers, entityUsers] = await Promise.all([
isAdmin(user)
? getUsers({}, 0, {}, USER_DATA_SCHEMA)
: getEntitiesUsers(
mapBy(user.entities, "id"),
{
$and: [
{ type: { $ne: "developer" } },
{ type: { $ne: "admin" } },
],
},
0,
USER_DATA_SCHEMA
),
isAdmin(user)
? getEntityUsers(
id,
0,
{
id: { $ne: user.id },
},
USER_DATA_SCHEMA
)
: filterAllowedUsers(user, [entity], USER_DATA_SCHEMA),
]);
const usersWithRole = entityUsers.map((u) => {
const e = u.entities.find((e) => e.id === id);
return { ...u, role: findBy(entity.roles, 'id', e?.role) };
const e = u?.entities?.find((e) => e.id === id);
return { ...u, role: findBy(entity.roles, "id", e?.role) };
});
return {
@@ -84,10 +129,14 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, params }) =>
user,
entity,
users: usersWithRole,
linkedUsers: linkedUsers.filter(x => x.id !== user.id && !mapBy(entityUsers, 'id').includes(x.id)),
linkedUsers: linkedUsers.filter(
(x) => !mapBy(entityUsers, "id").includes(x.id)
),
}),
};
}, sessionOptions);
},
sessionOptions
);
type UserWithRole = User & { role?: Role };
@@ -102,34 +151,52 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const [isAdding, setIsAdding] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [expiryDate, setExpiryDate] = useState(entity?.expiryDate)
const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price)
const [paymentCurrency, setPaymentCurrency] = useState(entity?.payment?.currency)
const [expiryDate, setExpiryDate] = useState(entity?.expiryDate);
const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price);
const [paymentCurrency, setPaymentCurrency] = useState(
entity?.payment?.currency
);
const router = useRouter();
const canRenameEntity = useEntityPermission(user, entity, "rename_entity")
const canViewRoles = useEntityPermission(user, entity, "view_entity_roles")
const canDeleteEntity = useEntityPermission(user, entity, "delete_entity")
const canRenameEntity = useEntityPermission(user, entity, "rename_entity");
const canViewRoles = useEntityPermission(user, entity, "view_entity_roles");
const canDeleteEntity = useEntityPermission(user, entity, "delete_entity");
const canAddMembers = useEntityPermission(user, entity, "add_to_entity")
const canRemoveMembers = useEntityPermission(user, entity, "remove_from_entity")
const canAddMembers = useEntityPermission(user, entity, "add_to_entity");
const canRemoveMembers = useEntityPermission(
user,
entity,
"remove_from_entity"
);
const canAssignRole = useEntityPermission(user, entity, "assign_to_role")
const canPay = useEntityPermission(user, entity, 'pay_entity')
const canAssignRole = useEntityPermission(user, entity, "assign_to_role");
const canPay = useEntityPermission(user, entity, "pay_entity");
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
const toggleUser = (u: User) =>
setSelectedUsers((prev) =>
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
);
const removeParticipants = () => {
if (selectedUsers.length === 0) return;
if (!canRemoveMembers) return;
if (!confirm(`Are you sure you want to remove ${selectedUsers.length} member${selectedUsers.length === 1 ? "" : "s"} from this entity?`))
if (
!confirm(
`Are you sure you want to remove ${selectedUsers.length} member${
selectedUsers.length === 1 ? "" : "s"
} from this entity?`
)
)
return;
setIsLoading(true);
axios
.patch(`/api/entities/${entity.id}/users`, { add: false, members: selectedUsers })
.patch(`/api/entities/${entity.id}/users`, {
add: false,
members: selectedUsers,
})
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
@@ -145,13 +212,24 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const addParticipants = () => {
if (selectedUsers.length === 0) return;
if (!canAddMembers || !isAdding) return;
if (!confirm(`Are you sure you want to add ${selectedUsers.length} member${selectedUsers.length === 1 ? "" : "s"} to this entity?`)) return;
if (
!confirm(
`Are you sure you want to add ${selectedUsers.length} member${
selectedUsers.length === 1 ? "" : "s"
} to this entity?`
)
)
return;
setIsLoading(true);
const defaultRole = findBy(entity.roles, 'isDefault', true)!
const defaultRole = findBy(entity.roles, "isDefault", true)!;
axios
.patch(`/api/entities/${entity.id}/users`, { add: true, members: selectedUsers, role: defaultRole.id })
.patch(`/api/entities/${entity.id}/users`, {
add: true,
members: selectedUsers,
role: defaultRole.id,
})
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
@@ -206,7 +284,9 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
setIsLoading(true);
axios
.patch(`/api/entities/${entity.id}`, { payment: { price: paymentPrice, currency: paymentCurrency } })
.patch(`/api/entities/${entity.id}`, {
payment: { price: paymentPrice, currency: paymentCurrency },
})
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
@@ -221,9 +301,13 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const editLicenses = () => {
if (!isAdmin(user)) return;
const licenses = prompt("Update the number of licenses:", (entity.licenses || 0).toString());
const licenses = prompt(
"Update the number of licenses:",
(entity.licenses || 0).toString()
);
if (!licenses) return;
if (!parseInt(licenses) || parseInt(licenses) <= 0) return toast.error("Write a valid number of licenses!")
if (!parseInt(licenses) || parseInt(licenses) <= 0)
return toast.error("Write a valid number of licenses!");
setIsLoading(true);
axios
@@ -259,8 +343,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
};
const assignUsersToRole = (role: string) => {
if (!canAssignRole) return
if (selectedUsers.length === 0) return
if (!canAssignRole) return;
if (selectedUsers.length === 0) return;
setIsLoading(true);
axios
@@ -274,7 +358,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
}
};
const renderCard = (u: UserWithRole) => {
return (
@@ -285,8 +369,9 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
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",
)}>
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} />
@@ -311,13 +396,17 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
{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"}
{u.lastLogin
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
: "N/A"}
</span>
</div>
</button>
@@ -345,10 +434,14 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<div className="flex items-center gap-2">
<Link
href="/entities"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">{entity.label} {isAdmin(user) && `- ${entity.licenses || 0} licenses`}</h2>
<h2 className="font-bold text-2xl">
{entity.label}{" "}
{isAdmin(user) && `- ${entity.licenses || 0} licenses`}
</h2>
</div>
{!isAdmin(user) && canPay && (
@@ -357,11 +450,15 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
className={clsx(
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!entity.expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(entity.expiryDate),
"bg-white border-mti-gray-platinum",
)}>
!entity.expiryDate
? "!bg-mti-green-ultralight !border-mti-green-light"
: expirationDateColor(entity.expiryDate),
"bg-white border-mti-gray-platinum"
)}
>
{!entity.expiryDate && "Unlimited"}
{entity.expiryDate && moment(entity.expiryDate).format("DD/MM/YYYY")}
{entity.expiryDate &&
moment(entity.expiryDate).format("DD/MM/YYYY")}
</Link>
)}
</div>
@@ -369,7 +466,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button
onClick={renameGroup}
disabled={isLoading || !canRenameEntity}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTag />
<span className="text-xs">Rename Entity</span>
</button>
@@ -377,7 +475,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button
onClick={editLicenses}
disabled={isLoading || !isAdmin(user)}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsHash />
<span className="text-xs">Edit Licenses</span>
</button>
@@ -385,14 +484,16 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button
onClick={() => router.push(`/entities/${entity.id}/roles`)}
disabled={isLoading || !canViewRoles}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPerson />
<span className="text-xs">Edit Roles</span>
</button>
<button
onClick={deleteGroup}
disabled={isLoading || !canDeleteEntity}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTrash />
<span className="text-xs">Delete Entity</span>
</button>
@@ -410,8 +511,10 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
className={clsx(
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate),
"transition duration-300 ease-in-out",
!expiryDate
? "!bg-mti-green-ultralight !border-mti-green-light"
: expirationDateColor(expiryDate),
"transition duration-300 ease-in-out"
)}
filterDate={(date) => moment(date).isAfter(new Date())}
dateFormat="dd/MM/yyyy"
@@ -425,8 +528,10 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
className={clsx(
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate),
"bg-white border-mti-gray-platinum",
!expiryDate
? "!bg-mti-green-ultralight !border-mti-green-light"
: expirationDateColor(expiryDate),
"bg-white border-mti-gray-platinum"
)}
>
Unlimited
@@ -435,17 +540,21 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<Checkbox
isChecked={!!expiryDate}
onChange={(checked: boolean) => setExpiryDate(checked ? entity.expiryDate || new Date() : null)}
onChange={(checked: boolean) =>
setExpiryDate(
checked ? entity.expiryDate || new Date() : null
)
}
>
Enable expiry date
</Checkbox>
</div>
<button
onClick={updateExpiryDate}
disabled={expiryDate === entity.expiryDate}
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsCheck />
<span className="text-xs">Apply Change</span>
</button>
@@ -457,25 +566,34 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<div className="w-full max-w-xl flex items-center gap-4">
<Input
name="paymentValue"
onChange={(e) => setPaymentPrice(e ? parseInt(e) : undefined)}
onChange={(e) =>
setPaymentPrice(e ? parseInt(e) : undefined)
}
type="number"
defaultValue={entity.payment?.price || 0}
thin
/>
<Select
className={clsx(
"px-4 !py-2 !w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
"px-4 !py-2 !w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
)}
options={CURRENCIES_OPTIONS}
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
onChange={(value) => setPaymentCurrency(value?.value ?? undefined)}
value={CURRENCIES_OPTIONS.find(
(c) => c.value === paymentCurrency
)}
onChange={(value) =>
setPaymentCurrency(value?.value ?? undefined)
}
/>
</div>
<button
onClick={updatePayment}
disabled={!paymentPrice || paymentPrice <= 0 || !paymentCurrency}
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
disabled={
!paymentPrice || paymentPrice <= 0 || !paymentCurrency
}
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsCheck />
<span className="text-xs">Apply Change</span>
</button>
@@ -485,28 +603,40 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<Divider />
<div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Members ({users.length})</span>
<span className="font-semibold text-xl">
Members ({users.length})
</span>
{!isAdding && (
<div className="flex items-center gap-2">
<button
onClick={() => setIsAdding(true)}
disabled={isLoading || !canAddMembers}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPlus />
<span className="text-xs">Add Members</span>
</button>
<Menu>
<MenuButton
disabled={isLoading || !canAssignRole || selectedUsers.length === 0}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
disabled={
isLoading || !canAssignRole || selectedUsers.length === 0
}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPerson />
<span className="text-xs">Assign Role</span>
</MenuButton>
<MenuItems anchor="bottom" className="bg-white rounded-xl shadow drop-shadow border mt-1 flex flex-col">
<MenuItems
anchor="bottom"
className="bg-white rounded-xl shadow drop-shadow border mt-1 flex flex-col"
>
{entity.roles.map((role) => (
<MenuItem key={role.id}>
<button onClick={() => assignUsersToRole(role.id)} className="p-4 hover:bg-neutral-100 w-32">
<button
onClick={() => assignUsersToRole(role.id)}
className="p-4 hover:bg-neutral-100 w-32"
>
{role.label}
</button>
</MenuItem>
@@ -516,8 +646,11 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button
onClick={removeParticipants}
disabled={selectedUsers.length === 0 || isLoading || !canRemoveMembers}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
disabled={
selectedUsers.length === 0 || isLoading || !canRemoveMembers
}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTrash />
<span className="text-xs">Remove Members</span>
</button>
@@ -528,16 +661,22 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<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">
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 || !canAddMembers}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
disabled={
selectedUsers.length === 0 || isLoading || !canAddMembers
}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPlus />
<span className="text-xs">Add Members ({selectedUsers.length})</span>
<span className="text-xs">
Add Members ({selectedUsers.length})
</span>
</button>
</div>
)}
@@ -546,7 +685,13 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<CardList<User | UserWithRole>
list={isAdding ? linkedUsers : users}
renderCard={renderCard}
searchFields={[["name"], ["email"], ["corporateInformation", "companyInformation", "name"], ["role", "label"], ["type"]]}
searchFields={[
["name"],
["email"],
["corporateInformation", "companyInformation", "name"],
["role", "label"],
["type"],
]}
/>
</section>
</>

View File

@@ -19,15 +19,10 @@ 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 {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"},
@@ -49,7 +44,7 @@ const USER_MANAGEMENT: PermissionLayout[] = [
{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"},
@@ -67,8 +62,9 @@ const EXAM_MANAGEMENT: PermissionLayout[] = [
{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 Statistics", key: "view_statistics"},
]
];
const CLASSROOM_MANAGEMENT: PermissionLayout[] = [
{label: "View Classrooms", key: "view_classrooms"},
@@ -80,7 +76,7 @@ const CLASSROOM_MANAGEMENT: PermissionLayout[] = [
{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"},
@@ -99,8 +95,8 @@ const ENTITY_MANAGEMENT: PermissionLayout[] = [
{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 Payment Record", key: "view_payment_record"},
];
const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [
{label: "View Assignments", key: "view_assignments"},
@@ -109,26 +105,33 @@ const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [
{label: "Edit Assignments", key: "edit_assignment"},
{label: "Delete Assignments", key: "delete_assignment"},
{label: "Archive Assignments", key: "archive_assignment"},
]
];
const WORKFLOW_MANAGEMENT: PermissionLayout[] = [
{label: "View Workflows", key: "view_workflows"},
{label: "Configure Workflows", key: "configure_workflows"},
{label: "Edit Workflow", key: "edit_workflow"},
{label: "Delete Workflow", key: "delete_workflow"},
];
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
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 { id, role } = params as { id: string, role: string };
const {id, role} = params as {id: string; role: string};
if (!mapBy(user.entities, 'id').includes(id) && !["admin", "developer"].includes(user.type)) return redirect("/entities")
if (!mapBy(user.entities, "id").includes(id) && !["admin", "developer"].includes(user.type)) return redirect("/entities");
const entity = await getEntityWithRoles(id);
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});
@@ -138,7 +141,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }
entity,
role: entityRole,
userCount,
disableEdit
disableEdit,
}),
};
}, sessionOptions);
@@ -148,19 +151,18 @@ interface Props {
entity: EntityWithRoles;
role: Role;
userCount: number;
disableEdit?: boolean
disableEdit?: boolean;
}
export default function EntityRole({user, entity, role, userCount, disableEdit}: Props) {
const [permissions, setPermissions] = useState(role.permissions)
const [permissions, setPermissions] = useState(role.permissions);
const [isLoading, setIsLoading] = useState(false);
const 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;
@@ -202,7 +204,7 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
};
const editPermissions = () => {
if (!canEditPermissions || disableEdit) return
if (!canEditPermissions || disableEdit) return;
setIsLoading(true);
@@ -217,21 +219,23 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
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."
@@ -249,7 +253,9 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<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">
@@ -286,16 +292,19 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
<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)}>
<Checkbox
disabled={!enableCheckbox(key)}
key={key}
isChecked={permissions.includes(key)}
onChange={() => togglePermissions(key)}>
{label}
</Checkbox>
))}
@@ -307,16 +316,19 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
<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)}>
<Checkbox
disabled={!enableCheckbox(key)}
key={key}
isChecked={permissions.includes(key)}
onChange={() => togglePermissions(key)}>
{label}
</Checkbox>
))}
@@ -328,16 +340,19 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
<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)}>
<Checkbox
disabled={!enableCheckbox(key)}
key={key}
isChecked={permissions.includes(key)}
onChange={() => togglePermissions(key)}>
{label}
</Checkbox>
))}
@@ -349,16 +364,19 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
<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)}>
<Checkbox
disabled={!enableCheckbox(key)}
key={key}
isChecked={permissions.includes(key)}
onChange={() => togglePermissions(key)}>
{label}
</Checkbox>
))}
@@ -370,16 +388,43 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
<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)}>
<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>
))}

View File

@@ -20,21 +20,41 @@ 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";
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) }),
props: serialize({ user, users }),
};
}, sessionOptions);
@@ -49,19 +69,31 @@ export default function Home({ user, users }: Props) {
const [label, setLabel] = useState("");
const [licenses, setLicenses] = useState(0);
const { rows, renderSearch } = useListSearch<User>([["name"], ["corporateInformation", "companyInformation", "name"]], users);
const { rows, renderSearch } = useListSearch<User>(
[["name"], ["corporateInformation", "companyInformation", "name"]],
users
);
const { items, renderMinimal } = usePagination<User>(rows, 16);
const router = useRouter();
const createGroup = () => {
if (!label.trim()) return;
if (!confirm(`Are you sure you want to create this entity with ${selectedUsers.length} members?`)) return;
if (
!confirm(
`Are you sure you want to create this entity with ${selectedUsers.length} members?`
)
)
return;
setIsLoading(true);
axios
.post<Entity>(`/api/entities`, { label, licenses, members: selectedUsers })
.post<Entity>(`/api/entities`, {
label,
licenses,
members: selectedUsers,
})
.then((result) => {
toast.success("Your entity has been created successfully!");
router.replace(`/entities/${result.data.id}`);
@@ -73,7 +105,10 @@ export default function Home({ user, users }: Props) {
.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 (
<>
@@ -93,7 +128,8 @@ export default function Home({ user, users }: Props) {
<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">
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>
@@ -102,7 +138,8 @@ export default function Home({ user, users }: Props) {
<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">
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>
@@ -112,17 +149,30 @@ export default function Home({ user, users }: Props) {
<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" />
<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" />
<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>
<span className="font-semibold text-xl">
Members ({selectedUsers.length} selected):
</span>
</div>
<div className="w-full flex items-center gap-4">
{renderSearch()}
@@ -139,15 +189,18 @@ export default function Home({ user, users }: Props) {
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",
)}>
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>
<span className="opacity-80 text-sm">
{USER_TYPE_LABELS[u.type]}
</span>
</div>
</div>
@@ -162,13 +215,17 @@ export default function Home({ user, users }: Props) {
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
{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"}
{u.lastLogin
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
: "N/A"}
</span>
</div>
</button>

View File

@@ -35,17 +35,35 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
);
const allowedEntities = findAllowedEntities(user, entities, "view_entities");
const entitiesWithCount = await Promise.all(
allowedEntities.map(async (e) => ({
entity: e,
count: await countEntityUsers(e.id, {
const [counts, users] = await Promise.all([
await Promise.all(
allowedEntities.map(async (e) =>
countEntityUsers(e.id, {
type: { $in: ["student", "teacher", "corporate", "mastercorporate"] },
}),
users: await getEntityUsers(e.id, 5, {
type: { $in: ["student", "teacher", "corporate", "mastercorporate"] },
}),
}))
);
})
)
),
await Promise.all(
allowedEntities.map(async (e) =>
getEntityUsers(
e.id,
5,
{
type: {
$in: ["student", "teacher", "corporate", "mastercorporate"],
},
},
{ name: 1 }
)
)
),
]);
const entitiesWithCount = allowedEntities.map<{
entity: EntityWithRoles;
users: User[];
count: number;
}>((e, i) => ({ entity: e, users: users[i], count: counts[i] }));
return {
props: serialize({ user, entities: entitiesWithCount }),

View File

@@ -21,77 +21,103 @@ import { getSessionByAssignment } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions";
import { activeAssignmentFilter } from "@/utils/assignments";
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)
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 })
}
props: serialize({
user,
assignment,
exams,
destinationURL,
session: session ?? undefined,
}),
};
}
return {
props: serialize({ user, destinationURL }),
};
}, sessionOptions);
},
sessionOptions
);
interface Props {
user: User;
assignment?: Assignment
exams?: Exam[]
session?: Session
destinationURL?: string
assignment?: Assignment;
exams?: Exam[];
session?: Session;
destinationURL?: string;
}
const Page: React.FC<Props> = ({ user, assignment, exams = [], destinationURL = "/exam", session }) => {
const router = useRouter()
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
if (!activeAssignmentFilter(assignment)) return;
dispatch({
type: "INIT_EXAM", payload: {
type: "INIT_EXAM",
payload: {
exams: exams.sort(sortByModule),
modules: exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
assignment
}
assignment,
},
});
router.replace(router.asPath)
router.replace(router.asPath);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session])
}, [assignment, exams, session]);
useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
dispatch({ type: "SET_SESSION", payload: { session } })
router.replace(router.asPath)
dispatch({ type: "SET_SESSION", payload: { session } });
router.replace(router.asPath);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session])
}, [assignment, exams, session]);
return (
<>
@@ -104,10 +130,15 @@ const Page: React.FC<Props> = ({ user, assignment, exams = [], destinationURL =
<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} />
<ExamPage
page="exams"
destination={destinationURL}
user={user}
hideSidebar={!!assignment || !!storeAssignment}
/>
</>
);
}
};
//Page.whyDidYouRender = true;
export default Page;

View File

@@ -21,79 +21,87 @@ 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 (!assignment) return redirect("/exam")
if (!["admin", "developer"].includes(user.type) && !assignment.assignees.includes(user.id)) return redirect("/exercises")
const exams = await getExamsByIds(uniqBy(assignment.exams, "id"))
const session = await getSessionByAssignment(assignmentID)
const assignment = await getAssignment(assignmentID);
if (!assignment) return redirect("/exam");
if (
filterBy(assignment.results, 'user', user.id) ||
!["admin", "developer"].includes(user.type) &&
!assignment.assignees.includes(user.id)
)
return redirect("/exercises");
if (
filterBy(assignment.results, "user", user.id) ||
moment(assignment.startDate).isBefore(moment()) ||
moment(assignment.endDate).isAfter(moment())
)
return redirect("/exam")
return redirect("/exam");
const [exams, session] = await Promise.all([
getExamsByIds(uniqBy(assignment.exams, "id")),
getSessionByAssignment(assignmentID),
]);
return {
props: serialize({ user, assignment, exams, session })
}
props: serialize({ user, assignment, exams, session }),
};
}
return {
props: serialize({ user }),
};
}, sessionOptions);
},
sessionOptions
);
interface Props {
user: User;
assignment?: Assignment
exams?: Exam[]
session?: Session
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: {
type: "INIT_EXAM",
payload: {
exams: exams.sort(sortByModule),
modules: exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
assignment
}
})
assignment,
},
});
router.replace(router.asPath)
router.replace(router.asPath);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session])
}, [assignment, exams, session]);
useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
dispatch({ type: "SET_SESSION", payload: { session } });
router.replace(router.asPath)
router.replace(router.asPath);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session])
}, [assignment, exams, session]);
return (
<>

View File

@@ -17,23 +17,24 @@ import ExamEditor from "@/components/ExamEditor";
import {mapBy, redirect, serialize} from "@/utils";
import {requestUser} from "@/utils/api";
import {Module} from "@/interfaces";
import { getExam, } from "@/utils/exams.be";
import {getExam} from "@/utils/exams.be";
import {Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise} from "@/interfaces/exam";
import {useEffect, useState} from "react";
import {getEntitiesWithRoles} from "@/utils/entities.be";
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")
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,
@@ -41,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 (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 }) {
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(() => {
@@ -71,11 +89,11 @@ 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 () => {
@@ -96,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
}, []);
@@ -173,8 +204,10 @@ export default function Generation({ id, user, exam, examModule, permissions }:
<RadioGroup
value={currentModule}
onChange={(currentModule) => updateRoot({currentModule})}
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
{[...MODULE_ARRAY].filter(m => permissions[m]).map((x) => (
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
@@ -209,7 +242,7 @@ export default function Generation({ id, user, exam, examModule, permissions }:
))}
</RadioGroup>
</div>
<ExamEditor levelParts={examLevelParts} />
<ExamEditor levelParts={examLevelParts} entitiesAllowEditPrivacy={entitiesAllowEditPrivacy} />
</>
)}
</>

View File

@@ -4,7 +4,7 @@ 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";
@@ -12,14 +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 { 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";
@@ -28,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";
@@ -53,32 +51,59 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!checkAccess(user, ["admin", "developer", "student"]))
return redirect("/");
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(entityIDS);
const assignments = await getAssignmentsByAssignee(user.id, {
const assignments = (await getAssignmentsByAssignee(
user.id,
{
archived: { $ne: true },
});
const sessions = await getSessionsByUser(user.id, 0, {
},
{
_id: 0,
id: 1,
name: 1,
startDate: 1,
endDate: 1,
exams: 1,
results: 1,
},
{
sort: { startDate: 1 },
}
)) as Assignment[];
const sessions = await getSessionsByUser(
user.id,
0,
{
"assignment.id": { $in: mapBy(assignments, "id") },
});
},
{
_id: 0,
id: 1,
assignment: 1,
}
);
const examIDs = uniqBy(
assignments.flatMap((a) =>
filterBy(a.exams, "assignee", user.id).map(
(e: any) => ({
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 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");
@@ -109,11 +134,12 @@ export default function OfficialExam({
});
if (assignmentExams.every((x) => !!x)) {
const sortedAssignmentExams = assignmentExams.sort(sortByModule);
dispatch({
type: "INIT_EXAM",
payload: {
exams: assignmentExams.sort(sortByModule),
modules: mapBy(assignmentExams.sort(sortByModule), "module"),
exams: sortedAssignmentExams,
modules: mapBy(sortedAssignmentExams, "module"),
assignment,
},
});
@@ -144,12 +170,16 @@ export default function OfficialExam({
[assignments]
);
const assignmentSessions = useMemo(
() =>
sessions.filter((s) =>
mapBy(studentAssignments, "id").includes(s.assignment?.id || "")
),
[sessions, studentAssignments]
const assignmentSessions = useMemo(() => {
const studentAssignmentsIDs = mapBy(studentAssignments, "id");
return sessions.filter((s) =>
studentAssignmentsIDs.includes(s.assignment?.id || "")
);
}, [sessions, studentAssignments]);
const entityLabels = useMemo(
() => mapBy(entities, "label")?.join(","),
[entities]
);
return (
@@ -167,7 +197,7 @@ export default function OfficialExam({
<>
{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>
<b>{entityLabels}</b>
</div>
)}
@@ -191,9 +221,7 @@ export default function OfficialExam({
<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) => (
{studentAssignments.map((a) => (
<AssignmentCard
key={a.id}
assignment={a}

View File

@@ -31,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";

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

@@ -18,6 +18,7 @@ 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;
@@ -40,31 +41,25 @@ export const getServerSideProps = withIronSessionSsr(
if (!params?.id) return redirect("/permissions");
// Fetch data from external API
const permission: Permission = await getPermissionDoc(params.id as string);
const allUserData: User[] = await getUsers();
const groups = await getGroups();
const [permission, users, groups] = await Promise.all([
getPermissionDoc(params.id as string),
getUsers({}, 0, {}, { _id: 0, id: 1, name: 1, type: 1 }),
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) =>
userGroups.flatMap((y) => y.participants).includes(x.admin)
)
? groups.filter((x) => userGroupsParticipants.includes(x.admin))
: groups;
const users = allUserData.map((u) => ({
id: u.id,
name: u.name,
type: u.type,
})) as BasicUser[];
const filteredGroupsParticipants = filteredGroups.flatMap(
(g) => g.participants
);
const filteredUsers = ["mastercorporate", "corporate"].includes(user.type)
? users.filter((u) =>
filteredGroups.flatMap((g) => g.participants).includes(u.id)
)
? users.filter((u) => filteredGroupsParticipants.includes(u.id))
: users;
// const res = await fetch("api/permissions");
@@ -158,12 +153,14 @@ export default function Page(props: Props) {
<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,
}))}
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>
@@ -195,9 +192,8 @@ export default function Page(props: Props) {
<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) => {
{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"
@@ -208,6 +204,7 @@ export default function Page(props: Props) {
</span>
</div>
);
return null;
})}
</div>
</div>

View File

@@ -53,23 +53,23 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/");
const linkedCorporate = (await getUserCorporate(user.id)) || null;
const groups = (
await getParticipantGroups(user.id, { _id: 0, admin: 1 })
).map((group) => group.admin);
const referralAgent =
const [linkedCorporate, groups, referralAgent] = await Promise.all([
getUserCorporate(user.id) || null,
getParticipantGroups(user.id, { _id: 0, group: 1 }),
user.type === "corporate" && user.corporateInformation.referralAgent
? await getUser(user.corporateInformation.referralAgent, {
? getUser(user.corporateInformation.referralAgent, {
_id: 0,
name: 1,
email: 1,
demographicInformation: 1,
})
: null;
: null,
]);
const groupsAdmin = groups.map((group) => group.admin);
const hasBenefitsFromUniversity =
(await countUsers({
id: { $in: groups },
id: { $in: groupsAdmin },
type: "corporate",
})) > 0;

View File

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

View File

@@ -13,7 +13,13 @@ import { checkAccess, getTypesOfUser } from "@/utils/permissions";
import { useState } from "react";
import Modal from "@/components/Modal";
import IconCard from "@/components/IconCard";
import { BsCode, BsCodeSquare, BsGearFill, BsPeopleFill, BsPersonFill } from "react-icons/bs";
import {
BsCode,
BsCodeSquare,
BsGearFill,
BsPeopleFill,
BsPersonFill,
} from "react-icons/bs";
import UserCreator from "./(admin)/UserCreator";
import CorporateGradingSystem from "./(admin)/CorporateGradingSystem";
import { CEFR_STEPS } from "@/resources/grading";
@@ -26,26 +32,55 @@ import { mapBy, serialize, redirect } from "@/utils";
import { EntityWithRoles } from "@/interfaces/entity";
import { requestUser } from "@/utils/api";
import { isAdmin } from "@/utils/users";
import { getGradingSystemByEntities, getGradingSystemByEntity } from "@/utils/grading.be";
import {
getGradingSystemByEntities,
getGradingSystemByEntity,
} from "@/utils/grading.be";
import { Grading } from "@/interfaces";
import { useRouter } from "next/router";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
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) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]))
return redirect("/")
const permissions = await getUserPermissions(user.id);
const entities = isAdmin(user) ? await getEntitiesWithRoles() : await getEntitiesWithRoles(mapBy(user.entities, 'id'))
const allUsers = await getUsers()
const gradingSystems = await getGradingSystemByEntities(mapBy(entities, 'id'))
const entitiesGrading = entities.map(e => gradingSystems.find(g => g.entity === e.id) || { entity: e.id, steps: CEFR_STEPS })
if (
shouldRedirectHome(user) ||
!checkAccess(user, [
"admin",
"developer",
"corporate",
"teacher",
"mastercorporate",
])
)
return redirect("/");
const [permissions, entities, allUsers] = await Promise.all([
getUserPermissions(user.id),
isAdmin(user)
? await getEntitiesWithRoles()
: await getEntitiesWithRoles(mapBy(user.entities, "id")),
getUsers(),
]);
const gradingSystems = await getGradingSystemByEntities(
mapBy(entities, "id")
);
const entitiesGrading = entities.map(
(e) =>
gradingSystems.find((g) => g.entity === e.id) || {
entity: e.id,
steps: CEFR_STEPS,
}
);
return {
props: serialize({ user, permissions, entities, allUsers, entitiesGrading }),
props: serialize({
user,
permissions,
entities,
allUsers,
entitiesGrading,
}),
};
}, sessionOptions);
@@ -53,19 +88,45 @@ interface Props {
user: User;
permissions: PermissionType[];
entities: EntityWithRoles[];
allUsers: User[]
entitiesGrading: Grading[]
allUsers: User[];
entitiesGrading: Grading[];
}
export default function Admin({ user, entities, permissions, allUsers, entitiesGrading }: Props) {
export default function Admin({
user,
entities,
permissions,
allUsers,
entitiesGrading,
}: Props) {
const [modalOpen, setModalOpen] = useState<string>();
const router = useRouter()
const router = useRouter();
const entitiesAllowCreateUser = useAllowedEntities(user, entities, 'create_user')
const entitiesAllowCreateUsers = useAllowedEntities(user, entities, 'create_user_batch')
const entitiesAllowCreateCode = useAllowedEntities(user, entities, 'create_code')
const entitiesAllowCreateCodes = useAllowedEntities(user, entities, 'create_code_batch')
const entitiesAllowEditGrading = useAllowedEntities(user, entities, 'edit_grading_system')
const entitiesAllowCreateUser = useAllowedEntities(
user,
entities,
"create_user"
);
const entitiesAllowCreateUsers = useAllowedEntities(
user,
entities,
"create_user_batch"
);
const entitiesAllowCreateCode = useAllowedEntities(
user,
entities,
"create_code"
);
const entitiesAllowCreateCodes = useAllowedEntities(
user,
entities,
"create_code_batch"
);
const entitiesAllowEditGrading = useAllowedEntities(
user,
entities,
"edit_grading_system"
);
return (
<>
@@ -80,7 +141,11 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
</Head>
<ToastContainer />
<>
<Modal isOpen={modalOpen === "batchCreateUser"} onClose={() => setModalOpen(undefined)} maxWidth="max-w-[85%]">
<Modal
isOpen={modalOpen === "batchCreateUser"}
onClose={() => setModalOpen(undefined)}
maxWidth="max-w-[85%]"
>
<BatchCreateUser
user={user}
entities={entitiesAllowCreateUser}
@@ -88,7 +153,10 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
onFinish={() => setModalOpen(undefined)}
/>
</Modal>
<Modal isOpen={modalOpen === "batchCreateCode"} onClose={() => setModalOpen(undefined)}>
<Modal
isOpen={modalOpen === "batchCreateCode"}
onClose={() => setModalOpen(undefined)}
>
<BatchCodeGenerator
entities={entitiesAllowCreateCodes}
user={user}
@@ -97,7 +165,10 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
onFinish={() => setModalOpen(undefined)}
/>
</Modal>
<Modal isOpen={modalOpen === "createCode"} onClose={() => setModalOpen(undefined)}>
<Modal
isOpen={modalOpen === "createCode"}
onClose={() => setModalOpen(undefined)}
>
<CodeGenerator
entities={entitiesAllowCreateCode}
user={user}
@@ -105,7 +176,10 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
onFinish={() => setModalOpen(undefined)}
/>
</Modal>
<Modal isOpen={modalOpen === "createUser"} onClose={() => setModalOpen(undefined)}>
<Modal
isOpen={modalOpen === "createUser"}
onClose={() => setModalOpen(undefined)}
>
<UserCreator
user={user}
entities={entitiesAllowCreateUsers}
@@ -114,7 +188,10 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
onFinish={() => setModalOpen(undefined)}
/>
</Modal>
<Modal isOpen={modalOpen === "gradingSystem"} onClose={() => setModalOpen(undefined)}>
<Modal
isOpen={modalOpen === "gradingSystem"}
onClose={() => setModalOpen(undefined)}
>
<CorporateGradingSystem
user={user}
entitiesGrading={entitiesGrading}
@@ -125,7 +202,12 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
<section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8">
<ExamLoader />
{checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && (
{checkAccess(
user,
getTypesOfUser(["teacher"]),
permissions,
"viewCodes"
) && (
<div className="w-full grid grid-cols-2 gap-4">
<IconCard
Icon={BsCode}
@@ -159,7 +241,12 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
onClick={() => setModalOpen("batchCreateUser")}
disabled={entitiesAllowCreateUsers.length === 0}
/>
{checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && (
{checkAccess(user, [
"admin",
"corporate",
"developer",
"mastercorporate",
]) && (
<IconCard
Icon={BsGearFill}
label="Grading System"

View File

@@ -28,67 +28,104 @@ import Head from "next/head";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import ReactDatePicker from "react-datepicker";
import {
BsBank,
BsChevronLeft,
BsX,
} from "react-icons/bs";
import { BsBank, BsChevronLeft, BsX } from "react-icons/bs";
interface Props {
user: User;
students: StudentUser[];
entities: EntityWithRoles[];
assignments: Assignment[];
sessions: Session[]
exams: Exam[]
sessions: Session[];
exams: Exam[];
}
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");
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const allowedEntities = findAllowedEntities(user, entities, 'view_entity_statistics')
const entities = await getEntitiesWithRoles(
isAdmin(user) ? undefined : entityIDS
);
const allowedEntities = findAllowedEntities(
user,
entities,
"view_entity_statistics"
);
if (allowedEntities.length === 0) return redirect("/")
if (allowedEntities.length === 0) return redirect("/");
const studentsAllowedEntities = findAllowedEntities(user, entities, 'view_students')
const students = await getEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" })
const studentsAllowedEntities = findAllowedEntities(
user,
entities,
"view_students"
);
const assignments = await getEntitiesAssignments(mapBy(entities, "id"));
const sessions = await getSessionsByAssignments(mapBy(assignments, 'id'))
const exams = await getExamsByIds(assignments.flatMap(a => a.exams))
const [students, assignments] = await Promise.all([
getEntitiesUsers(mapBy(studentsAllowedEntities, "id"), { type: "student" }),
getEntitiesAssignments(mapBy(entities, "id")),
]);
return { props: serialize({ user, students, entities: allowedEntities, assignments, sessions, exams }) };
const [sessions, exams] = await Promise.all([
getSessionsByAssignments(mapBy(assignments, "id")),
getExamsByIds(assignments.flatMap((a) => a.exams)),
]);
return {
props: serialize({
user,
students,
entities: allowedEntities,
assignments,
sessions,
exams,
}),
};
}, sessionOptions);
interface Item {
student: StudentUser
result?: AssignmentResult
assignment: Assignment
exams: Exam[]
entity: Entity
session?: Session
student: StudentUser;
result?: AssignmentResult;
assignment: Assignment;
exams: Exam[];
entity: Entity;
session?: Session;
}
const columnHelper = createColumnHelper<Item>();
export default function Statistical({ user, students, entities, assignments, sessions, exams }: Props) {
export default function Statistical({
user,
students,
entities,
assignments,
sessions,
exams,
}: Props) {
const [startDate, setStartDate] = useState<Date>(new Date());
const [endDate, setEndDate] = useState<Date | null>(moment().add(1, 'month').toDate());
const [selectedEntities, setSelectedEntities] = useState<string[]>([])
const [isDownloading, setIsDownloading] = useState(false)
const [endDate, setEndDate] = useState<Date | null>(
moment().add(1, "month").toDate()
);
const [selectedEntities, setSelectedEntities] = useState<string[]>([]);
const [isDownloading, setIsDownloading] = useState(false);
const entitiesAllowDownload = useAllowedEntities(user, entities, 'download_statistics_report')
const entitiesAllowDownload = useAllowedEntities(
user,
entities,
"download_statistics_report"
);
const resetDateRange = () => {
const orderedAssignments = orderBy(assignments, ['startDate'], ['asc'])
setStartDate(moment(orderedAssignments.shift()?.startDate || "2024-01-01T00:00:01.986Z").toDate())
setEndDate(moment().add(1, 'month').toDate())
}
const orderedAssignments = orderBy(assignments, ["startDate"], ["asc"]);
setStartDate(
moment(
orderedAssignments.shift()?.startDate || "2024-01-01T00:00:01.986Z"
).toDate()
);
setEndDate(moment().add(1, "month").toDate());
};
useEffect(resetDateRange, [assignments])
useEffect(resetDateRange, [assignments]);
const updateDateRange = (dates: [Date, Date | null]) => {
const [start, end] = dates;
@@ -96,75 +133,134 @@ export default function Statistical({ user, students, entities, assignments, ses
setEndDate(end);
};
const toggleEntity = (id: string) => setSelectedEntities(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
const toggleEntity = (id: string) =>
setSelectedEntities((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
const renderAssignmentResolution = (entityID: string) => {
const entityAssignments = filterBy(assignments, 'entity', entityID)
const total = entityAssignments.reduce((acc, curr) => acc + curr.assignees.length, 0)
const results = entityAssignments.reduce((acc, curr) => acc + curr.results.length, 0)
const entityAssignments = filterBy(assignments, "entity", entityID);
const total = entityAssignments.reduce(
(acc, curr) => acc + curr.assignees.length,
0
);
const results = entityAssignments.reduce(
(acc, curr) => acc + curr.results.length,
0
);
return `${results}/${total}`
}
return `${results}/${total}`;
};
const totalAssignmentResolution = useMemo(() => {
const total = assignments.reduce((acc, curr) => acc + curr.assignees.length, 0)
const results = assignments.reduce((acc, curr) => acc + curr.results.length, 0)
const total = assignments.reduce(
(acc, curr) => acc + curr.assignees.length,
0
);
const results = assignments.reduce(
(acc, curr) => acc + curr.results.length,
0
);
return { results, total }
}, [assignments])
return { results, total };
}, [assignments]);
const filteredAssignments = useMemo(() => {
if (!startDate && !endDate) return assignments
const startDateFiltered = startDate ? assignments.filter(a => moment(a.startDate).isSameOrAfter(moment(startDate))) : assignments
return endDate ? startDateFiltered.filter(a => moment(a.endDate).isSameOrBefore(moment(endDate))) : startDateFiltered
}, [startDate, endDate, assignments])
const data: Item[] = useMemo(() =>
filteredAssignments.filter(a => selectedEntities.includes(a.entity || "")).flatMap(a => a.assignees.map(x => {
const result = findBy(a.results, 'user', x)
const student = findBy(students, 'id', x)
const entity = findBy(entities, 'id', a.entity)
const assignmentExams = exams.filter(e => a.exams.map(x => `${x.id}_${x.module}`).includes(`${e.id}_${e.module}`))
const session = sessions.find(s => s.assignment?.id === a.id && s.user === x)
if (!student) return undefined
return { student, result, assignment: a, exams: assignmentExams, session, entity }
})).filter(x => !!x) as Item[],
[students, selectedEntities, filteredAssignments, exams, sessions, entities]
if (!startDate && !endDate) return assignments;
const startDateFiltered = startDate
? assignments.filter((a) =>
moment(a.startDate).isSameOrAfter(moment(startDate))
)
: assignments;
return endDate
? startDateFiltered.filter((a) =>
moment(a.endDate).isSameOrBefore(moment(endDate))
)
: startDateFiltered;
}, [startDate, endDate, assignments]);
const sortedData: Item[] = useMemo(() => data.sort((a, b) => {
const aTotalScore = a.result?.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.correct, 0) || 0
const bTotalScore = b.result?.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.correct, 0) || 0
const data: Item[] = useMemo(
() =>
filteredAssignments
.filter((a) => selectedEntities.includes(a.entity || ""))
.flatMap((a) =>
a.assignees.map((x) => {
const result = findBy(a.results, "user", x);
const student = findBy(students, "id", x);
const entity = findBy(entities, "id", a.entity);
const assignmentExams = exams.filter((e) =>
a.exams
.map((x) => `${x.id}_${x.module}`)
.includes(`${e.id}_${e.module}`)
);
const session = sessions.find(
(s) => s.assignment?.id === a.id && s.user === x
);
return bTotalScore - aTotalScore
}), [data])
if (!student) return undefined;
return {
student,
result,
assignment: a,
exams: assignmentExams,
session,
entity,
};
})
)
.filter((x) => !!x) as Item[],
[students, selectedEntities, filteredAssignments, exams, sessions, entities]
);
const sortedData: Item[] = useMemo(
() =>
data.sort((a, b) => {
const aTotalScore =
a.result?.stats
.filter((x) => !x.isPractice)
.reduce((acc, curr) => acc + curr.score.correct, 0) || 0;
const bTotalScore =
b.result?.stats
.filter((x) => !x.isPractice)
.reduce((acc, curr) => acc + curr.score.correct, 0) || 0;
return bTotalScore - aTotalScore;
}),
[data]
);
const downloadExcel = async () => {
setIsDownloading(true)
setIsDownloading(true);
const request = await axios.post("/api/statistical", {
entities: entities.filter(e => selectedEntities.includes(e.id)),
const request = await axios.post(
"/api/statistical",
{
entities: entities.filter((e) => selectedEntities.includes(e.id)),
items: data,
assignments: filteredAssignments,
startDate,
endDate
}, {
responseType: 'blob'
})
endDate,
},
{
responseType: "blob",
}
);
const href = URL.createObjectURL(request.data)
const link = document.createElement('a');
const href = URL.createObjectURL(request.data);
const link = document.createElement("a");
link.href = href;
link.setAttribute('download', `statistical_${new Date().toISOString()}.xlsx`);
link.setAttribute(
"download",
`statistical_${new Date().toISOString()}.xlsx`
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(href);
setIsDownloading(false)
}
setIsDownloading(false);
};
const columns = [
columnHelper.accessor("student.name", {
@@ -194,19 +290,26 @@ export default function Statistical({ user, students, entities, assignments, ses
columnHelper.accessor("result", {
header: "Progress",
cell: (info) => {
const student = info.row.original.student
const session = info.row.original.session
const student = info.row.original.student;
const session = info.row.original.session;
if (!student.lastLogin) return <span className="text-mti-red-dark">Never logged in</span>
if (info.getValue()) return <span className="text-mti-green font-semibold">Submitted</span>
if (!session) return <span className="text-mti-rose">Not started</span>
if (!student.lastLogin)
return <span className="text-mti-red-dark">Never logged in</span>;
if (info.getValue())
return (
<span className="text-mti-green font-semibold">Submitted</span>
);
if (!session) return <span className="text-mti-rose">Not started</span>;
return <span className="font-semibold">
{capitalize(session.exam?.module || "")} Module, Part {session.partIndex + 1}, Exercise {session.exerciseIndex + 1}
return (
<span className="font-semibold">
{capitalize(session.exam?.module || "")} Module, Part{" "}
{session.partIndex + 1}, Exercise {session.exerciseIndex + 1}
</span>
);
},
})
]
}),
];
return (
<>
@@ -224,13 +327,18 @@ export default function Statistical({ user, students, entities, assignments, ses
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Link href="/dashboard" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<Link
href="/dashboard"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">Statistical</h2>
</div>
<Checkbox
onChange={value => setSelectedEntities(value ? mapBy(entities, 'id') : [])}
onChange={(value) =>
setSelectedEntities(value ? mapBy(entities, "id") : [])
}
isChecked={selectedEntities.length === entities.length}
>
Select All
@@ -241,13 +349,14 @@ export default function Statistical({ user, students, entities, assignments, ses
<section className="flex flex-col gap-3">
<div className="w-full flex items-center justify-between gap-4 flex-wrap">
{entities.map(entity => (
{entities.map((entity) => (
<button
onClick={() => toggleEntity(entity.id)}
className={clsx(
"flex flex-col items-center justify-between gap-3 border-2 drop-shadow rounded-xl bg-white p-8 px-2 w-48 h-52",
"transition ease-in-out duration-300 hover:shadow-xl hover:border-mti-purple",
selectedEntities.includes(entity.id) && "border-mti-purple text-mti-purple"
selectedEntities.includes(entity.id) &&
"border-mti-purple text-mti-purple"
)}
key={entity.id}
>
@@ -268,7 +377,7 @@ export default function Statistical({ user, students, entities, assignments, ses
className={clsx(
"p-6 px-12 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
"transition duration-300 ease-in-out"
)}
dateFormat="dd/MM/yyyy"
selectsRange
@@ -278,13 +387,17 @@ export default function Statistical({ user, students, entities, assignments, ses
endDate={endDate}
/>
{startDate !== null && endDate !== null && (
<button onClick={resetDateRange} className="transition ease-in-out duration-300 rounded-full p-2 hover:bg-mti-gray-cool/10">
<button
onClick={resetDateRange}
className="transition ease-in-out duration-300 rounded-full p-2 hover:bg-mti-gray-cool/10"
>
<BsX size={24} />
</button>
)}
</div>
<span className="font-semibold text-lg pr-1">
Total: {totalAssignmentResolution.results} / {totalAssignmentResolution.total}
Total: {totalAssignmentResolution.results} /{" "}
{totalAssignmentResolution.total}
</span>
</div>
</section>
@@ -293,13 +406,21 @@ export default function Statistical({ user, students, entities, assignments, ses
<Table
columns={columns}
data={sortedData}
searchFields={[["student", "name"], ["student", "email"], ["student", "studentID"], ["exams", "id"], ["assignment", "name"]]}
searchFields={[
["student", "name"],
["student", "email"],
["student", "studentID"],
["exams", "id"],
["assignment", "name"],
]}
searchPlaceholder="Search by student, assignment or exam..."
onDownload={entitiesAllowDownload.length > 0 ? downloadExcel : undefined}
onDownload={
entitiesAllowDownload.length > 0 ? downloadExcel : undefined
}
isDownloadLoading={isDownloading}
/>
)}
</>
</>
)
);
}

View File

@@ -32,10 +32,7 @@ import { capitalize } from "lodash";
import { Module } from "@/interfaces";
import ProgressBar from "@/components/Low/ProgressBar";
import { calculateBandScore } from "@/utils/score";
import {
MODULE_ARRAY,
sortByModule,
} from "@/utils/moduleUtils";
import { MODULE_ARRAY, sortByModule } from "@/utils/moduleUtils";
import { Chart } from "react-chartjs-2";
import DatePicker from "react-datepicker";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
@@ -45,7 +42,6 @@ import { Stat, User } from "@/interfaces/user";
import { Divider } from "primereact/divider";
import Badge from "@/components/Low/Badge";
import { mapBy, redirect, serialize } from "@/utils";
import { getEntities } from "@/utils/entities.be";
import { checkAccess } from "@/utils/permissions";
import { EntityWithRoles } from "@/interfaces/entity";
import Select from "@/components/Low/Select";
@@ -69,19 +65,10 @@ const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"];
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/");
const entityIDs = mapBy(user.entities, "id");
const isAdmin = checkAccess(user, ["admin", "developer"]);
const entities = await getEntities(isAdmin ? undefined : entityIDs, {
id: 1,
label: 1,
});
return {
props: serialize({ user, entities, isAdmin }),
props: serialize({ user, isAdmin }),
};
}, sessionOptions);

View File

@@ -20,38 +20,55 @@ import ModuleBadge from "@/components/ModuleBadge";
import RecordFilter from "@/components/Medium/RecordFilter";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import { mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getEntitiesUsers } from "@/utils/users.be";
import { EntityWithRoles } from "@/interfaces/entity";
import { requestUser } from "@/utils/api";
import { checkAccess } 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("/")
const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(entityIDs)
const users = await getEntitiesUsers(entityIDs)
if (shouldRedirectHome(user)) return redirect("/");
const isAdmin = checkAccess(user, ["admin", "developer"]);
const entityIDs = mapBy(user.entities, "id");
const users = await getEntitiesUsers(entityIDs);
return {
props: serialize({user, users, entities}),
props: serialize({ user, users, isAdmin }),
};
}, sessionOptions);
const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[] }> = ({user, entities, users}) => {
const [recordUserId, setRecordTraining] = useRecordStore((state) => [state.selectedUser, state.setTraining]);
const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">();
const Training: React.FC<{
user: User;
entities: EntityWithRoles[];
users: User[];
isAdmin: boolean;
}> = ({ user, entities, isAdmin }) => {
const [recordUserId, setRecordTraining] = useRecordStore((state) => [
state.selectedUser,
state.setTraining,
]);
const [filter, setFilter] = useState<
"months" | "weeks" | "days" | "assignments"
>();
const [stats, setTrainingStats] = useTrainingContentStore((state) => [state.stats, state.setStats]);
const [isNewContentLoading, setIsNewContentLoading] = useState(stats.length != 0);
const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{[key: string]: ITrainingContent}>();
const [stats, setTrainingStats] = useTrainingContentStore((state) => [
state.stats,
state.setStats,
]);
const [isNewContentLoading, setIsNewContentLoading] = useState(
stats.length != 0
);
const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{
[key: string]: ITrainingContent;
}>();
const {data: trainingContent, isLoading: areRecordsLoading} = useFilterRecordsByUser<ITrainingContent[]>(
const { data: trainingContent, isLoading: areRecordsLoading } =
useFilterRecordsByUser<ITrainingContent[]>(
recordUserId || user?.id,
undefined,
"training",
"training"
);
useEffect(() => {
@@ -68,7 +85,10 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
useEffect(() => {
const postStats = async () => {
try {
const response = await axios.post<{id: string}>(`/api/training`, {userID: user.id, stats: stats});
const response = await axios.post<{ id: string }>(`/api/training`, {
userID: user.id,
stats: stats,
});
return response.data.id;
} catch (error) {
setIsNewContentLoading(false);
@@ -91,7 +111,9 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
router.push("/record");
};
const filterTrainingContentByDate = (trainingContent: {[key: string]: ITrainingContent}) => {
const filterTrainingContentByDate = (trainingContent: {
[key: string]: ITrainingContent;
}) => {
if (filter) {
const filterDate = moment()
.subtract({ [filter as string]: 1 })
@@ -99,7 +121,8 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
const filteredTrainingContent: { [key: string]: ITrainingContent } = {};
Object.keys(trainingContent).forEach((timestamp) => {
if (timestamp >= filterDate) filteredTrainingContent[timestamp] = trainingContent[timestamp];
if (timestamp >= filterDate)
filteredTrainingContent[timestamp] = trainingContent[timestamp];
});
return filteredTrainingContent;
}
@@ -133,18 +156,22 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
const trainingContentContainer = (timestamp: string) => {
if (!groupedByTrainingContent) return <></>;
const trainingContent: ITrainingContent = groupedByTrainingContent[timestamp];
const uniqueModules = [...new Set(trainingContent.exams.map((exam) => exam.module))];
const trainingContent: ITrainingContent =
groupedByTrainingContent[timestamp];
const uniqueModules = [
...new Set(trainingContent.exams.map((exam) => exam.module)),
];
return (
<>
<div
key={uuidv4()}
className={clsx(
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden"
)}
onClick={() => selectTrainingContent(trainingContent)}
role="button">
role="button"
>
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
<span className="font-medium">{formatTimestamp(timestamp)}</span>
@@ -181,22 +208,33 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
<span className="loading loading-infinity w-32 bg-mti-green-light" />
{isNewContentLoading && (
<span className="text-center text-2xl font-bold text-mti-green-light">Assessing your exams, please be patient...</span>
<span className="text-center text-2xl font-bold text-mti-green-light">
Assessing your exams, please be patient...
</span>
)}
</div>
) : (
<>
<RecordFilter entities={entities} user={user} filterState={{filter: filter, setFilter: setFilter}} assignments={false}>
<RecordFilter
entities={entities}
user={user}
isAdmin={isAdmin}
filterState={{ filter: filter, setFilter: setFilter }}
assignments={false}
>
{user.type === "student" && (
<>
<div className="flex items-center">
<div className="font-semibold text-2xl">Generate New Training Material</div>
<div className="font-semibold text-2xl">
Generate New Training Material
</div>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
"transition duration-300 ease-in-out",
"transition duration-300 ease-in-out"
)}
onClick={handleNewTrainingContent}>
onClick={handleNewTrainingContent}
>
<FaPlus />
</button>
</div>
@@ -205,12 +243,18 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
</RecordFilter>
{trainingContent.length == 0 && (
<div className="flex flex-grow justify-center items-center">
<span className="font-semibold ml-1">No training content to display...</span>
<span className="font-semibold ml-1">
No training content to display...
</span>
</div>
)}
{!areRecordsLoading && groupedByTrainingContent && Object.keys(groupedByTrainingContent).length > 0 && (
{!areRecordsLoading &&
groupedByTrainingContent &&
Object.keys(groupedByTrainingContent).length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-3 w-full gap-4 xl:gap-6">
{Object.keys(filterTrainingContentByDate(groupedByTrainingContent))
{Object.keys(
filterTrainingContentByDate(groupedByTrainingContent)
)
.sort((a, b) => parseInt(b) - parseInt(a))
.map(trainingContentContainer)}
</div>

View File

@@ -17,21 +17,26 @@ import { requestUser } from "@/utils/api";
import { redirect } from "@/utils";
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");
const entityIDs = mapBy(user.entities, 'id')
const entityIDs = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", 'developer']) ? undefined : entityIDs)
const allowedEntities = findAllowedEntities(user, entities, "view_student_performance")
const entities = await getEntitiesWithRoles(
checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs
);
const allowedEntities = findAllowedEntities(
user,
entities,
"view_student_performance"
);
if (allowedEntities.length === 0) return redirect("/")
if (allowedEntities.length === 0) return redirect("/");
const students = await (checkAccess(user, ["admin", 'developer'])
? getUsers({ type: 'student' })
: getEntitiesUsers(mapBy(allowedEntities, 'id'), { type: 'student' })
)
const groups = await getParticipantsGroups(mapBy(students, 'id'))
const students = await (checkAccess(user, ["admin", "developer"])
? getUsers({ type: "student" })
: getEntitiesUsers(mapBy(allowedEntities, "id"), { type: "student" }));
const groups = await getParticipantsGroups(mapBy(students, "id"));
return {
props: serialize({ user, students, entities, groups }),
@@ -40,9 +45,9 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
interface Props {
user: User;
students: StudentUser[]
entities: Entity[]
groups: Group[]
students: StudentUser[];
entities: Entity[];
groups: Group[];
}
const StudentPerformance = ({ user, students, entities, groups }: Props) => {
@@ -53,7 +58,10 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
const performanceStudents = students.map((u) => ({
...u,
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
entitiesLabel: mapBy(u.entities, 'id').map((id) => entities.find((e) => e.id === id)?.label).filter((e) => !!e).join(', '),
entitiesLabel: mapBy(u.entities, "id")
.map((id) => entities.find((e) => e.id === id)?.label)
.filter((e) => !!e)
.join(", "),
}));
return (
@@ -73,12 +81,15 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
<div className="flex items-center gap-2">
<button
onClick={() => {
router.back()
router.back();
}}
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft />
</button>
<h2 className="font-bold text-2xl">Student Performance ({students.length})</h2>
<h2 className="font-bold text-2xl">
Student Performance ({students.length})
</h2>
</div>
<StudentPerformanceList items={performanceStudents} stats={stats} />
</>

View File

@@ -1,71 +1,77 @@
export type RolePermission =
"view_students" |
"view_teachers" |
"view_corporates" |
"view_mastercorporates" |
"edit_students" |
"edit_teachers" |
"edit_corporates" |
"edit_mastercorporates" |
"delete_students" |
"delete_teachers" |
"delete_corporates" |
"delete_mastercorporates" |
"generate_reading" |
"view_reading" |
"delete_reading" |
"generate_listening" |
"view_listening" |
"delete_listening" |
"generate_writing" |
"view_writing" |
"delete_writing" |
"generate_speaking" |
"view_speaking" |
"delete_speaking" |
"generate_level" |
"view_level" |
"delete_level" |
"view_classrooms" |
"create_classroom" |
"rename_classrooms" |
"add_to_classroom" |
"remove_from_classroom" |
"delete_classroom" |
"view_entities" |
"rename_entity" |
"add_to_entity" |
"remove_from_entity" |
"delete_entity" |
"view_entity_roles" |
"create_entity_role" |
"rename_entity_role" |
"edit_role_permissions" |
"assign_to_role" |
"delete_entity_role" |
"view_assignments" |
"create_assignment" |
"edit_assignment" |
"delete_assignment" |
"start_assignment" |
"archive_assignment" |
"view_entity_statistics" |
"create_user" |
"create_user_batch" |
"create_code" |
"create_code_batch" |
"view_code_list" |
"delete_code" |
"view_statistics" |
"download_statistics_report" |
"edit_grading_system" |
"view_student_performance" |
"upload_classroom" |
"download_user_list" |
"view_student_record" |
"download_student_record" |
"pay_entity" |
"view_payment_record"
| "view_students"
| "view_teachers"
| "view_corporates"
| "view_mastercorporates"
| "edit_students"
| "edit_teachers"
| "edit_corporates"
| "edit_mastercorporates"
| "delete_students"
| "delete_teachers"
| "delete_corporates"
| "delete_mastercorporates"
| "generate_reading"
| "view_reading"
| "delete_reading"
| "generate_listening"
| "view_listening"
| "delete_listening"
| "generate_writing"
| "view_writing"
| "delete_writing"
| "generate_speaking"
| "view_speaking"
| "delete_speaking"
| "generate_level"
| "view_level"
| "delete_level"
| "view_classrooms"
| "create_classroom"
| "rename_classrooms"
| "add_to_classroom"
| "remove_from_classroom"
| "delete_classroom"
| "view_entities"
| "rename_entity"
| "add_to_entity"
| "remove_from_entity"
| "delete_entity"
| "view_entity_roles"
| "create_entity_role"
| "rename_entity_role"
| "edit_role_permissions"
| "assign_to_role"
| "delete_entity_role"
| "view_assignments"
| "create_assignment"
| "edit_assignment"
| "delete_assignment"
| "start_assignment"
| "archive_assignment"
| "view_entity_statistics"
| "create_user"
| "create_user_batch"
| "create_code"
| "create_code_batch"
| "view_code_list"
| "delete_code"
| "view_statistics"
| "download_statistics_report"
| "edit_grading_system"
| "view_student_performance"
| "upload_classroom"
| "download_user_list"
| "view_student_record"
| "download_student_record"
| "pay_entity"
| "view_payment_record"
| "view_approval_workflows"
| "update_exam_privacy"
| "view_workflows"
| "configure_workflows"
| "edit_workflow"
| "delete_workflow";
export const DEFAULT_PERMISSIONS: RolePermission[] = [
"view_students",
@@ -74,8 +80,8 @@ export const DEFAULT_PERMISSIONS: RolePermission[] = [
"view_classrooms",
"view_entity_roles",
"view_statistics",
"download_statistics_report"
]
"download_statistics_report",
];
export const ADMIN_PERMISSIONS: RolePermission[] = [
"view_students",
@@ -144,5 +150,10 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [
"view_student_record",
"download_student_record",
"pay_entity",
"view_payment_record"
]
"view_payment_record",
"update_exam_privacy",
"configure_workflows",
"view_workflows",
"edit_workflow",
"delete_workflow",
];

View File

@@ -3,11 +3,11 @@ import { Action, rootReducer } from "./reducers";
import ExamEditorStore from "./types";
import {create} from "zustand";
const useExamEditorStore = create<
ExamEditorStore & {
dispatch: (action: Action) => void;
}>((set) => ({
}
>((set) => ({
title: "",
globalEdit: [],
currentModule: "reading",
@@ -17,9 +17,9 @@ const useExamEditorStore = create<
writing: defaultModuleSettings("writing", 60),
speaking: defaultModuleSettings("speaking", 14),
listening: defaultModuleSettings("listening", 30),
level: defaultModuleSettings("level", 60)
level: defaultModuleSettings("level", 60),
},
dispatch: (action) => set((state) => rootReducer(state, action))
dispatch: (action) => set((state) => rootReducer(state, action)),
}));
export default useExamEditorStore;

View File

@@ -16,6 +16,18 @@
}
}
.ellipsis {
@apply line-clamp-1 max-h-20 overflow-hidden text-ellipsis whitespace-break-spaces leading-relaxed [-webkit-box-orient:vertical] [display:-webkit-box];
}
.ellipsis-2 {
@apply line-clamp-2 max-h-20 overflow-hidden text-ellipsis whitespace-break-spaces leading-relaxed [-webkit-box-orient:vertical] [display:-webkit-box];
}
.ellipsis-3 {
@apply line-clamp-3 max-h-20 overflow-hidden text-ellipsis whitespace-break-spaces leading-relaxed [-webkit-box-orient:vertical] [display:-webkit-box];
}
.training-scrollbar::-webkit-scrollbar {
@apply w-1.5;
}
@@ -36,19 +48,38 @@
:root {
--max-width: 1100px;
--border-radius: 12px;
--font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
--font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
"Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
--foreground-rgb: 53, 51, 56;
--background-start-rgb: 245, 245, 245;
--background-end-rgb: 245, 245, 245;
--primary-glow: conic-gradient(from 180deg at 50% 50%, #16abff33 0deg, #0885ff33 55deg, #54d6ff33 120deg, #0071ff33 160deg, transparent 360deg);
--secondary-glow: radial-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));
--primary-glow: conic-gradient(
from 180deg at 50% 50%,
#16abff33 0deg,
#0885ff33 55deg,
#54d6ff33 120deg,
#0071ff33 160deg,
transparent 360deg
);
--secondary-glow: radial-gradient(
rgba(255, 255, 255, 1),
rgba(255, 255, 255, 0)
);
--tile-start-rgb: 239, 245, 249;
--tile-end-rgb: 228, 232, 233;
--tile-border: conic-gradient(#00000080, #00000040, #00000030, #00000020, #00000010, #00000010, #00000080);
--tile-border: conic-gradient(
#00000080,
#00000040,
#00000030,
#00000020,
#00000010,
#00000010,
#00000080
);
--callout-rgb: 238, 240, 241;
--callout-border-rgb: 172, 175, 176;
@@ -68,7 +99,8 @@ html {
max-width: 100vw;
overflow-x: hidden;
overflow-y: auto;
font-family: "Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif;
font-family: "Open Sans", system-ui, -apple-system, "Helvetica Neue",
sans-serif;
}
body {
@@ -76,12 +108,18 @@ body {
height: 100%;
max-width: 100vw;
overflow-x: hidden;
font-family: "Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif;
font-family: "Open Sans", system-ui, -apple-system, "Helvetica Neue",
sans-serif;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(to bottom, transparent, rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
a {

View File

@@ -0,0 +1,126 @@
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import client from "@/lib/mongodb";
import { ObjectId } from "mongodb";
const db = client.db(process.env.MONGODB_DB);
export const getApprovalWorkflows = async (collection: string, ids?: string[]) => {
return await db
.collection<ApprovalWorkflow>(collection)
.find(ids ? { _id: { $in: ids.map((id) => new ObjectId(id)) } } : {})
.toArray();
};
export const getApprovalWorkflow = async (collection: string, id: string) => {
return await db.collection<ApprovalWorkflow>(collection).findOne({ _id: new ObjectId(id) });
};
export const getApprovalWorkflowsByEntities = async (collection: string, ids: string[]) => {
return await db
.collection<ApprovalWorkflow>(collection)
.find({ entityId: { $in: ids } })
.toArray();
};
export const getApprovalWorkflowByFormIntaker = async (entityId: string, formIntakerId: string) => {
return await db.collection<ApprovalWorkflow>("configured-workflows").findOne({
entityId,
steps: {
$elemMatch: {
stepNumber: 1,
assignees: formIntakerId,
},
},
});
};
export const getApprovalWorkflowsByExamId = async (examId: string) => {
return await db
.collection<ApprovalWorkflow>("active-workflows")
.find({
examId,
status: { $in: ["pending"] }
})
.toArray();
};
export const getFormIntakersByEntity = async (entityId: string) => {
const results = await db
.collection<ApprovalWorkflow>("configured-workflows")
.aggregate([
// 1. Match workflows with the provided entityId
{ $match: { entityId } },
// 2. Unwind the steps array to process each step individually
{ $unwind: "$steps" },
// 3. Filter for the first step (you could also check for a "firstStep" flag if you prefer)
{ $match: { "steps.stepNumber": 1 } },
// 4. Unwind the assignees array so that each assignee is handled separately
{ $unwind: "$steps.assignees" },
// 5. Group by null (i.e. all documents) and add each assignee to a set to remove duplicates
{ $group: { _id: null, assignees: { $addToSet: "$steps.assignees" } } },
])
.toArray();
// Return the assignees if the aggregation found any; otherwise return an empty array
return results.length > 0 ? results[0].assignees : [];
};
export const createApprovalWorkflow = async (collection: string, workflow: ApprovalWorkflow) => {
const { _id, ...workflowWithoutId } = workflow as ApprovalWorkflow;
return await db.collection(collection).insertOne(workflowWithoutId);
};
export const createApprovalWorkflows = async (collection: string, workflows: ApprovalWorkflow[]) => {
if (workflows.length === 0) return;
const workflowsWithoutIds: ApprovalWorkflow[] = workflows.map(({ _id, ...wfs }) => wfs);
return await db.collection(collection).insertMany(workflowsWithoutIds);
};
export const updateApprovalWorkflow = async (collection: string, workflow: ApprovalWorkflow) => {
const { _id, ...workflowWithoutId } = workflow as ApprovalWorkflow;
return await db.collection(collection).replaceOne({ _id: new ObjectId(_id) }, workflowWithoutId);
};
export const updateApprovalWorkflows = async (collection: string, workflows: ApprovalWorkflow[]) => {
const bulkOperations = workflows.map((workflow) => {
const { _id, ...workflowWithoutId } = workflow;
return {
replaceOne: {
filter: { _id: new ObjectId(_id) },
replacement: workflowWithoutId,
},
};
});
return await db.collection(collection).bulkWrite(bulkOperations);
};
export const deleteApprovalWorkflow = async (collection: string, id: string) => {
return await db.collection(collection).deleteOne({ _id: new ObjectId(id) });
};
export const replaceApprovalWorkflowsByEntities = async (workflows: ApprovalWorkflow[], entityIds: string[]) => {
// 1. Keep track of the _id values of all workflows we want to end up with
const finalIds = new Set<string>();
// 2. Process incoming workflows
for (const workflow of workflows) {
if (workflow._id) {
// Replace the existing ones
await updateApprovalWorkflow("configured-workflows", workflow);
finalIds.add(workflow._id.toString());
} else {
// Insert if no _id
const insertResult = await createApprovalWorkflow("configured-workflows", workflow);
finalIds.add(insertResult.insertedId.toString());
}
}
// 3. Delete any existing workflow (within these entityIds) that wasn't in the final list
await db.collection("configured-workflows").deleteMany({
_id: {
$nin: Array.from(finalIds).map((id) => new ObjectId(id)),
},
entityId: { $in: entityIds },
});
};

View File

@@ -19,8 +19,8 @@ export const getAssignmentsByAssigner = async (id: string, startDate?: Date, end
return await db.collection("assignments").find<Assignment>(query).toArray();
};
export const getAssignments = async () => {
return await db.collection("assignments").find<Assignment>({}).toArray();
export const getAssignments = async (projection = {}) => {
return await db.collection("assignments").find<Assignment>({}, { projection }).toArray();
};
export const getAssignment = async (id: string) => {

View File

@@ -9,7 +9,6 @@ const db = client.db(process.env.MONGODB_DB);
export const getEntityWithRoles = async (id: string): Promise<EntityWithRoles | undefined> => {
const entity = await getEntity(id);
if (!entity) return undefined;
const roles = await getRolesByEntity(id);
return { ...entity, roles };
};

View File

@@ -0,0 +1,84 @@
import { Exam } from "@/interfaces/exam";
import { diff, Diff } from "deep-diff";
const EXCLUDED_FIELDS = new Set(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private"]);
export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[] {
const differences = diff(oldExam, newExam) || [];
return differences.map((change) => formatDifference(change)).filter(Boolean) as string[];
}
function formatDifference(change: Diff<any, any>): string | undefined {
if (!change.path) {
return;
}
if (change.path.some((segment) => EXCLUDED_FIELDS.has(segment))) {
return;
}
// Convert path array to something human-readable
const pathString = change.path.join(" \u2192 "); // e.g. "parts → 0 → exercises → 1 → prompt"
switch (change.kind) {
case "N":
// A new property/element was added
return `\u{2022} Added \`${pathString}\` with value: ${formatValue(change.rhs)}`;
case "D":
// A property/element was deleted
return `\u{2022} Removed \`${pathString}\` which had value: ${formatValue(change.lhs)}`;
case "E":
// A property/element was edited
return `\u{2022} Changed \`${pathString}\` from ${formatValue(change.lhs)} to ${formatValue(change.rhs)}`;
case "A":
// An array change; change.item describes what happened at array index change.index
return formatArrayChange(change);
default:
return;
}
}
function formatArrayChange(change: Diff<any, any>): string | undefined {
if (!change.path) return;
if (change.path.some((segment) => EXCLUDED_FIELDS.has(segment))) {
return;
}
const pathString = change.path.join(" \u2192 ");
const arrayChange = (change as any).item;
const idx = (change as any).index;
if (!arrayChange) return;
switch (arrayChange.kind) {
case "N":
return `\u{2022} Added an item at index [${idx}] in \`${pathString}\`: ${formatValue(arrayChange.rhs)}`;
case "D":
return `\u{2022} Removed an item at index [${idx}] in \`${pathString}\`: ${formatValue(arrayChange.lhs)}`;
case "E":
return `\u{2022} Edited an item at index [${idx}] in \`${pathString}\` from ${formatValue(arrayChange.lhs)} to ${formatValue(arrayChange.rhs)}`;
case "A":
// Nested array changes could happen theoretically; handle or ignore similarly
return `\u{2022} Complex array change at index [${idx}] in \`${pathString}\`: ${JSON.stringify(arrayChange)}`;
default:
return;
}
}
function formatValue(value: any): string {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return JSON.stringify(value);
}

View File

@@ -1,11 +1,8 @@
import { collection, getDocs, query, where, setDoc, doc, Firestore, getDoc, and } from "firebase/firestore";
import { groupBy, shuffle } from "lodash";
import { CEFRLevels, Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam } from "@/interfaces/exam";
import { CEFRLevels, Exam, InstructorGender, SpeakingExam, Variant, WritingExam } from "@/interfaces/exam";
import { DeveloperUser, Stat, StudentUser, User } from "@/interfaces/user";
import { Module } from "@/interfaces";
import { getCorporateUser } from "@/resources/user";
import { getUserCorporate } from "./groups.be";
import { Db, ObjectId } from "mongodb";
import { Db } from "mongodb";
import client from "@/lib/mongodb";
import { MODULE_ARRAY } from "./moduleUtils";
import { mapBy } from ".";

View File

@@ -3,10 +3,10 @@ import client from "@/lib/mongodb";
const db = client.db(process.env.MONGODB_DB);
export const getSessionsByUser = async (id: string, limit = 0, filter = {}) =>
export const getSessionsByUser = async (id: string, limit = 0, filter = {}, projection = {}) =>
await db
.collection("sessions")
.find<Session>({ user: id, ...filter })
.find<Session>({ user: id, ...filter }, { projection })
.limit(limit || 0)
.toArray();

View File

@@ -129,19 +129,19 @@ export async function getUser(id: string, projection = {}): Promise<User | undef
return !!user ? user : undefined;
}
export async function getSpecificUsers(ids: string[]) {
export async function getSpecificUsers(ids: string[], projection = {}) {
if (ids.length === 0) return [];
return await db
.collection("users")
.find<User>({ id: { $in: ids } }, { projection: { _id: 0 } })
.find<User>({ id: { $in: ids } }, { projection: { _id: 0, ...projection } })
.toArray();
}
export async function getEntityUsers(id: string, limit?: number, filter?: object) {
export async function getEntityUsers(id: string, limit?: number, filter?: object, projection = {}) {
return await db
.collection("users")
.find<User>({ "entities.id": id, ...(filter || {}) })
.find<User>({ "entities.id": id, ...(filter || {}) }, { projection: { _id: 0, ...projection } })
.limit(limit || 0)
.toArray();
}
@@ -231,7 +231,7 @@ export async function getUserBalance(user: User) {
);
}
export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[]) => {
export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[], projection = {}) => {
const {
["view_students"]: allowedStudentEntities,
@@ -244,12 +244,12 @@ export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[]
'view_corporates',
'view_mastercorporates',
]);
const students = await getEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" })
const teachers = await getEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" })
const corporates = await getEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" })
const masterCorporates = await getEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" })
const [students, teachers, corporates, masterCorporates] = await Promise.all([
getEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }, 0, projection),
getEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }, 0, projection),
getEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }, 0, projection),
getEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }, 0, projection),
])
return [...students, ...teachers, ...corporates, ...masterCorporates]
}
@@ -266,11 +266,12 @@ export const countAllowedUsers = async (user: User, entities: EntityWithRoles[])
'view_corporates',
'view_mastercorporates',
]);
const student = await countEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" })
const teacher = await countEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" })
const corporate = await countEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" })
const mastercorporate = await countEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" })
const [student, teacher, corporate, mastercorporate] = await Promise.all([
countEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }),
countEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }),
countEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }),
countEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }),
])
return { student, teacher, corporate, mastercorporate }
}

View File

@@ -1,11 +1,8 @@
import { EntityWithRoles, WithLabeledEntities } from "@/interfaces/entity";
import { Group, User } from "@/interfaces/user";
import { getUserCompanyName, USER_TYPE_LABELS } from "@/resources/user";
import {WithLabeledEntities} from "@/interfaces/entity";
import {User} from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user";
import {capitalize} from "lodash";
import moment from "moment";
import { mapBy } from ".";
import { findAllowedEntities } from "./permissions";
import { getEntitiesUsers } from "./users.be";
export interface UserListRow {
name: string;
@@ -25,7 +22,7 @@ export const exportListToExcel = (rowUsers: WithLabeledEntities<User>[]) => {
name: user.name,
email: user.email,
type: USER_TYPE_LABELS[user.type],
entities: user.entities.map((e) => e.label).join(', '),
entities: user.entities.map((e) => e.label).join(", "),
expiryDate: user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited",
country: user.demographicInformation?.country || "N/A",
phone: user.demographicInformation?.phone || "N/A",
@@ -44,8 +41,7 @@ export const exportListToExcel = (rowUsers: WithLabeledEntities<User>[]) => {
export const getUserName = (user?: User) => {
if (!user) return "N/A";
if (user.type === "corporate" || user.type === "mastercorporate") return user.name;
return user.name;
};
export const isAdmin = (user: User) => ["admin", "developer"].includes(user?.type)
export const isAdmin = (user: User) => ["admin", "developer"].includes(user?.type);

View File

@@ -1683,6 +1683,11 @@
resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.4.tgz#cb7e85d9ad5ababfac2f27183e8ac8b576b2abb3"
integrity sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==
"@types/deep-diff@^1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@types/deep-diff/-/deep-diff-1.0.5.tgz#95c08a57f097ffadd28bc98a45a8025f53c581e4"
integrity sha512-PQyNSy1YMZU1hgZA5tTYfHPpUAo9Dorn1PZho2/budQLfqLu3JIP37JAavnwYpR1S2yFZTXa3hxaE4ifGW5jaA==
"@types/express-handlebars@^5":
version "5.3.1"
resolved "https://registry.yarnpkg.com/@types/express-handlebars/-/express-handlebars-5.3.1.tgz#30447330fa4b7d19bb953834c7c26077a906e25e"
@@ -3035,6 +3040,11 @@ decamelize@^1.2.0:
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
deep-diff@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-1.0.2.tgz#afd3d1f749115be965e89c63edc7abb1506b9c26"
integrity sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==
deep-is@^0.1.3, deep-is@~0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
@@ -6563,7 +6573,16 @@ streamsearch@^1.1.0:
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -6658,7 +6677,14 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -7295,7 +7321,7 @@ wordwrap@^1.0.0:
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -7313,6 +7339,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"