Compare commits
74 Commits
ENCOA-316-
...
workflow-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a72ebaea1 | ||
|
|
00d2a7c2ad | ||
|
|
a6e122e82d | ||
|
|
bf2aa29b98 | ||
|
|
cf12a4ed4f | ||
|
|
8711802b97 | ||
|
|
36afde8aa4 | ||
|
|
752881df41 | ||
|
|
63604b68e2 | ||
|
|
d74aa39076 | ||
|
|
c3849518fb | ||
|
|
7fb5e1a62b | ||
|
|
4b405297f2 | ||
|
|
f0849b9b42 | ||
|
|
845a5aa9dc | ||
|
|
d48c7b0d03 | ||
|
|
6692c201e4 | ||
|
|
f4c7961caa | ||
|
|
b215885dc6 | ||
|
|
de15eb5ee1 | ||
|
|
d3385caaf8 | ||
|
|
19f2193414 | ||
|
|
d59b654ac2 | ||
|
|
29b6a02118 | ||
|
|
b77476dc9a | ||
|
|
5a685ebe80 | ||
|
|
835a9bee03 | ||
|
|
16545d2075 | ||
|
|
b684262759 | ||
|
|
ac539332e6 | ||
|
|
ed87c8b163 | ||
|
|
e33ab315ad | ||
|
|
1feef5c419 | ||
|
|
a0229cd971 | ||
|
|
662e3b0266 | ||
|
|
b9aec7261f | ||
|
|
54a9f6869a | ||
|
|
9de4cba8e8 | ||
|
|
7d750dc584 | ||
|
|
98ba0bfc04 | ||
|
|
f89b42c41c | ||
|
|
c968044160 | ||
|
|
5d727fc528 | ||
|
|
bdc5ff7797 | ||
|
|
011c6e9e30 | ||
|
|
42a8ec2f8a | ||
|
|
a40ae04aa3 | ||
|
|
8db47a3962 | ||
|
|
ab81a1753d | ||
|
|
73610dc273 | ||
|
|
ac072b0a5a | ||
|
|
2c0153e055 | ||
|
|
2eff08bf86 | ||
|
|
f71a7182dd | ||
|
|
1f7639a30e | ||
|
|
41d09eaad8 | ||
|
|
f6b0c96b3b | ||
|
|
dcd25465fd | ||
|
|
c921d54d50 | ||
|
|
a4f60455b5 | ||
|
|
a0936cb1a4 | ||
|
|
aa76c2b54b | ||
|
|
4e81c08adb | ||
|
|
4895f00184 | ||
|
|
8f8d5e5640 | ||
|
|
73e2e95449 | ||
|
|
48187fc7f2 | ||
|
|
01222b3a13 | ||
|
|
39a397d262 | ||
|
|
50d2841349 | ||
|
|
f485c782f3 | ||
|
|
c2c9b3374c | ||
|
|
66d23b4140 | ||
|
|
580e319fb9 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -40,4 +40,6 @@ next-env.d.ts
|
|||||||
.env
|
.env
|
||||||
.yarn/*
|
.yarn/*
|
||||||
.history*
|
.history*
|
||||||
__ENV.js
|
__ENV.js
|
||||||
|
|
||||||
|
settings.json
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
"country-codes-list": "^1.6.11",
|
"country-codes-list": "^1.6.11",
|
||||||
"currency-symbol-map": "^5.1.0",
|
"currency-symbol-map": "^5.1.0",
|
||||||
"daisyui": "^3.1.5",
|
"daisyui": "^3.1.5",
|
||||||
|
"deep-diff": "^1.0.2",
|
||||||
"eslint": "8.33.0",
|
"eslint": "8.33.0",
|
||||||
"eslint-config-next": "13.1.6",
|
"eslint-config-next": "13.1.6",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
@@ -97,6 +98,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@simbathesailor/use-what-changed": "^2.0.0",
|
"@simbathesailor/use-what-changed": "^2.0.0",
|
||||||
"@types/blob-stream": "^0.1.33",
|
"@types/blob-stream": "^0.1.33",
|
||||||
|
"@types/deep-diff": "^1.0.5",
|
||||||
"@types/formidable": "^3.4.0",
|
"@types/formidable": "^3.4.0",
|
||||||
"@types/howler": "^2.2.11",
|
"@types/howler": "^2.2.11",
|
||||||
"@types/lodash": "^4.14.191",
|
"@types/lodash": "^4.14.191",
|
||||||
|
|||||||
32
src/components/ApprovalWorkflows/RequestedBy.tsx
Normal file
32
src/components/ApprovalWorkflows/RequestedBy.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import React from "react";
|
||||||
|
import { FaRegUser } from "react-icons/fa";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
prefix: string;
|
||||||
|
name: string;
|
||||||
|
profileImage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RequestedBy({ prefix, name, profileImage }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
|
||||||
|
<FaRegUser className="text-mti-purple-dark size-5"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-800">Requested by</p>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-xs font-medium text-gray-800">{prefix} {name}</p>
|
||||||
|
<img
|
||||||
|
src={profileImage ? profileImage : "/defaultAvatar.png"}
|
||||||
|
alt={name}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="w-6 h-6 rounded-full border-[1px] border-gray-400 border-opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
41
src/components/ApprovalWorkflows/StartedOn.tsx
Normal file
41
src/components/ApprovalWorkflows/StartedOn.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { PiCalendarDots } from "react-icons/pi";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
date: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StartedOn({ date }: Props) {
|
||||||
|
const formattedDate = new Date(date);
|
||||||
|
|
||||||
|
const yearMonthDay = formattedDate.toISOString().split("T")[0];
|
||||||
|
|
||||||
|
const fullDateTime = formattedDate.toLocaleString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
|
||||||
|
<PiCalendarDots className="text-mti-purple-dark size-7" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="pb-1 text-sm font-medium text-gray-800">Started on</p>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<p
|
||||||
|
className="text-xs font-medium text-gray-800"
|
||||||
|
title={fullDateTime}
|
||||||
|
>
|
||||||
|
{yearMonthDay}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
23
src/components/ApprovalWorkflows/Status.tsx
Normal file
23
src/components/ApprovalWorkflows/Status.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel } from "@/interfaces/approval.workflow";
|
||||||
|
import React from "react";
|
||||||
|
import { RiProgress5Line } from "react-icons/ri";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: ApprovalWorkflowStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Status({ status }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
|
||||||
|
<RiProgress5Line className="text-mti-purple-dark size-7"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="pb-1 text-sm font-medium text-gray-800">Status</p>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<p className="text-xs font-medium text-gray-800">{ApprovalWorkflowStatusLabel[status]}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
14
src/components/ApprovalWorkflows/Tip.tsx
Normal file
14
src/components/ApprovalWorkflows/Tip.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { MdTipsAndUpdates } from "react-icons/md";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Tip({ text }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-3 text-gray-500 font-medium">
|
||||||
|
<MdTipsAndUpdates size={25} />
|
||||||
|
<p>{text}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
src/components/ApprovalWorkflows/UserWithProfilePic.tsx
Normal file
24
src/components/ApprovalWorkflows/UserWithProfilePic.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
prefix: string;
|
||||||
|
name: string;
|
||||||
|
profileImage: string;
|
||||||
|
textSize?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserWithProfilePic({ prefix, name, profileImage, textSize }: Props) {
|
||||||
|
const textClassName = `${textSize ? textSize : "text-xs"} font-medium`
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className={textClassName}>{prefix} {name}</p>
|
||||||
|
<img
|
||||||
|
src={profileImage ? profileImage : "/defaultAvatar.png"}
|
||||||
|
alt={name}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="rounded-full h-auto border-[1px] border-gray-400 border-opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { EditableWorkflowStep } from "@/interfaces/approval.workflow";
|
||||||
|
import Option from "@/interfaces/option";
|
||||||
|
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser } from "@/interfaces/user";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { AiOutlineUserAdd } from "react-icons/ai";
|
||||||
|
import { BsTrash } from "react-icons/bs";
|
||||||
|
import { LuGripHorizontal } from "react-icons/lu";
|
||||||
|
import WorkflowStepNumber from "./WorkflowStepNumber";
|
||||||
|
import WorkflowStepSelects from "./WorkflowStepSelects";
|
||||||
|
|
||||||
|
interface Props extends Pick<EditableWorkflowStep, 'stepNumber' | 'assignees' | 'finalStep' | 'onDelete'> {
|
||||||
|
entityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
|
||||||
|
onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void;
|
||||||
|
isCompleted: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkflowEditableStepComponent({
|
||||||
|
stepNumber,
|
||||||
|
assignees = [null],
|
||||||
|
finalStep,
|
||||||
|
onDelete,
|
||||||
|
onSelectChange,
|
||||||
|
entityApprovers,
|
||||||
|
isCompleted,
|
||||||
|
}: Props) {
|
||||||
|
|
||||||
|
const [selects, setSelects] = useState<(Option | null | undefined)[]>([null]);
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
const approverOptions: Option[] = useMemo(() =>
|
||||||
|
entityApprovers
|
||||||
|
.map((approver) => ({
|
||||||
|
value: approver.id,
|
||||||
|
label: approver.name,
|
||||||
|
icon: () => <img src={approver.profilePicture} alt={approver.name} />
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||||
|
[entityApprovers]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (assignees && assignees.length > 0) {
|
||||||
|
const initialSelects = assignees.map((assignee) =>
|
||||||
|
typeof assignee === 'string' ? approverOptions.find(option => option.value === assignee) || null : null
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelects((prevSelects) => {
|
||||||
|
// This is needed to avoid unnecessary re-renders which can cause warning of a child component being re-rendered while parent is in the midle of also re-rendering.
|
||||||
|
const areEqual = initialSelects.length === prevSelects.length && initialSelects.every((option, idx) => option?.value === prevSelects[idx]?.value);
|
||||||
|
|
||||||
|
if (!areEqual) {
|
||||||
|
return initialSelects;
|
||||||
|
}
|
||||||
|
return prevSelects;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [assignees, approverOptions]);
|
||||||
|
|
||||||
|
const selectedValues = useMemo(() =>
|
||||||
|
selects.filter((opt): opt is Option => !!opt).map(opt => opt.value),
|
||||||
|
[selects]
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableApproverOptions = useMemo(() =>
|
||||||
|
approverOptions.filter(opt => !selectedValues.includes(opt.value)),
|
||||||
|
[approverOptions, selectedValues]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddSelectComponent = () => {
|
||||||
|
setIsAdding(true); // I hate to use flags... but it was the only way i was able to prevent onSelectChange to cause parent component from re-rendering in the midle of EditableWorkflowStep rerender.
|
||||||
|
setSelects(prev => [...prev, null]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdding) {
|
||||||
|
onSelectChange(selects.length, selects.length - 1, null);
|
||||||
|
setIsAdding(false);
|
||||||
|
}
|
||||||
|
}, [selects.length, isAdding, onSelectChange]);
|
||||||
|
|
||||||
|
const handleSelectChangeAt = (numberOfSelects: number, index: number, option: Option | null) => {
|
||||||
|
const updated = [...selects];
|
||||||
|
updated[index] = option;
|
||||||
|
setSelects(updated);
|
||||||
|
onSelectChange(numberOfSelects, index, option);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<WorkflowStepNumber stepNumber={stepNumber} completed={false} selected={false} />
|
||||||
|
|
||||||
|
{/* Vertical Bar connecting steps */}
|
||||||
|
{!finalStep && (
|
||||||
|
<div className="w-1 h-full min-h-10 bg-mti-purple-dark"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stepNumber !== 1 && !finalStep && !isCompleted
|
||||||
|
? <LuGripHorizontal className="ml-3 mt-2 cursor-grab active:cursor-grabbing min-w-[25px] min-h-[25px]" />
|
||||||
|
: <div className="ml-3 mt-2" style={{ width: 25, height: 25 }}></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className="ml-10 mb-12">
|
||||||
|
<WorkflowStepSelects
|
||||||
|
approvers={availableApproverOptions}
|
||||||
|
selects={selects}
|
||||||
|
placeholder={stepNumber === 1 ? "Form Intake By:" : "Approval By:"}
|
||||||
|
onSelectChange={handleSelectChangeAt}
|
||||||
|
isCompleted={isCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-start mt-1.5 ml-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddSelectComponent}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<AiOutlineUserAdd className="size-7 hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
|
</button>
|
||||||
|
{stepNumber !== 1 && !finalStep && (
|
||||||
|
<button
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={onDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<BsTrash className="size-6 mt-0.5 ml-3 hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
);
|
||||||
|
};
|
||||||
203
src/components/ApprovalWorkflows/WorkflowForm.tsx
Normal file
203
src/components/ApprovalWorkflows/WorkflowForm.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { EditableApprovalWorkflow, EditableWorkflowStep } from "@/interfaces/approval.workflow";
|
||||||
|
import Option from "@/interfaces/option";
|
||||||
|
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser } from "@/interfaces/user";
|
||||||
|
import { AnimatePresence, Reorder, motion } from "framer-motion";
|
||||||
|
import { FaRegCheckCircle, FaSpinner } from "react-icons/fa";
|
||||||
|
import { IoIosAddCircleOutline } from "react-icons/io";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import Tip from "./Tip";
|
||||||
|
import WorkflowEditableStepComponent from "./WorkflowEditableStepComponent";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
workflow: EditableApprovalWorkflow;
|
||||||
|
onWorkflowChange: (workflow: EditableApprovalWorkflow) => void;
|
||||||
|
entityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
|
||||||
|
entityAvailableFormIntakers?: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isRedirecting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkflowForm({ workflow, onWorkflowChange, entityApprovers, entityAvailableFormIntakers, isLoading, isRedirecting }: Props) {
|
||||||
|
const lastStep = workflow.steps[workflow.steps.length - 1];
|
||||||
|
|
||||||
|
const renumberSteps = (steps: EditableWorkflowStep[]): EditableWorkflowStep[] => {
|
||||||
|
return steps.map((step, index) => ({
|
||||||
|
...step,
|
||||||
|
stepNumber: index + 1,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addStep = () => {
|
||||||
|
const newStep: EditableWorkflowStep = {
|
||||||
|
key: Date.now(),
|
||||||
|
stepType: "approval-by",
|
||||||
|
stepNumber: workflow.steps.length,
|
||||||
|
completed: false,
|
||||||
|
assignees: [null],
|
||||||
|
firstStep: false,
|
||||||
|
finalStep: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedSteps = [
|
||||||
|
...workflow.steps.slice(0, -1),
|
||||||
|
newStep,
|
||||||
|
lastStep
|
||||||
|
];
|
||||||
|
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (key: number | undefined) => {
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
const updatedSteps = workflow.steps.filter((step) => step.key !== key);
|
||||||
|
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectChange = (key: number | undefined, numberOfSelects: number, index: number, selectedOption: Option | null) => {
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
const updatedSteps = workflow.steps.map((step) => {
|
||||||
|
if (step.key !== key) return step;
|
||||||
|
|
||||||
|
const assignees = step.assignees ?? [];
|
||||||
|
let newAssignees = [...assignees];
|
||||||
|
|
||||||
|
if (numberOfSelects === assignees.length) { // means no new select was added and instead one was changed
|
||||||
|
newAssignees[index] = selectedOption?.value;
|
||||||
|
} else if (numberOfSelects === assignees.length + 1) { // means a new select was added
|
||||||
|
newAssignees.push(selectedOption?.value || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...step, assignees: newAssignees };
|
||||||
|
});
|
||||||
|
onWorkflowChange({ ...workflow, steps: updatedSteps });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReorder = (newOrder: EditableWorkflowStep[]) => {
|
||||||
|
let draggableIndex = 0;
|
||||||
|
const updatedSteps = workflow.steps.map((step) => {
|
||||||
|
if (!step.firstStep && !step.finalStep && !step.completed) {
|
||||||
|
return newOrder[draggableIndex++];
|
||||||
|
}
|
||||||
|
// Keep static steps as-is
|
||||||
|
return step;
|
||||||
|
});
|
||||||
|
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{workflow.entityId && workflow.name &&
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-6"
|
||||||
|
>
|
||||||
|
<Tip text="Introduce here all the steps associated with this instance." />
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="solid"
|
||||||
|
onClick={addStep}
|
||||||
|
type="button"
|
||||||
|
className="max-w-fit text-lg font-medium flex items-center gap-2 text-left mb-7"
|
||||||
|
>
|
||||||
|
<IoIosAddCircleOutline className="size-6" />
|
||||||
|
Add Step
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Reorder.Group
|
||||||
|
axis="y"
|
||||||
|
values={workflow.steps}
|
||||||
|
onReorder={handleReorder}
|
||||||
|
className="flex flex-col gap-0"
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{workflow.steps.map((step, index) =>
|
||||||
|
step.completed || step.firstStep || step.finalStep ? (
|
||||||
|
<motion.div
|
||||||
|
key={step.key}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: -30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 30 }}
|
||||||
|
transition={{ duration: 0.20 }}
|
||||||
|
>
|
||||||
|
<WorkflowEditableStepComponent
|
||||||
|
stepNumber={index + 1}
|
||||||
|
assignees={step.assignees}
|
||||||
|
finalStep={step.finalStep}
|
||||||
|
onDelete={() => handleDelete(step.key)}
|
||||||
|
onSelectChange={(numberOfSelects, idx, option) =>
|
||||||
|
handleSelectChange(step.key, numberOfSelects, idx, option)
|
||||||
|
}
|
||||||
|
entityApprovers={
|
||||||
|
step.stepNumber === 1 && entityAvailableFormIntakers
|
||||||
|
? entityAvailableFormIntakers
|
||||||
|
: entityApprovers
|
||||||
|
}
|
||||||
|
isCompleted={step.completed}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
// Render non-completed steps as draggable items
|
||||||
|
<Reorder.Item
|
||||||
|
key={step.key}
|
||||||
|
value={step}
|
||||||
|
initial={{ opacity: 0, y: -30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 30 }}
|
||||||
|
transition={{ duration: 0.20 }}
|
||||||
|
layout
|
||||||
|
drag={!step.firstStep && !step.finalStep}
|
||||||
|
dragListener={!step.firstStep && !step.finalStep}
|
||||||
|
>
|
||||||
|
<WorkflowEditableStepComponent
|
||||||
|
stepNumber={index + 1}
|
||||||
|
assignees={step.assignees}
|
||||||
|
finalStep={step.finalStep}
|
||||||
|
onDelete={() => handleDelete(step.key)}
|
||||||
|
onSelectChange={(numberOfSelects, idx, option) =>
|
||||||
|
handleSelectChange(step.key, numberOfSelects, idx, option)
|
||||||
|
}
|
||||||
|
entityApprovers={
|
||||||
|
step.stepNumber === 1 && entityAvailableFormIntakers
|
||||||
|
? entityAvailableFormIntakers
|
||||||
|
: entityApprovers
|
||||||
|
}
|
||||||
|
isCompleted={step.completed}
|
||||||
|
/>
|
||||||
|
</Reorder.Item>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="purple"
|
||||||
|
variant="solid"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="max-w-fit text-lg font-medium flex items-center gap-2 text-left -mt-4"
|
||||||
|
>
|
||||||
|
{isRedirecting ? (
|
||||||
|
<>
|
||||||
|
<FaSpinner className="animate-spin size-5" />
|
||||||
|
Redirecting...
|
||||||
|
</>
|
||||||
|
) : isLoading ? (
|
||||||
|
<>
|
||||||
|
<FaSpinner className="animate-spin size-5" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaRegCheckCircle className="size-5" />
|
||||||
|
Confirm Exam Workflow Pipeline
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</AnimatePresence>
|
||||||
|
</Reorder.Group>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
101
src/components/ApprovalWorkflows/WorkflowStepComponent.tsx
Normal file
101
src/components/ApprovalWorkflows/WorkflowStepComponent.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { getUserTypeLabel, getUserTypeLabelShort, WorkflowStep } from "@/interfaces/approval.workflow";
|
||||||
|
import WorkflowStepNumber from "./WorkflowStepNumber";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { RiThumbUpLine } from "react-icons/ri";
|
||||||
|
import { FaWpforms } from "react-icons/fa6";
|
||||||
|
import { User } from "@/interfaces/user";
|
||||||
|
import UserWithProfilePic from "./UserWithProfilePic";
|
||||||
|
|
||||||
|
interface Props extends WorkflowStep {
|
||||||
|
workflowAssignees: User[],
|
||||||
|
currentStep: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkflowStepComponent({
|
||||||
|
workflowAssignees,
|
||||||
|
currentStep,
|
||||||
|
stepType,
|
||||||
|
stepNumber,
|
||||||
|
completed,
|
||||||
|
rejected = false,
|
||||||
|
completedBy,
|
||||||
|
assignees,
|
||||||
|
finalStep,
|
||||||
|
selected = false,
|
||||||
|
onClick,
|
||||||
|
}: Props) {
|
||||||
|
|
||||||
|
const completedByUser = workflowAssignees.find((assignee) => assignee.id === completedBy);
|
||||||
|
const assigneesUsers = workflowAssignees.filter(user => assignees.includes(user.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={clsx("flex flex-row gap-5 w-[600px] p-6 mb-5 rounded-2xl transition ease-in-out duration-300 cursor-pointer", {
|
||||||
|
"bg-mti-red-ultralight": rejected && selected,
|
||||||
|
"bg-mti-purple-ultralight": selected,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="relative flex flex-col items-center">
|
||||||
|
<WorkflowStepNumber stepNumber={stepNumber} selected={selected} completed={completed} finalStep={finalStep} rejected={rejected} />
|
||||||
|
|
||||||
|
{/* Vertical Bar connecting steps */}
|
||||||
|
{!finalStep && (
|
||||||
|
<div className="absolute w-1 bg-mti-purple-dark -bottom-20 top-11"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5">
|
||||||
|
{stepType === "approval-by" ? (
|
||||||
|
<RiThumbUpLine size={25} />
|
||||||
|
) : (
|
||||||
|
<FaWpforms size={25} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex flex-col gap-0">
|
||||||
|
{completed && completedBy && rejected ? (
|
||||||
|
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
|
||||||
|
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`} </p>
|
||||||
|
<UserWithProfilePic
|
||||||
|
prefix={`Rejected by: ${getUserTypeLabelShort(completedByUser!.type)}`}
|
||||||
|
name={completedByUser!.name}
|
||||||
|
profileImage={completedByUser!.profilePicture}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : completed && completedBy && !rejected ? (
|
||||||
|
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
|
||||||
|
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`} </p>
|
||||||
|
<UserWithProfilePic
|
||||||
|
prefix={`Completed by: ${getUserTypeLabelShort(completedByUser!.type)}`}
|
||||||
|
name={completedByUser!.name}
|
||||||
|
profileImage={completedByUser!.profilePicture}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : !completed && currentStep ? (
|
||||||
|
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
|
||||||
|
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval:` : `Form Intake:`} </p>
|
||||||
|
In Progress... Assignees:
|
||||||
|
<div className="flex flex-row flex-wrap gap-3 items-center">
|
||||||
|
{assigneesUsers.map(user => (
|
||||||
|
<span key={user.id}>
|
||||||
|
<UserWithProfilePic
|
||||||
|
prefix={getUserTypeLabelShort(user.type)}
|
||||||
|
name={user.name}
|
||||||
|
profileImage={user.profilePicture}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
|
||||||
|
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval:` : `Form Intake:`} </p>
|
||||||
|
Waiting for previous steps...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
31
src/components/ApprovalWorkflows/WorkflowStepNumber.tsx
Normal file
31
src/components/ApprovalWorkflows/WorkflowStepNumber.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { WorkflowStep } from "@/interfaces/approval.workflow";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { IoCheckmarkDoneSharp, IoCheckmarkSharp } from "react-icons/io5";
|
||||||
|
import { RxCross2 } from "react-icons/rx";
|
||||||
|
|
||||||
|
type Props = Pick<WorkflowStep, 'stepNumber' | 'completed' | 'finalStep' | 'selected' | 'rejected'>
|
||||||
|
|
||||||
|
export default function WorkflowStepNumber({ stepNumber, selected = false, completed, rejected, finalStep }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center justify-center min-w-11 min-h-11 rounded-full',
|
||||||
|
{
|
||||||
|
'bg-mti-red-dark text-mti-red-ultralight': rejected,
|
||||||
|
'bg-mti-purple-dark text-mti-purple-ultralight': selected,
|
||||||
|
'bg-mti-purple-ultralight text-gray-500': !selected,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rejected ? (
|
||||||
|
<RxCross2 className="text-xl font-bold" size={25}/>
|
||||||
|
) : completed && finalStep ? (
|
||||||
|
<IoCheckmarkDoneSharp className="text-xl font-bold" size={25} />
|
||||||
|
) : completed && !finalStep ? (
|
||||||
|
<IoCheckmarkSharp className="text-xl font-bold" size={25} />
|
||||||
|
) : (
|
||||||
|
<span className="text-lg font-semibold">{stepNumber}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
51
src/components/ApprovalWorkflows/WorkflowStepSelects.tsx
Normal file
51
src/components/ApprovalWorkflows/WorkflowStepSelects.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import Option from "@/interfaces/option";
|
||||||
|
import Select from "../Low/Select";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
approvers: Option[];
|
||||||
|
selects: (Option | null | undefined)[];
|
||||||
|
placeholder: string;
|
||||||
|
onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void;
|
||||||
|
isCompleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkflowStepSelects({
|
||||||
|
approvers,
|
||||||
|
selects,
|
||||||
|
placeholder,
|
||||||
|
onSelectChange,
|
||||||
|
isCompleted,
|
||||||
|
}: Props) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={"flex flex-wrap gap-0"}
|
||||||
|
>
|
||||||
|
{selects.map((option, index) => {
|
||||||
|
let classes = "px-2 rounded-none";
|
||||||
|
if (index === 0 && selects.length === 1) {
|
||||||
|
classes += " rounded-l-2xl rounded-r-2xl";
|
||||||
|
} else if (index === 0) {
|
||||||
|
classes += " rounded-l-2xl";
|
||||||
|
} else if (index === selects.length - 1) {
|
||||||
|
classes += " rounded-r-2xl";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="w-[275px]">
|
||||||
|
<Select
|
||||||
|
options={approvers}
|
||||||
|
value={option}
|
||||||
|
onChange={(option) => onSelectChange(selects.length, index, option)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
flat
|
||||||
|
isClearable
|
||||||
|
className={classes}
|
||||||
|
disabled={isCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
|||||||
}, [updateLocalAndScheduleGlobal]);
|
}, [updateLocalAndScheduleGlobal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit`}>
|
<div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit -2xl:w-full`}>
|
||||||
<div className={`w-full flex justify-center text-ielts-${module} font-bold text-xl`}>{sectionLabel} Settings</div>
|
<div className={`w-full flex justify-center text-ielts-${module} font-bold text-xl`}>{sectionLabel} Settings</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import ListeningComponents from "./listening/components";
|
|||||||
import ReadingComponents from "./reading/components";
|
import ReadingComponents from "./reading/components";
|
||||||
import SpeakingComponents from "./speaking/components";
|
import SpeakingComponents from "./speaking/components";
|
||||||
import SectionPicker from "./Shared/SectionPicker";
|
import SectionPicker from "./Shared/SectionPicker";
|
||||||
|
import { getExamById } from "@/utils/exams";
|
||||||
|
|
||||||
|
|
||||||
const LevelSettings: React.FC = () => {
|
const LevelSettings: React.FC = () => {
|
||||||
@@ -194,7 +195,7 @@ const LevelSettings: React.FC = () => {
|
|||||||
category: s.settings.category
|
category: s.settings.category
|
||||||
};
|
};
|
||||||
}).filter(part => part.exercises.length > 0),
|
}).filter(part => part.exercises.length > 0),
|
||||||
isDiagnostic: false,
|
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||||
minTimer,
|
minTimer,
|
||||||
module: "level",
|
module: "level",
|
||||||
id: title,
|
id: title,
|
||||||
@@ -204,7 +205,12 @@ const LevelSettings: React.FC = () => {
|
|||||||
|
|
||||||
const result = await axios.post('/api/exam/level', exam);
|
const result = await axios.post('/api/exam/level', exam);
|
||||||
playSound("sent");
|
playSound("sent");
|
||||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
// Successfully submitted exam
|
||||||
|
if (result.status === 200) {
|
||||||
|
toast.success(result.data.message);
|
||||||
|
} else if (result.status === 207) {
|
||||||
|
toast.warning(result.data.message);
|
||||||
|
}
|
||||||
|
|
||||||
Array.from(audioMap.values()).forEach(url => {
|
Array.from(audioMap.values()).forEach(url => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
|
|||||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
|
||||||
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
|
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
<div className="flex flex-row flex-wrap gap-2 items-center px-2 pb-4">
|
||||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { usePersistentExamStore } from "@/stores/exam";
|
|||||||
import { playSound } from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import ListeningComponents from "./components";
|
import ListeningComponents from "./components";
|
||||||
|
import { getExamById } from "@/utils/exams";
|
||||||
|
|
||||||
const ListeningSettings: React.FC = () => {
|
const ListeningSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -137,7 +138,7 @@ const ListeningSettings: React.FC = () => {
|
|||||||
category: s.settings.category
|
category: s.settings.category
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
isDiagnostic: false,
|
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||||
minTimer,
|
minTimer,
|
||||||
module: "listening",
|
module: "listening",
|
||||||
id: title,
|
id: title,
|
||||||
@@ -149,7 +150,12 @@ const ListeningSettings: React.FC = () => {
|
|||||||
|
|
||||||
const result = await axios.post('/api/exam/listening', exam);
|
const result = await axios.post('/api/exam/listening', exam);
|
||||||
playSound("sent");
|
playSound("sent");
|
||||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
// Successfully submitted exam
|
||||||
|
if (result.status === 200) {
|
||||||
|
toast.success(result.data.message);
|
||||||
|
} else if (result.status === 207) {
|
||||||
|
toast.warning(result.data.message);
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
toast.error('No audio sections found in the exam! Please either import them or generate them.');
|
toast.error('No audio sections found in the exam! Please either import them or generate them.');
|
||||||
|
|||||||
@@ -5,103 +5,140 @@ import ExercisePicker from "../../ExercisePicker";
|
|||||||
import { generate } from "../Shared/Generate";
|
import { generate } from "../Shared/Generate";
|
||||||
import GenerateBtn from "../Shared/GenerateBtn";
|
import GenerateBtn from "../Shared/GenerateBtn";
|
||||||
import { LevelPart, ReadingPart } from "@/interfaces/exam";
|
import { LevelPart, ReadingPart } from "@/interfaces/exam";
|
||||||
import { LevelSectionSettings, ReadingSectionSettings } from "@/stores/examEditor/types";
|
import {
|
||||||
|
LevelSectionSettings,
|
||||||
|
ReadingSectionSettings,
|
||||||
|
} from "@/stores/examEditor/types";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
localSettings: ReadingSectionSettings | LevelSectionSettings;
|
localSettings: ReadingSectionSettings | LevelSectionSettings;
|
||||||
updateLocalAndScheduleGlobal: (updates: Partial<ReadingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
|
updateLocalAndScheduleGlobal: (
|
||||||
currentSection: ReadingPart | LevelPart;
|
updates: Partial<ReadingSectionSettings | LevelSectionSettings>,
|
||||||
generatePassageDisabled?: boolean;
|
schedule?: boolean
|
||||||
levelId?: number;
|
) => void;
|
||||||
level?: boolean;
|
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> = ({
|
||||||
const { currentModule } = useExamEditorStore();
|
localSettings,
|
||||||
const {
|
updateLocalAndScheduleGlobal,
|
||||||
focusedSection,
|
currentSection,
|
||||||
difficulty,
|
levelId,
|
||||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
level = false,
|
||||||
|
generatePassageDisabled = false,
|
||||||
|
}) => {
|
||||||
|
const { currentModule } = useExamEditorStore();
|
||||||
|
const { focusedSection, difficulty } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule]
|
||||||
|
);
|
||||||
|
|
||||||
const generatePassage = useCallback(() => {
|
const generatePassage = useCallback(() => {
|
||||||
generate(
|
generate(
|
||||||
levelId ? levelId : focusedSection,
|
levelId ? levelId : focusedSection,
|
||||||
"reading",
|
"reading",
|
||||||
"passage",
|
"passage",
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
queryParams: {
|
queryParams: {
|
||||||
difficulty,
|
difficulty,
|
||||||
...(localSettings.readingTopic && { topic: localSettings.readingTopic })
|
...(localSettings.readingTopic && {
|
||||||
}
|
topic: localSettings.readingTopic,
|
||||||
},
|
}),
|
||||||
(data: any) => [{
|
},
|
||||||
title: data.title,
|
},
|
||||||
text: data.text
|
(data: any) => [
|
||||||
}],
|
{
|
||||||
level ? focusedSection : undefined,
|
title: data.title,
|
||||||
level
|
text: data.text,
|
||||||
);
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
],
|
||||||
}, [localSettings.readingTopic, difficulty, focusedSection, levelId]);
|
level ? focusedSection : undefined,
|
||||||
|
level
|
||||||
const onTopicChange = useCallback((readingTopic: string) => {
|
|
||||||
updateLocalAndScheduleGlobal({ readingTopic });
|
|
||||||
}, [updateLocalAndScheduleGlobal]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dropdown
|
|
||||||
title="Generate Passage"
|
|
||||||
module="reading"
|
|
||||||
open={localSettings.isPassageOpen}
|
|
||||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)}
|
|
||||||
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
|
|
||||||
disabled={generatePassageDisabled}
|
|
||||||
>
|
|
||||||
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
|
||||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
|
||||||
<Input
|
|
||||||
key={`section-${focusedSection}`}
|
|
||||||
type="text"
|
|
||||||
placeholder="Topic"
|
|
||||||
name="category"
|
|
||||||
onChange={onTopicChange}
|
|
||||||
roundness="full"
|
|
||||||
value={localSettings.readingTopic}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex self-end h-16 mb-1">
|
|
||||||
<GenerateBtn
|
|
||||||
module="reading"
|
|
||||||
genType="passage"
|
|
||||||
sectionId={focusedSection}
|
|
||||||
generateFnc={generatePassage}
|
|
||||||
level={level}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dropdown>
|
|
||||||
<Dropdown
|
|
||||||
title="Add Exercises"
|
|
||||||
module="reading"
|
|
||||||
open={localSettings.isReadingTopicOpean}
|
|
||||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })}
|
|
||||||
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
|
|
||||||
disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""}
|
|
||||||
>
|
|
||||||
<ExercisePicker
|
|
||||||
module="reading"
|
|
||||||
sectionId={levelId !== undefined ? levelId : focusedSection}
|
|
||||||
extraArgs={{ text: currentSection === undefined || currentSection.text === undefined ? "" : currentSection.text.content }}
|
|
||||||
levelSectionId={focusedSection}
|
|
||||||
level={level}
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [localSettings.readingTopic, difficulty, focusedSection, levelId]);
|
||||||
|
|
||||||
|
const onTopicChange = useCallback(
|
||||||
|
(readingTopic: string) => {
|
||||||
|
updateLocalAndScheduleGlobal({ readingTopic });
|
||||||
|
},
|
||||||
|
[updateLocalAndScheduleGlobal]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dropdown
|
||||||
|
title="Generate Passage"
|
||||||
|
module="reading"
|
||||||
|
open={localSettings.isPassageOpen}
|
||||||
|
setIsOpen={(isOpen: boolean) =>
|
||||||
|
updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)
|
||||||
|
}
|
||||||
|
contentWrapperClassName={level ? `border border-ielts-reading` : ""}
|
||||||
|
disabled={generatePassageDisabled}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex flex-row flex-wrap gap-2 items-center px-2 pb-4 "
|
||||||
|
>
|
||||||
|
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Topic (Optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
key={`section-${focusedSection}`}
|
||||||
|
type="text"
|
||||||
|
placeholder="Topic"
|
||||||
|
name="category"
|
||||||
|
onChange={onTopicChange}
|
||||||
|
roundness="full"
|
||||||
|
value={localSettings.readingTopic}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex self-end h-16 mb-1">
|
||||||
|
<GenerateBtn
|
||||||
|
module="reading"
|
||||||
|
genType="passage"
|
||||||
|
sectionId={focusedSection}
|
||||||
|
generateFnc={generatePassage}
|
||||||
|
level={level}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
<Dropdown
|
||||||
|
title="Add Exercises"
|
||||||
|
module="reading"
|
||||||
|
open={localSettings.isReadingTopicOpean}
|
||||||
|
setIsOpen={(isOpen: boolean) =>
|
||||||
|
updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })
|
||||||
|
}
|
||||||
|
contentWrapperClassName={level ? `border border-ielts-reading` : ""}
|
||||||
|
disabled={
|
||||||
|
currentSection === undefined ||
|
||||||
|
currentSection.text === undefined ||
|
||||||
|
currentSection.text.content === "" ||
|
||||||
|
currentSection.text.title === ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExercisePicker
|
||||||
|
module="reading"
|
||||||
|
sectionId={levelId !== undefined ? levelId : focusedSection}
|
||||||
|
extraArgs={{
|
||||||
|
text:
|
||||||
|
currentSection === undefined || currentSection.text === undefined
|
||||||
|
? ""
|
||||||
|
: currentSection.text.content,
|
||||||
|
}}
|
||||||
|
levelSectionId={focusedSection}
|
||||||
|
level={level}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ReadingComponents;
|
export default ReadingComponents;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import axios from "axios";
|
|||||||
import { playSound } from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import ReadingComponents from "./components";
|
import ReadingComponents from "./components";
|
||||||
|
import { getExamById } from "@/utils/exams";
|
||||||
|
|
||||||
const ReadingSettings: React.FC = () => {
|
const ReadingSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -46,15 +47,15 @@ const ReadingSettings: React.FC = () => {
|
|||||||
{
|
{
|
||||||
label: "Preset: Reading Passage 1",
|
label: "Preset: Reading Passage 1",
|
||||||
value: "Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas."
|
value: "Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Preset: Reading Passage 2",
|
label: "Preset: Reading Passage 2",
|
||||||
value: "Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views."
|
value: "Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Preset: Reading Passage 3",
|
label: "Preset: Reading Passage 3",
|
||||||
value: "Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas."
|
value: "Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas."
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const canPreviewOrSubmit = sections.some(
|
const canPreviewOrSubmit = sections.some(
|
||||||
@@ -75,7 +76,7 @@ const ReadingSettings: React.FC = () => {
|
|||||||
category: localSettings.category
|
category: localSettings.category
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
isDiagnostic: false,
|
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||||
minTimer,
|
minTimer,
|
||||||
module: "reading",
|
module: "reading",
|
||||||
id: title,
|
id: title,
|
||||||
@@ -88,7 +89,12 @@ const ReadingSettings: React.FC = () => {
|
|||||||
axios.post(`/api/exam/reading`, exam)
|
axios.post(`/api/exam/reading`, exam)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("sent");
|
playSound("sent");
|
||||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
// Successfully submitted exam
|
||||||
|
if (result.status === 200) {
|
||||||
|
toast.success(result.data.message);
|
||||||
|
} else if (result.status === 207) {
|
||||||
|
toast.warning(result.data.message);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import openDetachedTab from "@/utils/popout";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { playSound } from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import SpeakingComponents from "./components";
|
import SpeakingComponents from "./components";
|
||||||
|
import { getExamById } from "@/utils/exams";
|
||||||
|
|
||||||
export interface Avatar {
|
export interface Avatar {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -180,7 +181,7 @@ const SpeakingSettings: React.FC = () => {
|
|||||||
minTimer,
|
minTimer,
|
||||||
module: "speaking",
|
module: "speaking",
|
||||||
id: title,
|
id: title,
|
||||||
isDiagnostic: false,
|
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
difficulty,
|
difficulty,
|
||||||
instructorGender: "varied",
|
instructorGender: "varied",
|
||||||
@@ -189,7 +190,12 @@ const SpeakingSettings: React.FC = () => {
|
|||||||
|
|
||||||
const result = await axios.post('/api/exam/speaking', exam);
|
const result = await axios.post('/api/exam/speaking', exam);
|
||||||
playSound("sent");
|
playSound("sent");
|
||||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
// Successfully submitted exam
|
||||||
|
if (result.status === 200) {
|
||||||
|
toast.success(result.data.message);
|
||||||
|
} else if (result.status === 207) {
|
||||||
|
toast.warning(result.data.message);
|
||||||
|
}
|
||||||
|
|
||||||
Array.from(urlMap.values()).forEach(url => {
|
Array.from(urlMap.values()).forEach(url => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import axios from "axios";
|
|||||||
import { playSound } from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import WritingComponents from "./components";
|
import WritingComponents from "./components";
|
||||||
|
import { getExamById } from "@/utils/exams";
|
||||||
|
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
|
||||||
|
|
||||||
const WritingSettings: React.FC = () => {
|
const WritingSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -129,7 +131,7 @@ const WritingSettings: React.FC = () => {
|
|||||||
minTimer,
|
minTimer,
|
||||||
module: "writing",
|
module: "writing",
|
||||||
id: title,
|
id: title,
|
||||||
isDiagnostic: false,
|
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
|
||||||
variant: undefined,
|
variant: undefined,
|
||||||
difficulty,
|
difficulty,
|
||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
@@ -138,7 +140,12 @@ const WritingSettings: React.FC = () => {
|
|||||||
|
|
||||||
const result = await axios.post(`/api/exam/writing`, exam)
|
const result = await axios.post(`/api/exam/writing`, exam)
|
||||||
playSound("sent");
|
playSound("sent");
|
||||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
// Successfully submitted exam
|
||||||
|
if (result.status === 200) {
|
||||||
|
toast.success(result.data.message);
|
||||||
|
} else if (result.status === 207) {
|
||||||
|
toast.warning(result.data.message);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error submitting exam:', error);
|
console.error('Error submitting exam:', error);
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ const label = (type: string, firstId: string, lastId: string) => {
|
|||||||
const ExerciseLabel: React.FC<Props> = ({type, firstId, lastId, prompt}) => {
|
const ExerciseLabel: React.FC<Props> = ({type, firstId, lastId, prompt}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full justify-between items-center mr-4">
|
<div className="flex w-full justify-between items-center mr-4">
|
||||||
<span className="font-semibold">{label(type, firstId, lastId)}</span>
|
<span className="font-semibold ellipsis-2">{label(type, firstId, lastId)}</span>
|
||||||
<div className="text-sm font-light italic">{previewLabel(prompt)}</div>
|
<div className="text-sm font-light italic ellipsis-2">{previewLabel(prompt)}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import SectionRenderer from "./SectionRenderer";
|
|||||||
import Checkbox from "../Low/Checkbox";
|
import Checkbox from "../Low/Checkbox";
|
||||||
import Input from "../Low/Input";
|
import Input from "../Low/Input";
|
||||||
import Select from "../Low/Select";
|
import Select from "../Low/Select";
|
||||||
import { capitalize } from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import { Difficulty } from "@/interfaces/exam";
|
import {Difficulty} from "@/interfaces/exam";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import {useCallback, useEffect, useMemo, useState} from "react";
|
||||||
import { toast } from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import { ModuleState, SectionState } from "@/stores/examEditor/types";
|
import {ModuleState, SectionState} from "@/stores/examEditor/types";
|
||||||
import { Module } from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import WritingSettings from "./SettingsEditor/writing";
|
import WritingSettings from "./SettingsEditor/writing";
|
||||||
import ReadingSettings from "./SettingsEditor/reading";
|
import ReadingSettings from "./SettingsEditor/reading";
|
||||||
@@ -16,224 +16,243 @@ import LevelSettings from "./SettingsEditor/level";
|
|||||||
import ListeningSettings from "./SettingsEditor/listening";
|
import ListeningSettings from "./SettingsEditor/listening";
|
||||||
import SpeakingSettings from "./SettingsEditor/speaking";
|
import SpeakingSettings from "./SettingsEditor/speaking";
|
||||||
import ImportOrStartFromScratch from "./ImportExam/ImportOrFromScratch";
|
import ImportOrStartFromScratch from "./ImportExam/ImportOrFromScratch";
|
||||||
import { defaultSectionSettings } from "@/stores/examEditor/defaults";
|
import {defaultSectionSettings} from "@/stores/examEditor/defaults";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import ResetModule from "./Standalone/ResetModule";
|
import ResetModule from "./Standalone/ResetModule";
|
||||||
import ListeningInstructions from "./Standalone/ListeningInstructions";
|
import ListeningInstructions from "./Standalone/ListeningInstructions";
|
||||||
|
import {EntityWithRoles} from "@/interfaces/entity";
|
||||||
|
|
||||||
const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
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[]}> = ({
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
levelParts = 0,
|
||||||
const {
|
entitiesAllowEditPrivacy = [],
|
||||||
sections,
|
}) => {
|
||||||
minTimer,
|
const {currentModule, dispatch} = useExamEditorStore();
|
||||||
expandedSections,
|
const {sections, minTimer, expandedSections, examLabel, isPrivate, difficulty, sectionLabels, importModule} = useExamEditorStore(
|
||||||
examLabel,
|
(state) => state.modules[currentModule],
|
||||||
isPrivate,
|
);
|
||||||
difficulty,
|
|
||||||
sectionLabels,
|
|
||||||
importModule
|
|
||||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
|
||||||
|
|
||||||
const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1);
|
const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1);
|
||||||
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
|
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
|
||||||
|
|
||||||
// For exam edits
|
// For exam edits
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (levelParts !== 0) {
|
if (levelParts !== 0) {
|
||||||
setNumberOfLevelParts(levelParts);
|
setNumberOfLevelParts(levelParts);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'UPDATE_MODULE',
|
type: "UPDATE_MODULE",
|
||||||
payload: {
|
payload: {
|
||||||
updates: {
|
updates: {
|
||||||
sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({
|
sectionLabels: Array.from({length: levelParts}).map((_, i) => ({
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
label: `Part ${i + 1}`
|
label: `Part ${i + 1}`,
|
||||||
}))
|
})),
|
||||||
},
|
},
|
||||||
module: "level"
|
module: "level",
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [levelParts])
|
}, [levelParts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentSections = sections;
|
const currentSections = sections;
|
||||||
const currentLabels = sectionLabels;
|
const currentLabels = sectionLabels;
|
||||||
let updatedSections: SectionState[];
|
let updatedSections: SectionState[];
|
||||||
let updatedLabels: any;
|
let updatedLabels: any;
|
||||||
if (currentModule === "level" && currentSections.length !== currentLabels.length || numberOfLevelParts !== currentSections.length) {
|
if ((currentModule === "level" && currentSections.length !== currentLabels.length) || numberOfLevelParts !== currentSections.length) {
|
||||||
const newSections = [...currentSections];
|
const newSections = [...currentSections];
|
||||||
const newLabels = [...currentLabels];
|
const newLabels = [...currentLabels];
|
||||||
for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
|
for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
|
||||||
if (currentSections.length !== numberOfLevelParts) newSections.push(defaultSectionSettings(currentModule, i + 1));
|
if (currentSections.length !== numberOfLevelParts) newSections.push(defaultSectionSettings(currentModule, i + 1));
|
||||||
newLabels.push({
|
newLabels.push({
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
label: `Part ${i + 1}`
|
label: `Part ${i + 1}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
updatedSections = newSections;
|
updatedSections = newSections;
|
||||||
updatedLabels = newLabels;
|
updatedLabels = newLabels;
|
||||||
} else if (numberOfLevelParts < currentSections.length) {
|
} else if (numberOfLevelParts < currentSections.length) {
|
||||||
updatedSections = currentSections.slice(0, numberOfLevelParts);
|
updatedSections = currentSections.slice(0, numberOfLevelParts);
|
||||||
updatedLabels = currentLabels.slice(0, numberOfLevelParts);
|
updatedLabels = currentLabels.slice(0, numberOfLevelParts);
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedExpandedSections = expandedSections.filter(
|
const updatedExpandedSections = expandedSections.filter((sectionId) => updatedSections.some((section) => section.sectionId === sectionId));
|
||||||
sectionId => updatedSections.some(section => section.sectionId === sectionId)
|
|
||||||
);
|
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'UPDATE_MODULE',
|
type: "UPDATE_MODULE",
|
||||||
payload: {
|
payload: {
|
||||||
updates: {
|
updates: {
|
||||||
sections: updatedSections,
|
sections: updatedSections,
|
||||||
sectionLabels: updatedLabels,
|
sectionLabels: updatedLabels,
|
||||||
expandedSections: updatedExpandedSections
|
expandedSections: updatedExpandedSections,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [numberOfLevelParts]);
|
}, [numberOfLevelParts]);
|
||||||
|
|
||||||
const sectionIds = sections.map((section) => section.sectionId)
|
const sectionIds = sections.map((section) => section.sectionId);
|
||||||
|
|
||||||
const updateModule = useCallback((updates: Partial<ModuleState>) => {
|
const updateModule = useCallback(
|
||||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
|
(updates: Partial<ModuleState>) => {
|
||||||
}, [dispatch]);
|
dispatch({type: "UPDATE_MODULE", payload: {updates}});
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
);
|
||||||
|
|
||||||
const toggleSection = (sectionId: number) => {
|
const toggleSection = (sectionId: number) => {
|
||||||
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
|
||||||
toast.error("Include at least one section!");
|
toast.error("Include at least one section!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch({ type: 'TOGGLE_SECTION', payload: { sectionId } });
|
dispatch({type: "TOGGLE_SECTION", payload: {sectionId}});
|
||||||
};
|
};
|
||||||
|
|
||||||
const ModuleSettings: Record<Module, React.ComponentType> = {
|
const ModuleSettings: Record<Module, React.ComponentType> = {
|
||||||
reading: ReadingSettings,
|
reading: ReadingSettings,
|
||||||
writing: WritingSettings,
|
writing: WritingSettings,
|
||||||
speaking: SpeakingSettings,
|
speaking: SpeakingSettings,
|
||||||
listening: ListeningSettings,
|
listening: ListeningSettings,
|
||||||
level: LevelSettings
|
level: LevelSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
const Settings = ModuleSettings[currentModule];
|
const Settings = ModuleSettings[currentModule];
|
||||||
const showImport = importModule && ["reading", "listening", "level"].includes(currentModule);
|
const showImport = importModule && ["reading", "listening", "level"].includes(currentModule);
|
||||||
|
|
||||||
const updateLevelParts = (parts: number) => {
|
const updateLevelParts = (parts: number) => {
|
||||||
setNumberOfLevelParts(parts);
|
setNumberOfLevelParts(parts);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showImport ? <ImportOrStartFromScratch module={currentModule} setNumberOfLevelParts={updateLevelParts} /> : (
|
{showImport ? (
|
||||||
<>
|
<ImportOrStartFromScratch module={currentModule} setNumberOfLevelParts={updateLevelParts} />
|
||||||
{isResetModuleOpen && <ResetModule module={currentModule} isOpen={isResetModuleOpen} setIsOpen={setIsResetModuleOpen} setNumberOfLevelParts={setNumberOfLevelParts}/>}
|
) : (
|
||||||
<div className="flex gap-4 w-full items-center">
|
<>
|
||||||
<div className="flex flex-col gap-3">
|
{isResetModuleOpen && (
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
<ResetModule
|
||||||
<Input
|
module={currentModule}
|
||||||
type="number"
|
isOpen={isResetModuleOpen}
|
||||||
name="minTimer"
|
setIsOpen={setIsResetModuleOpen}
|
||||||
onChange={(e) => updateModule({ minTimer: parseInt(e) < 15 ? 15 : parseInt(e) })}
|
setNumberOfLevelParts={setNumberOfLevelParts}
|
||||||
value={minTimer}
|
/>
|
||||||
className="max-w-[300px]"
|
)}
|
||||||
/>
|
<div className="flex gap-4 w-full items-center -xl:flex-col">
|
||||||
</div>
|
<div className="flex flex-row gap-3 w-full">
|
||||||
<div className="flex flex-col gap-3 flex-grow">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
<Select
|
<Input
|
||||||
isMulti={true}
|
type="number"
|
||||||
options={DIFFICULTIES.map((x) => ({
|
name="minTimer"
|
||||||
value: x,
|
onChange={(e) =>
|
||||||
label: capitalize(x)
|
updateModule({
|
||||||
}))}
|
minTimer: parseInt(e) < 15 ? 15 : parseInt(e),
|
||||||
onChange={(values) => {
|
})
|
||||||
const selectedDifficulties = values ? values.map(v => v.value as Difficulty) : [];
|
}
|
||||||
updateModule({ difficulty: selectedDifficulties });
|
value={minTimer}
|
||||||
}}
|
className="max-w-[300px]"
|
||||||
value={
|
/>
|
||||||
difficulty
|
</div>
|
||||||
? difficulty.map(d => ({
|
<div className="flex flex-col gap-3 flex-grow">
|
||||||
value: d,
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
label: capitalize(d)
|
<Select
|
||||||
}))
|
isMulti={true}
|
||||||
: null
|
options={DIFFICULTIES.map((x) => ({
|
||||||
}
|
value: x,
|
||||||
/>
|
label: capitalize(x),
|
||||||
</div>
|
}))}
|
||||||
{(sectionLabels.length != 0 && currentModule !== "level") ? (
|
onChange={(values) => {
|
||||||
<div className="flex flex-col gap-3">
|
const selectedDifficulties = values ? values.map((v) => v.value as Difficulty) : [];
|
||||||
<label className="font-normal text-base text-mti-gray-dim">{sectionLabels[0].label.split(" ")[0]}</label>
|
updateModule({difficulty: selectedDifficulties});
|
||||||
<div className="flex flex-row gap-8">
|
}}
|
||||||
{sectionLabels.map(({ id, label }) => (
|
value={
|
||||||
<span
|
difficulty
|
||||||
key={id}
|
? difficulty.map((d) => ({
|
||||||
className={clsx(
|
value: d,
|
||||||
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
label: capitalize(d),
|
||||||
"transition duration-300 ease-in-out",
|
}))
|
||||||
sectionIds.includes(id)
|
: null
|
||||||
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
|
}
|
||||||
: "bg-white border-mti-gray-platinum"
|
/>
|
||||||
)}
|
</div>
|
||||||
onClick={() => toggleSection(id)}
|
</div>
|
||||||
>
|
{sectionLabels.length != 0 && currentModule !== "level" ? (
|
||||||
{label}
|
<div className="flex flex-col gap-3 -xl:w-full">
|
||||||
</span>
|
<label className="font-normal text-base text-mti-gray-dim">{sectionLabels[0].label.split(" ")[0]}</label>
|
||||||
))}
|
<div className="flex flex-row gap-8">
|
||||||
</div>
|
{sectionLabels.map(({id, label}) => (
|
||||||
</div>
|
<span
|
||||||
|
key={id}
|
||||||
) : (
|
className={clsx(
|
||||||
<div className="flex flex-col gap-3 w-1/3">
|
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
|
"transition duration-300 ease-in-out",
|
||||||
<Input type="number" name="Number of Parts" min={1} onChange={(v) => setNumberOfLevelParts(parseInt(v))} value={numberOfLevelParts} />
|
sectionIds.includes(id)
|
||||||
</div>
|
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
|
||||||
)}
|
: "bg-white border-mti-gray-platinum",
|
||||||
<div className="flex flex-col gap-3 w-fit h-fit">
|
)}
|
||||||
<div className="h-6" />
|
onClick={() => toggleSection(id)}>
|
||||||
<Checkbox isChecked={isPrivate} onChange={(checked) => updateModule({ isPrivate: checked })}>
|
{label}
|
||||||
Privacy (Only available for Assignments)
|
</span>
|
||||||
</Checkbox>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-3 w-full">
|
) : (
|
||||||
<div className="flex flex-col gap-3 flex-grow">
|
<div className="flex flex-col gap-3 w-1/3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Exam Label *</label>
|
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="number"
|
||||||
placeholder="Exam Label"
|
name="Number of Parts"
|
||||||
name="label"
|
min={1}
|
||||||
onChange={(text) => updateModule({ examLabel: text })}
|
onChange={(v) => setNumberOfLevelParts(parseInt(v))}
|
||||||
roundness="xl"
|
value={numberOfLevelParts}
|
||||||
value={examLabel}
|
/>
|
||||||
required
|
</div>
|
||||||
/>
|
)}
|
||||||
</div>
|
<div className="flex flex-col gap-3 w-fit h-fit">
|
||||||
{currentModule === "listening" && <ListeningInstructions />}
|
<div className="h-6" />
|
||||||
<Button
|
<Checkbox
|
||||||
onClick={() => setIsResetModuleOpen(true)}
|
isChecked={isPrivate}
|
||||||
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
|
onChange={(checked) => updateModule({isPrivate: checked})}
|
||||||
className={`text-white self-end`}
|
disabled={entitiesAllowEditPrivacy.length === 0}>
|
||||||
>
|
Privacy (Only available for Assignments)
|
||||||
Reset Module
|
</Checkbox>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-8">
|
<div className="flex flex-row gap-3 w-full">
|
||||||
<Settings />
|
<div className="flex flex-col gap-3 flex-grow">
|
||||||
<div className="flex-grow max-w-[66%]">
|
<label className="font-normal text-base text-mti-gray-dim">Exam Label *</label>
|
||||||
<SectionRenderer />
|
<Input
|
||||||
</div>
|
type="text"
|
||||||
</div>
|
placeholder="Exam Label"
|
||||||
</>
|
name="label"
|
||||||
)}
|
onChange={(text) => updateModule({examLabel: text})}
|
||||||
</>
|
roundness="xl"
|
||||||
);
|
value={examLabel}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{currentModule === "listening" && <ListeningInstructions />}
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsResetModuleOpen(true)}
|
||||||
|
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
|
||||||
|
className={`text-white self-end`}>
|
||||||
|
Reset Module
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-8 -2xl:flex-col">
|
||||||
|
<Settings />
|
||||||
|
<div className="flex-grow max-w-[66%] -2xl:max-w-full">
|
||||||
|
<SectionRenderer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ExamEditor;
|
export default ExamEditor;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const DroppableQuestionArea: React.FC<DroppableQuestionAreaProps> = ({ question,
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
key={`answer_${question.id}_${answer}`}
|
key={`answer_${question.id}_${answer}`}
|
||||||
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
|
className={clsx("w-48 h-10 border-2 border-mti-purple-light self-center rounded-xl flex items-center justify-center", isOver && "border-mti-purple-dark")}>
|
||||||
{answer && `Paragraph ${answer}`}
|
{answer && `Paragraph ${answer}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Session } from "@/hooks/useSessions";
|
import { Session } from "@/hooks/useSessions";
|
||||||
import { Assignment } from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments";
|
import {
|
||||||
|
activeAssignmentFilter,
|
||||||
|
futureAssignmentFilter,
|
||||||
|
} from "@/utils/assignments";
|
||||||
import { sortByModuleName } from "@/utils/moduleUtils";
|
import { sortByModuleName } from "@/utils/moduleUtils";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -11,102 +14,124 @@ import Button from "../Low/Button";
|
|||||||
import ModuleBadge from "../ModuleBadge";
|
import ModuleBadge from "../ModuleBadge";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assignment: Assignment
|
assignment: Assignment;
|
||||||
user: User
|
user: User;
|
||||||
session?: Session
|
session?: Session;
|
||||||
startAssignment: (assignment: Assignment) => void
|
startAssignment: (assignment: Assignment) => void;
|
||||||
resumeAssignment: (session: Session) => void
|
resumeAssignment: (session: Session) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentCard({ user, assignment, session, startAssignment, resumeAssignment }: Props) {
|
export default function AssignmentCard({
|
||||||
const router = useRouter()
|
user,
|
||||||
|
assignment,
|
||||||
|
session,
|
||||||
|
startAssignment,
|
||||||
|
resumeAssignment,
|
||||||
|
}: Props) {
|
||||||
|
const hasBeenSubmitted = useMemo(
|
||||||
|
() => assignment.results.map((r) => r.user).includes(user.id),
|
||||||
|
[assignment.results, user.id]
|
||||||
|
);
|
||||||
|
|
||||||
const hasBeenSubmitted = useMemo(() => assignment.results.map((r) => r.user).includes(user.id), [assignment.results, user.id])
|
return (
|
||||||
|
<div
|
||||||
return (
|
className={clsx(
|
||||||
<div
|
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
|
||||||
className={clsx(
|
assignment.results.map((r) => r.user).includes(user.id) &&
|
||||||
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
|
"border-mti-green-light"
|
||||||
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
|
)}
|
||||||
)}
|
key={assignment.id}
|
||||||
key={assignment.id}>
|
>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
|
<h3 className="text-mti-black/90 text-xl font-semibold">
|
||||||
<span className="flex justify-between gap-1 text-lg">
|
{assignment.name}
|
||||||
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
</h3>
|
||||||
<span>-</span>
|
<span className="flex justify-between gap-1 text-lg">
|
||||||
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
</span>
|
<span>-</span>
|
||||||
</div>
|
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
<div className="flex w-full items-center justify-between">
|
</span>
|
||||||
<div className="-md:mt-2 grid w-fit min-w-[140px] grid-cols-2 grid-rows-2 place-items-center justify-between gap-4">
|
</div>
|
||||||
{assignment.exams
|
<div className="flex w-full items-center justify-between">
|
||||||
.filter((e) => e.assignee === user.id)
|
<div className="-md:mt-2 grid w-fit min-w-[140px] grid-cols-2 grid-rows-2 place-items-center justify-between gap-4">
|
||||||
.map((e) => e.module)
|
{assignment.exams
|
||||||
.sort(sortByModuleName)
|
.filter((e) => e.assignee === user.id)
|
||||||
.map((module) => (
|
.map((e) => e.module)
|
||||||
<ModuleBadge className="scale-110 w-full" key={module} module={module} />
|
.sort(sortByModuleName)
|
||||||
))}
|
.map((module) => (
|
||||||
</div>
|
<ModuleBadge
|
||||||
{futureAssignmentFilter(assignment) && !hasBeenSubmitted && (
|
className="scale-110 w-full"
|
||||||
<Button
|
key={module}
|
||||||
color="rose"
|
module={module}
|
||||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
/>
|
||||||
disabled
|
))}
|
||||||
variant="outline">
|
</div>
|
||||||
Not yet started
|
{futureAssignmentFilter(assignment) && !hasBeenSubmitted && (
|
||||||
</Button>
|
<Button
|
||||||
)}
|
color="rose"
|
||||||
{activeAssignmentFilter(assignment) && !hasBeenSubmitted && (
|
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||||
<>
|
disabled
|
||||||
<div
|
variant="outline"
|
||||||
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">
|
Not yet started
|
||||||
<Button className="h-full w-full !rounded-xl" variant="outline">
|
</Button>
|
||||||
Start
|
)}
|
||||||
</Button>
|
{activeAssignmentFilter(assignment) && !hasBeenSubmitted && (
|
||||||
</div>
|
<>
|
||||||
{!session && (
|
<div
|
||||||
<div
|
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
|
||||||
data-tip="You have already started this assignment!"
|
data-tip="Your screen size is too small to perform an assignment"
|
||||||
className={clsx(
|
>
|
||||||
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
|
<Button className="h-full w-full !rounded-xl" variant="outline">
|
||||||
!!session && "tooltip",
|
Start
|
||||||
)}>
|
</Button>
|
||||||
<Button
|
</div>
|
||||||
className={clsx("w-full h-full !rounded-xl")}
|
{!session && (
|
||||||
onClick={() => startAssignment(assignment)}
|
<div
|
||||||
variant="outline">
|
data-tip="You have already started this assignment!"
|
||||||
Start
|
className={clsx(
|
||||||
</Button>
|
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
|
||||||
</div>
|
!!session && "tooltip"
|
||||||
)}
|
)}
|
||||||
{!!session && (
|
>
|
||||||
<div
|
<Button
|
||||||
className={clsx(
|
className={clsx("w-full h-full !rounded-xl")}
|
||||||
"-md:hidden h-full w-full max-w-[50%] cursor-pointer"
|
onClick={() => startAssignment(assignment)}
|
||||||
)}>
|
variant="outline"
|
||||||
<Button
|
>
|
||||||
className={clsx("w-full h-full !rounded-xl")}
|
Start
|
||||||
onClick={() => resumeAssignment(session)}
|
</Button>
|
||||||
color="green"
|
</div>
|
||||||
variant="outline">
|
)}
|
||||||
Resume
|
{!!session && (
|
||||||
</Button>
|
<div
|
||||||
</div>
|
className={clsx(
|
||||||
)}
|
"-md:hidden h-full w-full max-w-[50%] cursor-pointer"
|
||||||
</>
|
)}
|
||||||
)}
|
>
|
||||||
{hasBeenSubmitted && (
|
<Button
|
||||||
<Button
|
className={clsx("w-full h-full !rounded-xl")}
|
||||||
color="green"
|
onClick={() => resumeAssignment(session)}
|
||||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
color="green"
|
||||||
disabled
|
variant="outline"
|
||||||
variant="outline">
|
>
|
||||||
Submitted
|
Resume
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</>
|
||||||
)
|
)}
|
||||||
|
{hasBeenSubmitted && (
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||||
|
disabled
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Submitted
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import {useListSearch} from "@/hooks/useListSearch";
|
|||||||
import usePagination from "@/hooks/usePagination";
|
import usePagination from "@/hooks/usePagination";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import {ReactNode} from "react";
|
import {ReactNode} from "react";
|
||||||
import Checkbox from "../Low/Checkbox";
|
|
||||||
import Separator from "../Low/Separator";
|
|
||||||
|
|
||||||
interface Props<T> {
|
interface Props<T> {
|
||||||
list: T[];
|
list: T[];
|
||||||
|
|||||||
@@ -1,109 +1,159 @@
|
|||||||
import { useListSearch } from "@/hooks/useListSearch"
|
import { useListSearch } from "@/hooks/useListSearch";
|
||||||
import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, useReactTable } from "@tanstack/react-table"
|
import {
|
||||||
import clsx from "clsx"
|
ColumnDef,
|
||||||
import { useEffect, useState } from "react"
|
flexRender,
|
||||||
import { BsArrowDown, BsArrowUp } from "react-icons/bs"
|
getCoreRowModel,
|
||||||
import Button from "../Low/Button"
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
PaginationState,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { BsArrowDown, BsArrowUp } from "react-icons/bs";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
interface Props<T> {
|
interface Props<T> {
|
||||||
data: T[]
|
data: T[];
|
||||||
columns: ColumnDef<any, any>[]
|
columns: ColumnDef<any, any>[];
|
||||||
searchFields: string[][]
|
searchFields: string[][];
|
||||||
size?: number
|
size?: number;
|
||||||
onDownload?: (rows: T[]) => void
|
onDownload?: (rows: T[]) => void;
|
||||||
isDownloadLoading?: boolean
|
isDownloadLoading?: boolean;
|
||||||
searchPlaceholder?: string
|
searchPlaceholder?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Table<T>({ data, columns, searchFields, size = 16, onDownload, isDownloadLoading, searchPlaceholder }: Props<T>) {
|
export default function Table<T>({
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
data,
|
||||||
pageIndex: 0,
|
columns,
|
||||||
pageSize: size,
|
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({
|
const table = useReactTable({
|
||||||
data: rows,
|
data: rows,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
state: {
|
state: {
|
||||||
pagination
|
pagination,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col gap-2">
|
<div className="w-full flex flex-col gap-2">
|
||||||
<div className="w-full flex gap-2 items-end">
|
<div className="w-full flex gap-2 items-end">
|
||||||
{renderSearch()}
|
{renderSearch()}
|
||||||
{onDownload && (
|
{onDownload && (
|
||||||
<Button isLoading={isDownloadLoading} className="w-full max-w-[200px] mb-1" variant="outline" onClick={() => onDownload(rows)}>
|
<Button
|
||||||
Download
|
isLoading={isDownloadLoading}
|
||||||
</Button>
|
className="w-full max-w-[200px] mb-1"
|
||||||
)
|
variant="outline"
|
||||||
}
|
onClick={() => onDownload(rows)}
|
||||||
</div>
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex gap-2 justify-between items-center">
|
<div className="w-full flex gap-2 justify-between items-center">
|
||||||
<div className="flex items-center gap-4 w-fit">
|
<div className="flex items-center gap-4 w-fit">
|
||||||
<Button className="w-[200px] h-fit" disabled={!table.getCanPreviousPage()} onClick={() => table.previousPage()}>
|
<Button
|
||||||
Previous Page
|
className="w-[200px] h-fit"
|
||||||
</Button>
|
disabled={!table.getCanPreviousPage()}
|
||||||
</div>
|
onClick={() => table.previousPage()}
|
||||||
<div className="flex items-center gap-4 w-fit">
|
>
|
||||||
<span className="flex items-center gap-1">
|
Previous Page
|
||||||
<div>Page</div>
|
</Button>
|
||||||
<strong>
|
</div>
|
||||||
{table.getState().pagination.pageIndex + 1} of{' '}
|
<div className="flex items-center gap-4 w-fit">
|
||||||
{table.getPageCount().toLocaleString()}
|
<span className="flex items-center gap-1">
|
||||||
</strong>
|
<div>Page</div>
|
||||||
<div>| Total: {table.getRowCount().toLocaleString()}</div>
|
<strong>
|
||||||
</span>
|
{table.getState().pagination.pageIndex + 1} of{" "}
|
||||||
<Button className="w-[200px]" disabled={!table.getCanNextPage()} onClick={() => table.nextPage()}>
|
{table.getPageCount().toLocaleString()}
|
||||||
Next Page
|
</strong>
|
||||||
</Button>
|
<div>| Total: {table.getRowCount().toLocaleString()}</div>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<Button
|
||||||
|
className="w-[200px]"
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
>
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
<thead>
|
<thead>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th className="py-4 px-4 text-left" key={header.id} colSpan={header.colSpan}>
|
<th
|
||||||
<div
|
className="py-4 px-4 text-left"
|
||||||
className={clsx(header.column.getCanSort() && 'cursor-pointer select-none', 'flex items-center gap-2')}
|
key={header.id}
|
||||||
onClick={header.column.getToggleSortingHandler()}
|
colSpan={header.colSpan}
|
||||||
>
|
>
|
||||||
{flexRender(
|
<div
|
||||||
header.column.columnDef.header,
|
className={clsx(
|
||||||
header.getContext()
|
header.column.getCanSort() &&
|
||||||
)}
|
"cursor-pointer select-none",
|
||||||
{{
|
"flex items-center gap-2"
|
||||||
asc: <BsArrowUp />,
|
)}
|
||||||
desc: <BsArrowDown />,
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
}[header.column.getIsSorted() as string] ?? null}
|
>
|
||||||
</div>
|
{flexRender(
|
||||||
</th>
|
header.column.columnDef.header,
|
||||||
))}
|
header.getContext()
|
||||||
</tr>
|
)}
|
||||||
))}
|
{{
|
||||||
</thead>
|
asc: <BsArrowUp />,
|
||||||
<tbody className="px-2 w-full">
|
desc: <BsArrowDown />,
|
||||||
{table.getRowModel().rows.map((row) => (
|
}[header.column.getIsSorted() as string] ?? null}
|
||||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
</div>
|
||||||
{row.getVisibleCells().map((cell) => (
|
</th>
|
||||||
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
|
))}
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
</tr>
|
||||||
</td>
|
))}
|
||||||
))}
|
</thead>
|
||||||
</tr>
|
<tbody className="px-2 w-full">
|
||||||
))}
|
{table.getRowModel().rows.map((row) => (
|
||||||
</tbody>
|
<tr
|
||||||
</table>
|
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
||||||
</div>
|
key={row.id}
|
||||||
)
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="min-h-screen flex justify-center items-start">
|
||||||
|
<span className="loading loading-infinity w-32" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
BsClipboardData,
|
BsClipboardData,
|
||||||
BsPeople,
|
BsPeople,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
|
import { GoWorkflow } from "react-icons/go";
|
||||||
import { CiDumbbell } from "react-icons/ci";
|
import { CiDumbbell } from "react-icons/ci";
|
||||||
import { RiLogoutBoxFill } from "react-icons/ri";
|
import { RiLogoutBoxFill } from "react-icons/ri";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -149,6 +150,7 @@ export default function Sidebar({
|
|||||||
viewSettings: true,
|
viewSettings: true,
|
||||||
viewPaymentRecord: true,
|
viewPaymentRecord: true,
|
||||||
viewGeneration: true,
|
viewGeneration: true,
|
||||||
|
viewApprovalWorkflows: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const sidebarPermissions: { [key: string]: boolean } = {
|
const sidebarPermissions: { [key: string]: boolean } = {
|
||||||
@@ -160,6 +162,7 @@ export default function Sidebar({
|
|||||||
viewSettings: false,
|
viewSettings: false,
|
||||||
viewPaymentRecord: false,
|
viewPaymentRecord: false,
|
||||||
viewGeneration: false,
|
viewGeneration: false,
|
||||||
|
viewApprovalWorkflows: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!user || !user?.type) return sidebarPermissions;
|
if (!user || !user?.type) return sidebarPermissions;
|
||||||
@@ -197,6 +200,7 @@ export default function Sidebar({
|
|||||||
(entitiesAllowGeneration.length > 0 || isAdmin)
|
(entitiesAllowGeneration.length > 0 || isAdmin)
|
||||||
) {
|
) {
|
||||||
sidebarPermissions["viewGeneration"] = true;
|
sidebarPermissions["viewGeneration"] = true;
|
||||||
|
sidebarPermissions["viewApprovalWorkflows"] = true;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
getTypesOfUser(["agent"]).includes(user.type) &&
|
getTypesOfUser(["agent"]).includes(user.type) &&
|
||||||
@@ -260,7 +264,7 @@ export default function Sidebar({
|
|||||||
<section
|
<section
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
|
||||||
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
|
isMinimized ? "w-fit" : "-xl:w-20 w-1/6",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -364,6 +368,17 @@ export default function Sidebar({
|
|||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{sidebarPermissions["viewApprovalWorkflows"] && (
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={GoWorkflow}
|
||||||
|
label="Approval Workflows"
|
||||||
|
path={path}
|
||||||
|
keyPath="/approval-workflows"
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||||
<Nav
|
<Nav
|
||||||
@@ -432,6 +447,16 @@ export default function Sidebar({
|
|||||||
isMinimized
|
isMinimized
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{sidebarPermissions["viewApprovalWorkflows"] && (
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={GoWorkflow}
|
||||||
|
label="Approval Workflows"
|
||||||
|
path={path}
|
||||||
|
keyPath="/approval-workflows"
|
||||||
|
isMinimized
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8">
|
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function QuestionSolutionArea({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-56 h-10 border rounded-xl items-center justify-center flex gap-3 px-2",
|
"w-56 h-10 border self-center rounded-xl items-center justify-center flex gap-3 px-2",
|
||||||
!userSolution
|
!userSolution
|
||||||
? "border-mti-gray-davy"
|
? "border-mti-gray-davy"
|
||||||
: userSolution.option.toString() === question.solution.toString()
|
: userSolution.option.toString() === question.solution.toString()
|
||||||
|
|||||||
204
src/demo/approval_workflows.json
Normal file
204
src/demo/approval_workflows.json
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "kajhfakscbka-asacaca-acawesae",
|
||||||
|
"name": "English Exam 1st Quarter 2025",
|
||||||
|
"entityId": "64a92896-fa8c-4908-95f3-23ffe05560c5",
|
||||||
|
"modules": [
|
||||||
|
"reading",
|
||||||
|
"writing"
|
||||||
|
],
|
||||||
|
"requester": "ffdIipRyXTRmm10Sq2eg7P97rLB2",
|
||||||
|
"startDate": 1737712243906,
|
||||||
|
"status": "pending",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"stepType": "form-intake",
|
||||||
|
"stepNumber": 1,
|
||||||
|
"completed": true,
|
||||||
|
"completedBy": "5fZibjknlJdfIZVndlV2FIdamtn1",
|
||||||
|
"completedDate": 1737712243906,
|
||||||
|
"firstStep": true,
|
||||||
|
"assignees": [
|
||||||
|
"5fZibjknlJdfIZVndlV2FIdamtn1",
|
||||||
|
"50jqJuESQNX0Qas64B5JZBQTIiq1",
|
||||||
|
"2rtgJKmBXfWFzrtG8AjFgyrGBcp1"
|
||||||
|
],
|
||||||
|
"comments": "This is a random comment\nThis is a random comment\nThis is a random comment\nThis is a random comment\nThis is a random comment\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stepType": "approval-by",
|
||||||
|
"stepNumber": 2,
|
||||||
|
"completed": true,
|
||||||
|
"completedBy": "50jqJuESQNX0Qas64B5JZBQTIiq1",
|
||||||
|
"completedDate": 1737712243906,
|
||||||
|
"assignees": [
|
||||||
|
"5fZibjknlJdfIZVndlV2FIdamtn1",
|
||||||
|
"50jqJuESQNX0Qas64B5JZBQTIiq1",
|
||||||
|
"2rtgJKmBXfWFzrtG8AjFgyrGBcp1"
|
||||||
|
],
|
||||||
|
"comments": "This is a random comment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stepType": "approval-by",
|
||||||
|
"stepNumber": 3,
|
||||||
|
"completed": false,
|
||||||
|
"assignees": [
|
||||||
|
"5fZibjknlJdfIZVndlV2FIdamtn1",
|
||||||
|
"50jqJuESQNX0Qas64B5JZBQTIiq1",
|
||||||
|
"2rtgJKmBXfWFzrtG8AjFgyrGBcp1"
|
||||||
|
],
|
||||||
|
"comments": "This is a random comment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stepType": "approval-by",
|
||||||
|
"stepNumber": 4,
|
||||||
|
"completed": false,
|
||||||
|
"assignees": [
|
||||||
|
"50jqJuESQNX0Qas64B5JZBQTIiq1"
|
||||||
|
],
|
||||||
|
"comments": "This is a random comment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stepType": "approval-by",
|
||||||
|
"stepNumber": 5,
|
||||||
|
"completed": false,
|
||||||
|
"finalStep": true,
|
||||||
|
"assignees": [
|
||||||
|
"50jqJuESQNX0Qas64B5JZBQTIiq1",
|
||||||
|
"2rtgJKmBXfWFzrtG8AjFgyrGBcp1"
|
||||||
|
],
|
||||||
|
"comments": "This is a random comment"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "aaaaaakscbka-asacaca-acawesae",
|
||||||
|
"name": "English Exam 2nd Quarter 2025",
|
||||||
|
"entityId": "64a92896-fa8c-4908-95f3-23ffe05560c5",
|
||||||
|
"modules": [
|
||||||
|
"reading",
|
||||||
|
"writing",
|
||||||
|
"level",
|
||||||
|
"speaking",
|
||||||
|
"listening"
|
||||||
|
],
|
||||||
|
"requester": "231c84b2-a65a-49a9-803c-c664d84b13e0",
|
||||||
|
"startDate": 1737712243906,
|
||||||
|
"status": "approved",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"stepType": "form-intake",
|
||||||
|
"stepNumber": 1,
|
||||||
|
"completed": true,
|
||||||
|
"completedBy": "fd5fce42-4bcc-4150-a143-b484e750b265",
|
||||||
|
"completedDate": 1737712243906,
|
||||||
|
"firstStep": true,
|
||||||
|
"assignees": [
|
||||||
|
"fd5fce42-4bcc-4150-a143-b484e750b265",
|
||||||
|
"231c84b2-a65a-49a9-803c-c664d84b13e0",
|
||||||
|
"c5fc1514-1a94-4f8c-a046-a62099097a50"
|
||||||
|
],
|
||||||
|
"comments": "This is a random comment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stepType": "approval-by",
|
||||||
|
"stepNumber": 2,
|
||||||
|
"completed": true,
|
||||||
|
"completedBy": "rTh9yz6Z1WOidHlVOSGInlpoxrk1",
|
||||||
|
"completedDate": 1737712243906,
|
||||||
|
"assignees": [
|
||||||
|
"fd5fce42-4bcc-4150-a143-b484e750b265",
|
||||||
|
"rTh9yz6Z1WOidHlVOSGInlpoxrk1",
|
||||||
|
"c5fc1514-1a94-4f8c-a046-a62099097a50"
|
||||||
|
],
|
||||||
|
"comments": "This is a random comment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stepType": "approval-by",
|
||||||
|
"stepNumber": 3,
|
||||||
|
"completed": true,
|
||||||
|
"completedBy": "231c84b2-a65a-49a9-803c-c664d84b13e0",
|
||||||
|
"completedDate": 1737712243906,
|
||||||
|
"assignees": [
|
||||||
|
"fd5fce42-4bcc-4150-a143-b484e750b265",
|
||||||
|
"231c84b2-a65a-49a9-803c-c664d84b13e0",
|
||||||
|
"c5fc1514-1a94-4f8c-a046-a62099097a50"
|
||||||
|
],
|
||||||
|
"comments": "This is a random comment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stepType": "approval-by",
|
||||||
|
"stepNumber": 4,
|
||||||
|
"completed": true,
|
||||||
|
"completedBy": "231c84b2-a65a-49a9-803c-c664d84b13e0",
|
||||||
|
"completedDate": 1737712243906,
|
||||||
|
"assignees": [
|
||||||
|
"fd5fce42-4bcc-4150-a143-b484e750b265"
|
||||||
|
],
|
||||||
|
"comments": "This is a random comment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stepType": "approval-by",
|
||||||
|
"stepNumber": 5,
|
||||||
|
"completed": true,
|
||||||
|
"completedBy": "c5fc1514-1a94-4f8c-a046-a62099097a50",
|
||||||
|
"completedDate": 1737712243906,
|
||||||
|
"finalStep": true,
|
||||||
|
"assignees": [
|
||||||
|
"rTh9yz6Z1WOidHlVOSGInlpoxrk1",
|
||||||
|
"c5fc1514-1a94-4f8c-a046-a62099097a50"
|
||||||
|
],
|
||||||
|
"comments": "This is a random comment"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bbbbkscbka-asacaca-acawesae",
|
||||||
|
"name": "English Exam 3rd Quarter 2025",
|
||||||
|
"entityId": "49ed2f0c-7d0d-46e4-9576-7cf19edc4980",
|
||||||
|
"modules": [
|
||||||
|
"reading"
|
||||||
|
],
|
||||||
|
"requester": "rTh9yz6Z1WOidHlVOSGInlpoxrk1",
|
||||||
|
"startDate": 1737712243906,
|
||||||
|
"status": "rejected",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"stepType": "form-intake",
|
||||||
|
"stepNumber": 1,
|
||||||
|
"completed": true,
|
||||||
|
"completedBy": "231c84b2-a65a-49a9-803c-c664d84b13e0",
|
||||||
|
"completedDate": 1737712243906,
|
||||||
|
"firstStep": true,
|
||||||
|
"assignees": [
|
||||||
|
"fd5fce42-4bcc-4150-a143-b484e750b265",
|
||||||
|
"231c84b2-a65a-49a9-803c-c664d84b13e0",
|
||||||
|
"c5fc1514-1a94-4f8c-a046-a62099097a50"
|
||||||
|
],
|
||||||
|
"comments": "This is a random comment\nThis is a random comment\nThis is a random comment\nThis is a random comment\nThis is a random comment\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stepType": "approval-by",
|
||||||
|
"stepNumber": 2,
|
||||||
|
"completed": true,
|
||||||
|
"completedBy": "rTh9yz6Z1WOidHlVOSGInlpoxrk1",
|
||||||
|
"completedDate": 1737712243906,
|
||||||
|
"assignees": [
|
||||||
|
"rTh9yz6Z1WOidHlVOSGInlpoxrk1",
|
||||||
|
"c5fc1514-1a94-4f8c-a046-a62099097a50"
|
||||||
|
],
|
||||||
|
"comments": "This is a random comment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stepType": "approval-by",
|
||||||
|
"stepNumber": 3,
|
||||||
|
"completed": false,
|
||||||
|
"finalStep": true,
|
||||||
|
"assignees": [
|
||||||
|
"rTh9yz6Z1WOidHlVOSGInlpoxrk1"
|
||||||
|
],
|
||||||
|
"comments": "This is a random comment"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -89,7 +89,7 @@ export default function Selection({ user, page, onStart }: Props) {
|
|||||||
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
|
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
|
||||||
),
|
),
|
||||||
label: "Reading",
|
label: "Reading",
|
||||||
value: reading,
|
value: reading || 0,
|
||||||
tooltip: "The amount of reading exams performed.",
|
tooltip: "The amount of reading exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -97,7 +97,7 @@ export default function Selection({ user, page, onStart }: Props) {
|
|||||||
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
|
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
|
||||||
),
|
),
|
||||||
label: "Listening",
|
label: "Listening",
|
||||||
value: listening,
|
value: listening || 0,
|
||||||
tooltip: "The amount of listening exams performed.",
|
tooltip: "The amount of listening exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -105,7 +105,7 @@ export default function Selection({ user, page, onStart }: Props) {
|
|||||||
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
|
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
|
||||||
),
|
),
|
||||||
label: "Writing",
|
label: "Writing",
|
||||||
value: writing,
|
value: writing || 0,
|
||||||
tooltip: "The amount of writing exams performed.",
|
tooltip: "The amount of writing exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -113,7 +113,7 @@ export default function Selection({ user, page, onStart }: Props) {
|
|||||||
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
|
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
|
||||||
),
|
),
|
||||||
label: "Speaking",
|
label: "Speaking",
|
||||||
value: speaking,
|
value: speaking || 0,
|
||||||
tooltip: "The amount of speaking exams performed.",
|
tooltip: "The amount of speaking exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -121,7 +121,7 @@ export default function Selection({ user, page, onStart }: Props) {
|
|||||||
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
|
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
|
||||||
),
|
),
|
||||||
label: "Level",
|
label: "Level",
|
||||||
value: level,
|
value: level || 0,
|
||||||
tooltip: "The amount of level exams performed.",
|
tooltip: "The amount of level exams performed.",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
24
src/hooks/useApprovalWorkflow.tsx
Normal file
24
src/hooks/useApprovalWorkflow.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function useApprovalWorkflow(id: string) {
|
||||||
|
const [workflow, setWorkflow] = useState<ApprovalWorkflow>();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const getData = useCallback(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.get<ApprovalWorkflow>(`/api/approval-workflows/${id}`)
|
||||||
|
.then((response) => setWorkflow(response.data))
|
||||||
|
.catch((error) => {
|
||||||
|
setIsError(true);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(getData, [getData]);
|
||||||
|
|
||||||
|
return { workflow, isLoading, isError, reload: getData };
|
||||||
|
}
|
||||||
24
src/hooks/useApprovalWorkflows.tsx
Normal file
24
src/hooks/useApprovalWorkflows.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function useApprovalWorkflows() {
|
||||||
|
const [workflows, setWorkflows] = useState<ApprovalWorkflow[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const getData = useCallback(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.get<ApprovalWorkflow[]>(`/api/approval-workflows`)
|
||||||
|
.then((response) => setWorkflows(response.data))
|
||||||
|
.catch((error) => {
|
||||||
|
setIsError(true);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(getData, [getData]);
|
||||||
|
|
||||||
|
return { workflows, isLoading, isError, reload: getData };
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { EntityWithRoles, WithLabeledEntities } from "@/interfaces/entity";
|
import { WithLabeledEntities } from "@/interfaces/entity";
|
||||||
import { Discount } from "@/interfaces/paypal";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import { Code, Group, Type, User } from "@/interfaces/user";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
@@ -12,7 +11,9 @@ export default function useEntitiesUsers(type?: Type) {
|
|||||||
const getData = () => {
|
const getData = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.get<WithLabeledEntities<User>[]>(`/api/entities/users${type ? "?type=" + type : ""}`)
|
.get<WithLabeledEntities<User>[]>(
|
||||||
|
`/api/entities/users${type ? "?type=" + type : ""}`
|
||||||
|
)
|
||||||
.then((response) => setUsers(response.data))
|
.then((response) => setUsers(response.data))
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|||||||
72
src/interfaces/approval.workflow.ts
Normal file
72
src/interfaces/approval.workflow.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { ObjectId } from "mongodb";
|
||||||
|
import { Module } from ".";
|
||||||
|
import { Type, User, userTypeLabels, userTypeLabelsShort } from "./user";
|
||||||
|
|
||||||
|
export interface ApprovalWorkflow {
|
||||||
|
_id?: ObjectId,
|
||||||
|
name: string,
|
||||||
|
entityId: string,
|
||||||
|
requester: User["id"],
|
||||||
|
startDate: number,
|
||||||
|
modules: Module[],
|
||||||
|
examId?: string,
|
||||||
|
status: ApprovalWorkflowStatus,
|
||||||
|
steps: WorkflowStep[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditableApprovalWorkflow extends Omit<ApprovalWorkflow, "_id" | "steps"> {
|
||||||
|
id: string,
|
||||||
|
steps: EditableWorkflowStep[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StepType = "form-intake" | "approval-by";
|
||||||
|
export const StepTypeLabel: Record<StepType, string> = {
|
||||||
|
"form-intake": "Form Intake",
|
||||||
|
"approval-by": "Approval",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface WorkflowStep {
|
||||||
|
stepType: StepType,
|
||||||
|
stepNumber: number,
|
||||||
|
completed: boolean,
|
||||||
|
rejected?: boolean,
|
||||||
|
completedBy?: User["id"],
|
||||||
|
completedDate?: number,
|
||||||
|
assignees: (User["id"])[];
|
||||||
|
firstStep?: boolean,
|
||||||
|
finalStep?: boolean,
|
||||||
|
selected?: boolean,
|
||||||
|
comments?: string,
|
||||||
|
examChanges?: string[],
|
||||||
|
onClick?: React.MouseEventHandler<HTMLDivElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditableWorkflowStep {
|
||||||
|
key: number,
|
||||||
|
stepType: StepType,
|
||||||
|
stepNumber: number,
|
||||||
|
completed: boolean,
|
||||||
|
rejected?: boolean,
|
||||||
|
completedBy?: User["id"],
|
||||||
|
completedDate?: number,
|
||||||
|
assignees: (User["id"] | null | undefined)[]; // bit of an hack, but allowing null or undefined values allows us to match one to one the select input components with the assignees array. And since select inputs allow undefined or null values, it is allowed here too, but must validate required input before form submission
|
||||||
|
firstStep: boolean,
|
||||||
|
finalStep?: boolean,
|
||||||
|
onDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserTypeLabel(type: Type | undefined): string {
|
||||||
|
if (type) return userTypeLabels[type];
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
export function getUserTypeLabelShort(type: Type | undefined): string {
|
||||||
|
if (type) return userTypeLabelsShort[type];
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApprovalWorkflowStatus = "approved" | "pending" | "rejected";
|
||||||
|
export const ApprovalWorkflowStatusLabel: Record<ApprovalWorkflowStatus, string> = {
|
||||||
|
approved: "Approved",
|
||||||
|
pending: "Pending",
|
||||||
|
rejected: "Rejected",
|
||||||
|
};
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
export type Module = "reading" | "listening" | "writing" | "speaking" | "level";
|
export type Module = "reading" | "listening" | "writing" | "speaking" | "level";
|
||||||
|
export const ModuleTypeLabels: Record<Module, string> = {
|
||||||
|
reading: "Reading",
|
||||||
|
listening: "Listening",
|
||||||
|
writing: "Writing",
|
||||||
|
speaking: "Speaking",
|
||||||
|
level: "Level",
|
||||||
|
};
|
||||||
|
|
||||||
export interface Step {
|
export interface Step {
|
||||||
min: number;
|
min: number;
|
||||||
|
|||||||
@@ -170,4 +170,24 @@ export interface Code {
|
|||||||
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate";
|
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate";
|
||||||
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"];
|
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"];
|
||||||
|
|
||||||
|
export const userTypeLabels: Record<Type, string> = {
|
||||||
|
student: "Student",
|
||||||
|
teacher: "Teacher",
|
||||||
|
corporate: "Corporate",
|
||||||
|
admin: "Admin",
|
||||||
|
developer: "Developer",
|
||||||
|
agent: "Agent",
|
||||||
|
mastercorporate: "Master Corporate",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userTypeLabelsShort: Record<Type, string> = {
|
||||||
|
student: "",
|
||||||
|
teacher: "Prof.",
|
||||||
|
corporate: "Dir.",
|
||||||
|
admin: "Admin",
|
||||||
|
developer: "Dev.",
|
||||||
|
agent: "Agent",
|
||||||
|
mastercorporate: "Dir.",
|
||||||
|
};
|
||||||
|
|
||||||
export type WithUser<T> = T extends { participants: string[] } ? Omit<T, "participants"> & { participants: User[] } : T;
|
export type WithUser<T> = T extends { participants: string[] } ? Omit<T, "participants"> & { participants: User[] } : T;
|
||||||
|
|||||||
34
src/lib/createWorkflowsOnExamCreation.ts
Normal file
34
src/lib/createWorkflowsOnExamCreation.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ if (!process.env.MONGODB_URI) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uri = process.env.MONGODB_URI || "";
|
const uri = process.env.MONGODB_URI || "";
|
||||||
const options = {};
|
const options = {
|
||||||
|
maxPoolSize: 10,
|
||||||
|
};
|
||||||
|
|
||||||
let client: MongoClient;
|
let client: MongoClient;
|
||||||
|
|
||||||
|
|||||||
@@ -5,192 +5,329 @@ import Separator from "@/components/Low/Separator";
|
|||||||
import { Grading, Step } from "@/interfaces";
|
import { Grading, Step } from "@/interfaces";
|
||||||
import { Entity } from "@/interfaces/entity";
|
import { Entity } from "@/interfaces/entity";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import { CEFR_STEPS, GENERAL_STEPS, IELTS_STEPS, TOFEL_STEPS } from "@/resources/grading";
|
import {
|
||||||
import { mapBy } from "@/utils";
|
CEFR_STEPS,
|
||||||
|
GENERAL_STEPS,
|
||||||
|
IELTS_STEPS,
|
||||||
|
TOFEL_STEPS,
|
||||||
|
} from "@/resources/grading";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Divider } from "primereact/divider";
|
import {
|
||||||
import { useEffect, useState } from "react";
|
Dispatch,
|
||||||
|
memo,
|
||||||
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { BsPlusCircle, BsTrash } from "react-icons/bs";
|
import { BsPlusCircle, BsTrash } from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
const areStepsOverlapped = (steps: Step[]) => {
|
const areStepsOverlapped = (steps: Step[]) => {
|
||||||
for (let i = 0; i < steps.length; i++) {
|
for (let i = 0; i < steps.length; i++) {
|
||||||
if (i === 0) continue;
|
if (i === 0) continue;
|
||||||
|
|
||||||
const step = steps[i];
|
const step = steps[i];
|
||||||
const previous = steps[i - 1];
|
const previous = steps[i - 1];
|
||||||
|
|
||||||
if (previous.max >= step.min) return true;
|
if (previous.max >= step.min) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
interface RowProps {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
index: number;
|
||||||
|
label: string;
|
||||||
|
isLast: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
setSteps: Dispatch<SetStateAction<Step[]>>;
|
||||||
|
addRow: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GradingRow({
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
label,
|
||||||
|
index,
|
||||||
|
isLoading,
|
||||||
|
isLast,
|
||||||
|
setSteps,
|
||||||
|
addRow,
|
||||||
|
}: RowProps) {
|
||||||
|
const onChangeMin = useCallback(
|
||||||
|
(e: string) => {
|
||||||
|
setSteps((prev) =>
|
||||||
|
prev.map((x, i) => (i === index ? { ...x, min: parseInt(e) } : x))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[index, setSteps]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onChangeMax = useCallback(
|
||||||
|
(e: string) => {
|
||||||
|
setSteps((prev) =>
|
||||||
|
prev.map((x, i) => (i === index ? { ...x, max: parseInt(e) } : x))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[index, setSteps]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onChangeLabel = useCallback(
|
||||||
|
(e: string) => {
|
||||||
|
setSteps((prev) =>
|
||||||
|
prev.map((x, i) => (i === index ? { ...x, label: e } : x))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[index, setSteps]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onAddRow = useCallback(() => addRow(index), [addRow, index]);
|
||||||
|
|
||||||
|
const removeRow = useCallback(
|
||||||
|
() => setSteps((prev) => prev.filter((_, i) => i !== index)),
|
||||||
|
[index, setSteps]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4 w-full">
|
||||||
|
<Input
|
||||||
|
label="Min. Percentage"
|
||||||
|
value={min}
|
||||||
|
type="number"
|
||||||
|
disabled={index === 0 || isLoading}
|
||||||
|
onChange={onChangeMin}
|
||||||
|
name="min"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Grade"
|
||||||
|
value={label}
|
||||||
|
type="text"
|
||||||
|
disabled={isLoading}
|
||||||
|
onChange={onChangeLabel}
|
||||||
|
name="min"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Max. Percentage"
|
||||||
|
value={max}
|
||||||
|
type="number"
|
||||||
|
disabled={isLast || isLoading}
|
||||||
|
onChange={onChangeMax}
|
||||||
|
name="max"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{index !== 0 && !isLast && (
|
||||||
|
<button
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pt-9 text-xl group"
|
||||||
|
onClick={removeRow}
|
||||||
|
>
|
||||||
|
<div className="w-full h-full flex items-center justify-center group-hover:bg-neutral-200 rounded-full p-3 transition ease-in-out duration-300">
|
||||||
|
<BsTrash />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLast && (
|
||||||
|
<Button
|
||||||
|
className="w-full flex items-center justify-center"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={onAddRow}
|
||||||
|
>
|
||||||
|
<BsPlusCircle />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const GradingRowMemo = memo(GradingRow);
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
entitiesGrading: Grading[];
|
entitiesGrading: Grading[];
|
||||||
entities: Entity[]
|
entities: Entity[];
|
||||||
mutate: () => void
|
mutate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CorporateGradingSystem({ user, entitiesGrading = [], entities = [], mutate }: Props) {
|
export default function CorporateGradingSystem({
|
||||||
const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined)
|
user,
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
entitiesGrading = [],
|
||||||
const [steps, setSteps] = useState<Step[]>([]);
|
entities = [],
|
||||||
const [otherEntities, setOtherEntities] = useState<string[]>([])
|
mutate,
|
||||||
|
}: Props) {
|
||||||
|
const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [steps, setSteps] = useState<Step[]>([]);
|
||||||
|
const [otherEntities, setOtherEntities] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (entity) {
|
if (entity) {
|
||||||
const entitySteps = entitiesGrading.find(e => e.entity === entity)!.steps
|
const entitySteps = entitiesGrading.find(
|
||||||
setSteps(entitySteps || [])
|
(e) => e.entity === entity
|
||||||
}
|
)!.steps;
|
||||||
}, [entitiesGrading, entity])
|
setSteps(entitySteps || []);
|
||||||
|
}
|
||||||
|
}, [entitiesGrading, entity]);
|
||||||
|
|
||||||
const saveGradingSystem = () => {
|
const saveGradingSystem = () => {
|
||||||
if (!steps.every((x) => x.min < x.max)) return toast.error("One of your steps has a minimum threshold inferior to its superior threshold.");
|
if (!steps.every((x) => x.min < x.max))
|
||||||
if (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps.");
|
return toast.error(
|
||||||
if (
|
"One of your steps has a minimum threshold inferior to its superior threshold."
|
||||||
steps.reduce((acc, curr) => {
|
);
|
||||||
return acc - (curr.max - curr.min + 1);
|
if (areStepsOverlapped(steps))
|
||||||
}, 100) > 0
|
return toast.error("There seems to be an overlap in one of your steps.");
|
||||||
)
|
if (
|
||||||
return toast.error("There seems to be an open interval in your steps.");
|
steps.reduce((acc, curr) => {
|
||||||
|
return acc - (curr.max - curr.min + 1);
|
||||||
|
}, 100) > 0
|
||||||
|
)
|
||||||
|
return toast.error("There seems to be an open interval in your steps.");
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.post("/api/grading", { user: user.id, entity, steps })
|
.post("/api/grading", { user: user.id, entity, steps })
|
||||||
.then(() => toast.success("Your grading system has been saved!"))
|
.then(() => toast.success("Your grading system has been saved!"))
|
||||||
.then(mutate)
|
.then(mutate)
|
||||||
.catch(() => toast.error("Something went wrong, please try again later"))
|
.catch(() => toast.error("Something went wrong, please try again later"))
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyToOtherEntities = () => {
|
const applyToOtherEntities = () => {
|
||||||
if (!steps.every((x) => x.min < x.max)) return toast.error("One of your steps has a minimum threshold inferior to its superior threshold.");
|
if (!steps.every((x) => x.min < x.max))
|
||||||
if (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps.");
|
return toast.error(
|
||||||
if (
|
"One of your steps has a minimum threshold inferior to its superior threshold."
|
||||||
steps.reduce((acc, curr) => {
|
);
|
||||||
return acc - (curr.max - curr.min + 1);
|
if (areStepsOverlapped(steps))
|
||||||
}, 100) > 0
|
return toast.error("There seems to be an overlap in one of your steps.");
|
||||||
)
|
if (
|
||||||
return toast.error("There seems to be an open interval in your steps.");
|
steps.reduce((acc, curr) => {
|
||||||
|
return acc - (curr.max - curr.min + 1);
|
||||||
|
}, 100) > 0
|
||||||
|
)
|
||||||
|
return toast.error("There seems to be an open interval in your steps.");
|
||||||
|
|
||||||
if (otherEntities.length === 0) return toast.error("Select at least one entity")
|
if (otherEntities.length === 0)
|
||||||
|
return toast.error("Select at least one entity");
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.post("/api/grading/multiple", { user: user.id, entities: otherEntities, steps })
|
.post("/api/grading/multiple", {
|
||||||
.then(() => toast.success("Your grading system has been saved!"))
|
user: user.id,
|
||||||
.then(mutate)
|
entities: otherEntities,
|
||||||
.catch(() => toast.error("Something went wrong, please try again later"))
|
steps,
|
||||||
.finally(() => setIsLoading(false));
|
})
|
||||||
};
|
.then(() => toast.success("Your grading system has been saved!"))
|
||||||
|
.then(mutate)
|
||||||
|
.catch(() => toast.error("Something went wrong, please try again later"))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const addRow = useCallback((index: number) => {
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
setSteps((prev) => {
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Grading System</label>
|
const item = {
|
||||||
<div className={clsx("flex flex-col gap-4")}>
|
min: prev[index === 0 ? 0 : index - 1].max + 1,
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
|
max: prev[index + 1].min - 1,
|
||||||
<Select
|
label: "",
|
||||||
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
|
};
|
||||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
return [
|
||||||
onChange={(e) => setEntity(e?.value || undefined)}
|
...prev.slice(0, index + 1),
|
||||||
isClearable={checkAccess(user, ["admin", "developer"])}
|
item,
|
||||||
/>
|
...prev.slice(index + 1, prev.length),
|
||||||
</div>
|
];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
{entities.length > 1 && (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||||
<Separator />
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Apply this grading system to other entities</label>
|
Grading System
|
||||||
<Select
|
</label>
|
||||||
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
onChange={(e) => !e ? setOtherEntities([]) : setOtherEntities(e.map(o => o.value!))}
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
isMulti
|
Entity
|
||||||
/>
|
</label>
|
||||||
<Button onClick={applyToOtherEntities} isLoading={isLoading} disabled={isLoading || otherEntities.length === 0} variant="outline">
|
<Select
|
||||||
Apply to {otherEntities.length} other entities
|
defaultValue={{
|
||||||
</Button>
|
value: (entities || [])[0]?.id,
|
||||||
<Separator />
|
label: (entities || [])[0]?.label,
|
||||||
</>
|
}}
|
||||||
)}
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
|
onChange={(e) => setEntity(e?.value || undefined)}
|
||||||
|
isClearable={checkAccess(user, ["admin", "developer"])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Preset Systems</label>
|
{entities.length > 1 && (
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<>
|
||||||
<Button variant="outline" onClick={() => setSteps(CEFR_STEPS)}>
|
<Separator />
|
||||||
CEFR
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
</Button>
|
Apply this grading system to other entities
|
||||||
<Button variant="outline" onClick={() => setSteps(GENERAL_STEPS)}>
|
</label>
|
||||||
General English
|
<Select
|
||||||
</Button>
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
<Button variant="outline" onClick={() => setSteps(IELTS_STEPS)}>
|
onChange={(e) =>
|
||||||
IELTS
|
!e
|
||||||
</Button>
|
? setOtherEntities([])
|
||||||
<Button variant="outline" onClick={() => setSteps(TOFEL_STEPS)}>
|
: setOtherEntities(e.map((o) => o.value!))
|
||||||
TOFEL iBT
|
}
|
||||||
</Button>
|
isMulti
|
||||||
</div>
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={applyToOtherEntities}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={isLoading || otherEntities.length === 0}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Apply to {otherEntities.length} other entities
|
||||||
|
</Button>
|
||||||
|
<Separator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{steps.map((step, index) => (
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
<>
|
Preset Systems
|
||||||
<div className="flex items-center gap-4">
|
</label>
|
||||||
<div className="grid grid-cols-3 gap-4 w-full" key={step.min}>
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<Input
|
<Button variant="outline" onClick={() => setSteps(CEFR_STEPS)}>
|
||||||
label="Min. Percentage"
|
CEFR
|
||||||
value={step.min}
|
</Button>
|
||||||
type="number"
|
<Button variant="outline" onClick={() => setSteps(GENERAL_STEPS)}>
|
||||||
disabled={index === 0 || isLoading}
|
General English
|
||||||
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, min: parseInt(e) } : x)))}
|
</Button>
|
||||||
name="min"
|
<Button variant="outline" onClick={() => setSteps(IELTS_STEPS)}>
|
||||||
/>
|
IELTS
|
||||||
<Input
|
</Button>
|
||||||
label="Grade"
|
<Button variant="outline" onClick={() => setSteps(TOFEL_STEPS)}>
|
||||||
value={step.label}
|
TOFEL iBT
|
||||||
type="text"
|
</Button>
|
||||||
disabled={isLoading}
|
</div>
|
||||||
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 && (
|
{steps.map((step, index) => (
|
||||||
<Button
|
<GradingRowMemo
|
||||||
className="w-full flex items-center justify-center"
|
key={index}
|
||||||
disabled={isLoading}
|
min={step.min}
|
||||||
onClick={() => {
|
max={step.max}
|
||||||
const item = { min: steps[index === 0 ? 0 : index - 1].max + 1, max: steps[index + 1].min - 1, label: "" };
|
label={step.label}
|
||||||
setSteps((prev) => [...prev.slice(0, index + 1), item, ...prev.slice(index + 1, steps.length)]);
|
index={index}
|
||||||
}}>
|
isLoading={isLoading}
|
||||||
<BsPlusCircle />
|
isLast={index === steps.length - 1}
|
||||||
</Button>
|
setSteps={setSteps}
|
||||||
)}
|
addRow={addRow}
|
||||||
</>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Button onClick={saveGradingSystem} isLoading={isLoading} disabled={isLoading} className="mt-8">
|
<Button
|
||||||
Save Grading System
|
onClick={saveGradingSystem}
|
||||||
</Button>
|
isLoading={isLoading}
|
||||||
</div>
|
disabled={isLoading}
|
||||||
);
|
className="mt-8"
|
||||||
|
>
|
||||||
|
Save Grading System
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,32 @@
|
|||||||
import { useMemo, useState } from "react";
|
import {useMemo, useState} from "react";
|
||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||||
import useExams from "@/hooks/useExams";
|
import useExams from "@/hooks/useExams";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { Module } from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import { Exam } from "@/interfaces/exam";
|
import {Exam} from "@/interfaces/exam";
|
||||||
import { Type, User } from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/exam";
|
import useExamStore from "@/stores/exam";
|
||||||
import { getExamById } from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import { countExercises } from "@/utils/moduleUtils";
|
import {countExercises} from "@/utils/moduleUtils";
|
||||||
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import {capitalize, uniq} from "lodash";
|
||||||
import { capitalize, uniq } from "lodash";
|
import {useRouter} from "next/router";
|
||||||
import { useRouter } from "next/router";
|
import {BsBan, BsCheck, BsCircle, BsPencil, BsTrash, BsUpload, BsX} from "react-icons/bs";
|
||||||
import { BsBan, BsBanFill, BsCheck, BsCircle, BsPencil, BsStop, BsTrash, BsUpload, BsX } from "react-icons/bs";
|
import {toast} from "react-toastify";
|
||||||
import { toast } from "react-toastify";
|
import {useListSearch} from "@/hooks/useListSearch";
|
||||||
import { useListSearch } from "@/hooks/useListSearch";
|
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import {checkAccess} from "@/utils/permissions";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import {EntityWithRoles} from "@/interfaces/entity";
|
||||||
import { FiEdit, FiArrowRight } from 'react-icons/fi';
|
import {BiEdit} from "react-icons/bi";
|
||||||
import { HiArrowRight } from "react-icons/hi";
|
import {findBy, mapBy} from "@/utils";
|
||||||
import { BiEdit } from "react-icons/bi";
|
import {getUserName} from "@/utils/users";
|
||||||
|
|
||||||
const searchFields = [["module"], ["id"], ["createdBy"]];
|
const searchFields = [["module"], ["id"], ["createdBy"]];
|
||||||
|
|
||||||
const CLASSES: { [key in Module]: string } = {
|
const CLASSES: {[key in Module]: string} = {
|
||||||
reading: "text-ielts-reading",
|
reading: "text-ielts-reading",
|
||||||
listening: "text-ielts-listening",
|
listening: "text-ielts-listening",
|
||||||
speaking: "text-ielts-speaking",
|
speaking: "text-ielts-speaking",
|
||||||
@@ -37,45 +36,20 @@ const CLASSES: { [key in Module]: string } = {
|
|||||||
|
|
||||||
const columnHelper = createColumnHelper<Exam>();
|
const columnHelper = createColumnHelper<Exam>();
|
||||||
|
|
||||||
const ExamOwnerSelector = ({ options, exam, onSave }: { options: User[]; exam: Exam; onSave: (owners: string[]) => void }) => {
|
export default function ExamList({user, entities}: {user: User; entities: EntityWithRoles[]}) {
|
||||||
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[]; }) {
|
|
||||||
const [selectedExam, setSelectedExam] = useState<Exam>();
|
const [selectedExam, setSelectedExam] = useState<Exam>();
|
||||||
|
|
||||||
const { exams, reload } = useExams();
|
const {exams, reload} = useExams();
|
||||||
const { users } = useUsers();
|
const {users} = useUsers();
|
||||||
const { groups } = useGroups({ admin: user?.id, userType: user?.type });
|
|
||||||
|
|
||||||
const filteredExams = useMemo(() => exams.filter((e) => {
|
const filteredExams = useMemo(
|
||||||
if (!e.private) return true
|
() =>
|
||||||
return (e.owners || []).includes(user?.id || "")
|
exams.filter((e) => {
|
||||||
}), [exams, user?.id])
|
if (!e.private) return true;
|
||||||
|
return (e.entities || []).some((ent) => mapBy(user.entities, "id").includes(ent));
|
||||||
const filteredCorporates = useMemo(() => {
|
}),
|
||||||
const participantsAndAdmins = uniq(groups.flatMap((x) => [...x.participants, x.admin])).filter((x) => x !== user?.id);
|
[exams, user?.entities],
|
||||||
return users.filter((x) => participantsAndAdmins.includes(x.id) && x.type === "corporate");
|
);
|
||||||
}, [users, groups, user]);
|
|
||||||
|
|
||||||
const parsedExams = useMemo(() => {
|
const parsedExams = useMemo(() => {
|
||||||
return filteredExams.map((exam) => {
|
return filteredExams.map((exam) => {
|
||||||
@@ -93,7 +67,7 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
});
|
});
|
||||||
}, [filteredExams, users]);
|
}, [filteredExams, users]);
|
||||||
|
|
||||||
const { rows: filteredRows, renderSearch } = useListSearch<Exam>(searchFields, parsedExams);
|
const {rows: filteredRows, renderSearch} = useListSearch<Exam>(searchFields, parsedExams);
|
||||||
|
|
||||||
const dispatch = useExamStore((state) => state.dispatch);
|
const dispatch = useExamStore((state) => state.dispatch);
|
||||||
|
|
||||||
@@ -108,7 +82,7 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch({ type: "INIT_EXAM", payload: { exams: [exam], modules: [module] } })
|
dispatch({type: "INIT_EXAM", payload: {exams: [exam], modules: [module]}});
|
||||||
|
|
||||||
router.push("/exam");
|
router.push("/exam");
|
||||||
};
|
};
|
||||||
@@ -117,7 +91,7 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
if (!confirm(`Are you sure you want to make this ${capitalize(exam.module)} exam ${exam.private ? "public" : "private"}?`)) return;
|
if (!confirm(`Are you sure you want to make this ${capitalize(exam.module)} exam ${exam.private ? "public" : "private"}?`)) return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch(`/api/exam/${exam.module}/${exam.id}`, { private: !exam.private })
|
.patch(`/api/exam/${exam.module}/${exam.id}`, {private: !exam.private})
|
||||||
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
if (reason.response.status === 404) {
|
if (reason.response.status === 404) {
|
||||||
@@ -135,29 +109,6 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
.finally(reload);
|
.finally(reload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateExam = async (exam: Exam, body: object) => {
|
|
||||||
if (!confirm(`Are you sure you want to update this ${capitalize(exam.module)} exam?`)) return;
|
|
||||||
|
|
||||||
axios
|
|
||||||
.patch(`/api/exam/${exam.module}/${exam.id}`, body)
|
|
||||||
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
|
||||||
.catch((reason) => {
|
|
||||||
if (reason.response.status === 404) {
|
|
||||||
toast.error("Exam not found!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reason.response.status === 403) {
|
|
||||||
toast.error("You do not have permission to update this exam!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error("Something went wrong, please try again later.");
|
|
||||||
})
|
|
||||||
.finally(reload)
|
|
||||||
.finally(() => setSelectedExam(undefined));
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteExam = async (exam: Exam) => {
|
const deleteExam = async (exam: Exam) => {
|
||||||
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
|
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
|
||||||
|
|
||||||
@@ -222,12 +173,12 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
}),
|
}),
|
||||||
columnHelper.accessor("createdBy", {
|
columnHelper.accessor("createdBy", {
|
||||||
header: "Created By",
|
header: "Created By",
|
||||||
cell: (info) => info.getValue(),
|
cell: (info) => (!info.getValue() ? "System" : findBy(users, "id", info.getValue())?.name || "N/A"),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
header: "",
|
header: "",
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }: { row: { original: Exam } }) => {
|
cell: ({row}: {row: {original: Exam}}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{(row.original.owners?.includes(user.id) || checkAccess(user, ["admin", "developer"])) && (
|
{(row.original.owners?.includes(user.id) || checkAccess(user, ["admin", "developer"])) && (
|
||||||
@@ -270,7 +221,7 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
|
|
||||||
const handleExamEdit = () => {
|
const handleExamEdit = () => {
|
||||||
router.push(`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`);
|
router.push(`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 w-full h-full">
|
<div className="flex flex-col gap-4 w-full h-full">
|
||||||
@@ -286,30 +237,17 @@ export default function ExamList({ user, entities }: { user: User; entities: Ent
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4 mb-3">
|
<div className="bg-gray-50 rounded-lg p-4 mb-3">
|
||||||
<p className="font-medium mb-1">
|
<p className="font-medium mb-1">Exam ID: {selectedExam.id}</p>
|
||||||
Exam ID: {selectedExam.id}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-gray-500 text-sm">Click 'Next' to proceed to the exam editor.</p>
|
||||||
Click 'Next' to proceed to the exam editor.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between gap-4 mt-8">
|
<div className="flex justify-between gap-4 mt-8">
|
||||||
<Button
|
<Button color="purple" variant="outline" onClick={() => setSelectedExam(undefined)} className="w-32">
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setSelectedExam(undefined)}
|
|
||||||
className="w-32"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button color="purple" onClick={handleExamEdit} className="w-32 text-white flex items-center justify-center gap-2">
|
||||||
color="purple"
|
|
||||||
onClick={handleExamEdit}
|
|
||||||
className="w-32 text-white flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
Proceed
|
Proceed
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import clsx from "clsx";
|
|||||||
import { capitalize } from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { 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 { toast } from "react-toastify";
|
||||||
import { countries, TCountries } from "countries-list";
|
import { countries, TCountries } from "countries-list";
|
||||||
import countryCodes from "country-codes-list";
|
import countryCodes from "country-codes-list";
|
||||||
@@ -24,426 +29,597 @@ import { WithLabeledEntities } from "@/interfaces/entity";
|
|||||||
import Table from "@/components/High/Table";
|
import Table from "@/components/High/Table";
|
||||||
import useEntities from "@/hooks/useEntities";
|
import useEntities from "@/hooks/useEntities";
|
||||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||||
import { findAllowedEntities } from "@/utils/permissions";
|
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<WithLabeledEntities<User>>();
|
const columnHelper = createColumnHelper<WithLabeledEntities<User>>();
|
||||||
const searchFields = [["name"], ["email"], ["entities", ""]];
|
const searchFields = [["name"], ["email"], ["entities", ""]];
|
||||||
|
|
||||||
export default function UserList({
|
export default function UserList({
|
||||||
user,
|
user,
|
||||||
filters = [],
|
filters = [],
|
||||||
type,
|
type,
|
||||||
renderHeader,
|
renderHeader,
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: User;
|
||||||
filters?: ((user: User) => boolean)[];
|
filters?: ((user: User) => boolean)[];
|
||||||
type?: Type;
|
type?: Type;
|
||||||
renderHeader?: (total: number) => JSX.Element;
|
renderHeader?: (total: number) => JSX.Element;
|
||||||
}) {
|
}) {
|
||||||
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
|
const [showDemographicInformation, setShowDemographicInformation] =
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
|
||||||
const { users, reload } = useEntitiesUsers(type)
|
const { users, isLoading, reload } = useEntitiesUsers(type);
|
||||||
const { entities } = useEntities()
|
const { entities } = useEntities();
|
||||||
|
|
||||||
const isAdmin = useMemo(() => ["admin", "developer"].includes(user?.type), [user?.type])
|
const isAdmin = useMemo(
|
||||||
|
() => ["admin", "developer"].includes(user?.type),
|
||||||
|
[user?.type]
|
||||||
|
);
|
||||||
|
|
||||||
const entitiesViewStudents = useAllowedEntities(user, entities, "view_students")
|
const entitiesViewStudents = useAllowedEntities(
|
||||||
const entitiesEditStudents = useAllowedEntities(user, entities, "edit_students")
|
user,
|
||||||
const entitiesDeleteStudents = useAllowedEntities(user, entities, "delete_students")
|
entities,
|
||||||
|
"view_students"
|
||||||
|
);
|
||||||
|
const entitiesEditStudents = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"edit_students"
|
||||||
|
);
|
||||||
|
const entitiesDeleteStudents = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"delete_students"
|
||||||
|
);
|
||||||
|
|
||||||
const entitiesViewTeachers = useAllowedEntities(user, entities, "view_teachers")
|
const entitiesViewTeachers = useAllowedEntities(
|
||||||
const entitiesEditTeachers = useAllowedEntities(user, entities, "edit_teachers")
|
user,
|
||||||
const entitiesDeleteTeachers = useAllowedEntities(user, entities, "delete_teachers")
|
entities,
|
||||||
|
"view_teachers"
|
||||||
|
);
|
||||||
|
const entitiesEditTeachers = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"edit_teachers"
|
||||||
|
);
|
||||||
|
const entitiesDeleteTeachers = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"delete_teachers"
|
||||||
|
);
|
||||||
|
|
||||||
const entitiesViewCorporates = useAllowedEntities(user, entities, "view_corporates")
|
const entitiesViewCorporates = useAllowedEntities(
|
||||||
const entitiesEditCorporates = useAllowedEntities(user, entities, "edit_corporates")
|
user,
|
||||||
const entitiesDeleteCorporates = useAllowedEntities(user, entities, "delete_corporates")
|
entities,
|
||||||
|
"view_corporates"
|
||||||
|
);
|
||||||
|
const entitiesEditCorporates = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"edit_corporates"
|
||||||
|
);
|
||||||
|
const entitiesDeleteCorporates = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"delete_corporates"
|
||||||
|
);
|
||||||
|
|
||||||
const entitiesViewMasterCorporates = useAllowedEntities(user, entities, "view_mastercorporates")
|
const entitiesViewMasterCorporates = useAllowedEntities(
|
||||||
const entitiesEditMasterCorporates = useAllowedEntities(user, entities, "edit_mastercorporates")
|
user,
|
||||||
const entitiesDeleteMasterCorporates = useAllowedEntities(user, entities, "delete_mastercorporates")
|
entities,
|
||||||
|
"view_mastercorporates"
|
||||||
|
);
|
||||||
|
const entitiesEditMasterCorporates = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"edit_mastercorporates"
|
||||||
|
);
|
||||||
|
const entitiesDeleteMasterCorporates = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"delete_mastercorporates"
|
||||||
|
);
|
||||||
|
|
||||||
const entitiesDownloadUsers = useAllowedEntities(user, entities, "download_user_list")
|
const entitiesDownloadUsers = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"download_user_list"
|
||||||
|
);
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const expirationDateColor = (date: Date) => {
|
const expirationDateColor = (date: Date) => {
|
||||||
const momentDate = moment(date);
|
const momentDate = moment(date);
|
||||||
const today = moment(new Date());
|
const today = moment(new Date());
|
||||||
|
|
||||||
if (today.isAfter(momentDate)) return "!text-mti-red-light font-bold line-through";
|
if (today.isAfter(momentDate))
|
||||||
if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light";
|
return "!text-mti-red-light font-bold line-through";
|
||||||
if (today.add(2, "weeks").isAfter(momentDate)) return "!text-mti-rose-light";
|
if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-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) => {
|
const allowedUsers = useMemo(
|
||||||
if (isAdmin) return true
|
() =>
|
||||||
if (u.id === user?.id) return false
|
users.filter((u) => {
|
||||||
|
if (isAdmin) return true;
|
||||||
|
if (u.id === user?.id) return false;
|
||||||
|
|
||||||
switch (u.type) {
|
switch (u.type) {
|
||||||
case "student": return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewStudents, 'id').includes(id))
|
case "student":
|
||||||
case "teacher": return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewTeachers, 'id').includes(id))
|
return mapBy(u.entities || [], "id").some((id) =>
|
||||||
case 'corporate': return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewCorporates, 'id').includes(id))
|
mapBy(entitiesViewStudents, "id").includes(id)
|
||||||
case 'mastercorporate': return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewMasterCorporates, 'id').includes(id))
|
);
|
||||||
default: return false
|
case "teacher":
|
||||||
}
|
return mapBy(u.entities || [], "id").some((id) =>
|
||||||
})
|
mapBy(entitiesViewTeachers, "id").includes(id)
|
||||||
, [entitiesViewCorporates, entitiesViewMasterCorporates, entitiesViewStudents, entitiesViewTeachers, isAdmin, user?.id, users])
|
);
|
||||||
|
case "corporate":
|
||||||
|
return mapBy(u.entities || [], "id").some((id) =>
|
||||||
|
mapBy(entitiesViewCorporates, "id").includes(id)
|
||||||
|
);
|
||||||
|
case "mastercorporate":
|
||||||
|
return mapBy(u.entities || [], "id").some((id) =>
|
||||||
|
mapBy(entitiesViewMasterCorporates, "id").includes(id)
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
entitiesViewCorporates,
|
||||||
|
entitiesViewMasterCorporates,
|
||||||
|
entitiesViewStudents,
|
||||||
|
entitiesViewTeachers,
|
||||||
|
isAdmin,
|
||||||
|
user?.id,
|
||||||
|
users,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const displayUsers = useMemo(() =>
|
const displayUsers = useMemo(
|
||||||
filters.length > 0 ? filters.reduce((d, f) => d.filter(f), allowedUsers) : allowedUsers,
|
() =>
|
||||||
[filters, allowedUsers])
|
filters.length > 0
|
||||||
|
? filters.reduce((d, f) => d.filter(f), allowedUsers)
|
||||||
|
: allowedUsers,
|
||||||
|
[filters, allowedUsers]
|
||||||
|
);
|
||||||
|
|
||||||
const deleteAccount = (user: User) => {
|
const deleteAccount = (user: User) => {
|
||||||
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return;
|
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`))
|
||||||
|
return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete<{ ok: boolean }>(`/api/user?id=${user.id}`)
|
.delete<{ ok: boolean }>(`/api/user?id=${user.id}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("User deleted successfully!");
|
toast.success("User deleted successfully!");
|
||||||
reload()
|
reload();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong!", { toastId: "delete-error" });
|
toast.error("Something went wrong!", { toastId: "delete-error" });
|
||||||
})
|
})
|
||||||
.finally(reload);
|
.finally(reload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const verifyAccount = (user: User) => {
|
const verifyAccount = (user: User) => {
|
||||||
axios
|
axios
|
||||||
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
|
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
|
||||||
...user,
|
...user,
|
||||||
isVerified: true,
|
isVerified: true,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("User verified successfully!");
|
toast.success("User verified successfully!");
|
||||||
reload();
|
reload();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong!", { toastId: "update-error" });
|
toast.error("Something went wrong!", { toastId: "update-error" });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleDisableAccount = (user: User) => {
|
const toggleDisableAccount = (user: User) => {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
`Are you sure you want to ${user.status === "disabled" ? "enable" : "disable"} ${user.name
|
`Are you sure you want to ${
|
||||||
}'s account? This change is usually related to their payment state.`,
|
user.status === "disabled" ? "enable" : "disable"
|
||||||
)
|
} ${
|
||||||
)
|
user.name
|
||||||
return;
|
}'s account? This change is usually related to their payment state.`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
|
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
|
||||||
...user,
|
...user,
|
||||||
status: user.status === "disabled" ? "active" : "disabled",
|
status: user.status === "disabled" ? "active" : "disabled",
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`User ${user.status === "disabled" ? "enabled" : "disabled"} successfully!`);
|
toast.success(
|
||||||
reload();
|
`User ${
|
||||||
})
|
user.status === "disabled" ? "enabled" : "disabled"
|
||||||
.catch(() => {
|
} successfully!`
|
||||||
toast.error("Something went wrong!", { toastId: "update-error" });
|
);
|
||||||
});
|
reload();
|
||||||
};
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Something went wrong!", { toastId: "update-error" });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getEditPermission = (type: Type) => {
|
const getEditPermission = (type: Type) => {
|
||||||
if (type === "student") return entitiesEditStudents
|
if (type === "student") return entitiesEditStudents;
|
||||||
if (type === "teacher") return entitiesEditTeachers
|
if (type === "teacher") return entitiesEditTeachers;
|
||||||
if (type === "corporate") return entitiesEditCorporates
|
if (type === "corporate") return entitiesEditCorporates;
|
||||||
if (type === "mastercorporate") return entitiesEditMasterCorporates
|
if (type === "mastercorporate") return entitiesEditMasterCorporates;
|
||||||
|
|
||||||
return []
|
return [];
|
||||||
}
|
};
|
||||||
|
|
||||||
const getDeletePermission = (type: Type) => {
|
const getDeletePermission = (type: Type) => {
|
||||||
if (type === "student") return entitiesDeleteStudents
|
if (type === "student") return entitiesDeleteStudents;
|
||||||
if (type === "teacher") return entitiesDeleteTeachers
|
if (type === "teacher") return entitiesDeleteTeachers;
|
||||||
if (type === "corporate") return entitiesDeleteCorporates
|
if (type === "corporate") return entitiesDeleteCorporates;
|
||||||
if (type === "mastercorporate") return entitiesDeleteMasterCorporates
|
if (type === "mastercorporate") return entitiesDeleteMasterCorporates;
|
||||||
|
|
||||||
return []
|
return [];
|
||||||
}
|
};
|
||||||
|
|
||||||
const canEditUser = (u: User) =>
|
const canEditUser = (u: User) =>
|
||||||
isAdmin || u.entities.some(e => mapBy(getEditPermission(u.type), 'id').includes(e.id))
|
isAdmin ||
|
||||||
|
u.entities.some((e) =>
|
||||||
|
mapBy(getEditPermission(u.type), "id").includes(e.id)
|
||||||
|
);
|
||||||
|
|
||||||
const canDeleteUser = (u: User) =>
|
const canDeleteUser = (u: User) =>
|
||||||
isAdmin || u.entities.some(e => mapBy(getDeletePermission(u.type), 'id').includes(e.id))
|
isAdmin ||
|
||||||
|
u.entities.some((e) =>
|
||||||
|
mapBy(getDeletePermission(u.type), "id").includes(e.id)
|
||||||
|
);
|
||||||
|
|
||||||
const actionColumn = ({ row }: { row: { original: User } }) => {
|
const actionColumn = ({ row }: { row: { original: User } }) => {
|
||||||
const canEdit = canEditUser(row.original)
|
const canEdit = canEditUser(row.original);
|
||||||
const canDelete = canDeleteUser(row.original)
|
const canDelete = canDeleteUser(row.original);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{!row.original.isVerified && canEdit && (
|
{!row.original.isVerified && canEdit && (
|
||||||
<div data-tip="Verify User" className="cursor-pointer tooltip" onClick={() => verifyAccount(row.original)}>
|
<div
|
||||||
<BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
data-tip="Verify User"
|
||||||
</div>
|
className="cursor-pointer tooltip"
|
||||||
)}
|
onClick={() => verifyAccount(row.original)}
|
||||||
{canEdit && (
|
>
|
||||||
<div
|
<BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
data-tip={row.original.status === "disabled" ? "Enable User" : "Disable User"}
|
</div>
|
||||||
className="cursor-pointer tooltip"
|
)}
|
||||||
onClick={() => toggleDisableAccount(row.original)}>
|
{canEdit && (
|
||||||
{row.original.status === "disabled" ? (
|
<div
|
||||||
<BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
data-tip={
|
||||||
) : (
|
row.original.status === "disabled"
|
||||||
<BsFillExclamationOctagonFill className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
? "Enable User"
|
||||||
)}
|
: "Disable User"
|
||||||
</div>
|
}
|
||||||
)}
|
className="cursor-pointer tooltip"
|
||||||
{canDelete && (
|
onClick={() => toggleDisableAccount(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" />
|
{row.original.status === "disabled" ? (
|
||||||
</div>
|
<BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
)}
|
) : (
|
||||||
</div>
|
<BsFillExclamationOctagonFill className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
);
|
)}
|
||||||
};
|
</div>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<div
|
||||||
|
data-tip="Delete"
|
||||||
|
className="cursor-pointer tooltip"
|
||||||
|
onClick={() => deleteAccount(row.original)}
|
||||||
|
>
|
||||||
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const demographicColumns = [
|
const demographicColumns = [
|
||||||
columnHelper.accessor("name", {
|
columnHelper.accessor("name", {
|
||||||
header: "Name",
|
header: "Name",
|
||||||
cell: ({ row, getValue }) => (
|
cell: ({ row, getValue }) => (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
canEditUser(row.original) &&
|
canEditUser(row.original) &&
|
||||||
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
canEditUser(row.original) ? setSelectedUser(row.original) : null
|
canEditUser(row.original) ? setSelectedUser(row.original) : null
|
||||||
}>
|
}
|
||||||
{getValue()}
|
>
|
||||||
</div>
|
{getValue()}
|
||||||
),
|
</div>
|
||||||
}),
|
),
|
||||||
columnHelper.accessor("demographicInformation.country", {
|
}),
|
||||||
header: "Country",
|
columnHelper.accessor("demographicInformation.country", {
|
||||||
cell: (info) =>
|
header: "Country",
|
||||||
info.getValue()
|
cell: (info) =>
|
||||||
? `${countryCodes.findOne("countryCode" as any, info.getValue())?.flag} ${countries[info.getValue() as unknown as keyof TCountries]?.name
|
info.getValue()
|
||||||
} (+${countryCodes.findOne("countryCode" as any, info.getValue())?.countryCallingCode})`
|
? `${
|
||||||
: "N/A",
|
countryCodes.findOne("countryCode" as any, info.getValue())?.flag
|
||||||
}),
|
} ${
|
||||||
columnHelper.accessor("demographicInformation.phone", {
|
countries[info.getValue() as unknown as keyof TCountries]?.name
|
||||||
header: "Phone",
|
} (+${
|
||||||
cell: (info) => info.getValue() || "N/A",
|
countryCodes.findOne("countryCode" as any, info.getValue())
|
||||||
enableSorting: true,
|
?.countryCallingCode
|
||||||
}),
|
})`
|
||||||
columnHelper.accessor(
|
: "N/A",
|
||||||
(x) =>
|
}),
|
||||||
x.type === "corporate" || x.type === "mastercorporate" ? x.demographicInformation?.position : x.demographicInformation?.employment,
|
columnHelper.accessor("demographicInformation.phone", {
|
||||||
{
|
header: "Phone",
|
||||||
id: "employment",
|
cell: (info) => info.getValue() || "N/A",
|
||||||
header: "Employment",
|
enableSorting: true,
|
||||||
cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "N/A",
|
}),
|
||||||
enableSorting: true,
|
columnHelper.accessor(
|
||||||
},
|
(x) =>
|
||||||
),
|
x.type === "corporate" || x.type === "mastercorporate"
|
||||||
columnHelper.accessor("lastLogin", {
|
? x.demographicInformation?.position
|
||||||
header: "Last Login",
|
: x.demographicInformation?.employment,
|
||||||
cell: (info) => (!!info.getValue() ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") : "N/A"),
|
{
|
||||||
}),
|
id: "employment",
|
||||||
columnHelper.accessor("demographicInformation.gender", {
|
header: "Employment",
|
||||||
header: "Gender",
|
cell: (info) =>
|
||||||
cell: (info) => capitalize(info.getValue()) || "N/A",
|
(info.row.original.type === "corporate"
|
||||||
enableSorting: true,
|
? info.getValue()
|
||||||
}),
|
: capitalize(info.getValue())) || "N/A",
|
||||||
{
|
enableSorting: true,
|
||||||
header: (
|
}
|
||||||
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
|
),
|
||||||
Switch
|
columnHelper.accessor("lastLogin", {
|
||||||
</span>
|
header: "Last Login",
|
||||||
),
|
cell: (info) =>
|
||||||
id: "actions",
|
!!info.getValue()
|
||||||
cell: actionColumn,
|
? moment(info.getValue()).format("YYYY-MM-DD HH:mm")
|
||||||
sortable: false
|
: "N/A",
|
||||||
},
|
}),
|
||||||
];
|
columnHelper.accessor("demographicInformation.gender", {
|
||||||
|
header: "Gender",
|
||||||
|
cell: (info) => capitalize(info.getValue()) || "N/A",
|
||||||
|
enableSorting: true,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
header: (
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setShowDemographicInformation((prev) => !prev)}
|
||||||
|
>
|
||||||
|
Switch
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
id: "actions",
|
||||||
|
cell: actionColumn,
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const defaultColumns = [
|
const defaultColumns = [
|
||||||
columnHelper.accessor("name", {
|
columnHelper.accessor("name", {
|
||||||
header: "Name",
|
header: "Name",
|
||||||
cell: ({ row, getValue }) => (
|
cell: ({ row, getValue }) => (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
canEditUser(row.original) &&
|
canEditUser(row.original) &&
|
||||||
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
canEditUser(row.original) ? setSelectedUser(row.original) : null
|
canEditUser(row.original) ? setSelectedUser(row.original) : null
|
||||||
}>
|
}
|
||||||
{getValue()}
|
>
|
||||||
</div>
|
{getValue()}
|
||||||
),
|
</div>
|
||||||
}),
|
),
|
||||||
columnHelper.accessor("email", {
|
}),
|
||||||
header: "E-mail",
|
columnHelper.accessor("email", {
|
||||||
cell: ({ row, getValue }) => (
|
header: "E-mail",
|
||||||
<div
|
cell: ({ row, getValue }) => (
|
||||||
className={clsx(
|
<div
|
||||||
canEditUser(row.original) &&
|
className={clsx(
|
||||||
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
canEditUser(row.original) &&
|
||||||
)}
|
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
|
||||||
onClick={() => (canEditUser(row.original) ? setSelectedUser(row.original) : null)}>
|
)}
|
||||||
{getValue()}
|
onClick={() =>
|
||||||
</div>
|
canEditUser(row.original) ? setSelectedUser(row.original) : null
|
||||||
),
|
}
|
||||||
}),
|
>
|
||||||
columnHelper.accessor("type", {
|
{getValue()}
|
||||||
header: "Type",
|
</div>
|
||||||
cell: (info) => USER_TYPE_LABELS[info.getValue()],
|
),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("studentID", {
|
columnHelper.accessor("type", {
|
||||||
header: "Student ID",
|
header: "Type",
|
||||||
cell: (info) => info.getValue() || "N/A",
|
cell: (info) => USER_TYPE_LABELS[info.getValue()],
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("entities", {
|
columnHelper.accessor("studentID", {
|
||||||
header: "Entities",
|
header: "Student ID",
|
||||||
cell: ({ getValue }) => mapBy(getValue(), 'label').join(', '),
|
cell: (info) => info.getValue() || "N/A",
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("subscriptionExpirationDate", {
|
columnHelper.accessor("entities", {
|
||||||
header: "Expiration",
|
header: "Entities",
|
||||||
cell: (info) => (
|
cell: ({ getValue }) => mapBy(getValue(), "label").join(", "),
|
||||||
<span className={clsx(info.getValue() ? expirationDateColor(moment(info.getValue()).toDate()) : "")}>
|
}),
|
||||||
{!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")}
|
columnHelper.accessor("subscriptionExpirationDate", {
|
||||||
</span>
|
header: "Expiration",
|
||||||
),
|
cell: (info) => (
|
||||||
}),
|
<span
|
||||||
columnHelper.accessor("isVerified", {
|
className={clsx(
|
||||||
header: "Verified",
|
info.getValue()
|
||||||
cell: (info) => (
|
? expirationDateColor(moment(info.getValue()).toDate())
|
||||||
<div className="flex gap-3 items-center text-mti-gray-dim text-sm self-center">
|
: ""
|
||||||
<div
|
)}
|
||||||
className={clsx(
|
>
|
||||||
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
{!info.getValue()
|
||||||
"transition duration-300 ease-in-out",
|
? "No expiry date"
|
||||||
info.getValue() && "!bg-mti-purple-light ",
|
: moment(info.getValue()).format("DD/MM/YYYY")}
|
||||||
)}>
|
</span>
|
||||||
<BsCheck color="white" className="w-full h-full" />
|
),
|
||||||
</div>
|
}),
|
||||||
</div>
|
columnHelper.accessor("isVerified", {
|
||||||
),
|
header: "Verified",
|
||||||
}),
|
cell: (info) => (
|
||||||
{
|
<div className="flex gap-3 items-center text-mti-gray-dim text-sm self-center">
|
||||||
header: (
|
<div
|
||||||
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
|
className={clsx(
|
||||||
Switch
|
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
||||||
</span>
|
"transition duration-300 ease-in-out",
|
||||||
),
|
info.getValue() && "!bg-mti-purple-light "
|
||||||
id: "actions",
|
)}
|
||||||
cell: actionColumn,
|
>
|
||||||
sortable: false
|
<BsCheck color="white" className="w-full h-full" />
|
||||||
},
|
</div>
|
||||||
];
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
header: (
|
||||||
|
<span
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setShowDemographicInformation((prev) => !prev)}
|
||||||
|
>
|
||||||
|
Switch
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
id: "actions",
|
||||||
|
cell: actionColumn,
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const downloadExcel = (rows: WithLabeledEntities<User>[]) => {
|
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) =>
|
||||||
const csv = exportListToExcel(allowedRows);
|
mapBy(r.entities, "id").some((e) =>
|
||||||
|
mapBy(entitiesDownloadUsers, "id").includes(e)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const csv = exportListToExcel(allowedRows);
|
||||||
|
|
||||||
const element = document.createElement("a");
|
const element = document.createElement("a");
|
||||||
const file = new Blob([csv], { type: "text/csv" });
|
const file = new Blob([csv], { type: "text/csv" });
|
||||||
element.href = URL.createObjectURL(file);
|
element.href = URL.createObjectURL(file);
|
||||||
element.download = "users.csv";
|
element.download = "users.csv";
|
||||||
document.body.appendChild(element);
|
document.body.appendChild(element);
|
||||||
element.click();
|
element.click();
|
||||||
document.body.removeChild(element);
|
document.body.removeChild(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewStudentFilter = (x: User) => x.type === "student";
|
const viewStudentFilter = (x: User) => x.type === "student";
|
||||||
const viewTeacherFilter = (x: User) => x.type === "teacher";
|
const viewTeacherFilter = (x: User) => x.type === "teacher";
|
||||||
const belongsToAdminFilter = (x: User) => x.entities.some(({ id }) => mapBy(selectedUser?.entities || [], 'id').includes(id));
|
const belongsToAdminFilter = (x: User) =>
|
||||||
|
x.entities.some(({ id }) =>
|
||||||
|
mapBy(selectedUser?.entities || [], "id").includes(id)
|
||||||
|
);
|
||||||
|
|
||||||
const viewStudentFilterBelongsToAdmin = (x: User) => viewStudentFilter(x) && belongsToAdminFilter(x);
|
const viewStudentFilterBelongsToAdmin = (x: User) =>
|
||||||
const viewTeacherFilterBelongsToAdmin = (x: User) => viewTeacherFilter(x) && belongsToAdminFilter(x);
|
viewStudentFilter(x) && belongsToAdminFilter(x);
|
||||||
|
const viewTeacherFilterBelongsToAdmin = (x: User) =>
|
||||||
|
viewTeacherFilter(x) && belongsToAdminFilter(x);
|
||||||
|
|
||||||
const renderUserCard = (selectedUser: User) => {
|
const renderUserCard = (selectedUser: User) => {
|
||||||
const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin);
|
const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin);
|
||||||
const teachersFromAdmin = users.filter(viewTeacherFilterBelongsToAdmin);
|
const teachersFromAdmin = users.filter(viewTeacherFilterBelongsToAdmin);
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col gap-8">
|
<div className="w-full flex flex-col gap-8">
|
||||||
<UserCard
|
<UserCard
|
||||||
maxUserAmount={0}
|
maxUserAmount={0}
|
||||||
loggedInUser={user}
|
loggedInUser={user}
|
||||||
onViewStudents={
|
onViewStudents={
|
||||||
(selectedUser.type === "corporate" || selectedUser.type === "teacher") && studentsFromAdmin.length > 0
|
(selectedUser.type === "corporate" ||
|
||||||
? () => {
|
selectedUser.type === "teacher") &&
|
||||||
appendUserFilters({
|
studentsFromAdmin.length > 0
|
||||||
id: "view-students",
|
? () => {
|
||||||
filter: viewStudentFilter,
|
appendUserFilters({
|
||||||
});
|
id: "view-students",
|
||||||
appendUserFilters({
|
filter: viewStudentFilter,
|
||||||
id: "belongs-to-admin",
|
});
|
||||||
filter: belongsToAdminFilter,
|
appendUserFilters({
|
||||||
});
|
id: "belongs-to-admin",
|
||||||
|
filter: belongsToAdminFilter,
|
||||||
|
});
|
||||||
|
|
||||||
router.push("/users");
|
router.push("/users");
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onViewTeachers={
|
onViewTeachers={
|
||||||
(selectedUser.type === "corporate" || selectedUser.type === "student") && teachersFromAdmin.length > 0
|
(selectedUser.type === "corporate" ||
|
||||||
? () => {
|
selectedUser.type === "student") &&
|
||||||
appendUserFilters({
|
teachersFromAdmin.length > 0
|
||||||
id: "view-teachers",
|
? () => {
|
||||||
filter: viewTeacherFilter,
|
appendUserFilters({
|
||||||
});
|
id: "view-teachers",
|
||||||
appendUserFilters({
|
filter: viewTeacherFilter,
|
||||||
id: "belongs-to-admin",
|
});
|
||||||
filter: belongsToAdminFilter,
|
appendUserFilters({
|
||||||
});
|
id: "belongs-to-admin",
|
||||||
|
filter: belongsToAdminFilter,
|
||||||
|
});
|
||||||
|
|
||||||
router.push("/users");
|
router.push("/users");
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onViewCorporate={
|
onViewCorporate={
|
||||||
selectedUser.type === "teacher" || selectedUser.type === "student"
|
selectedUser.type === "teacher" || selectedUser.type === "student"
|
||||||
? () => {
|
? () => {
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "view-corporate",
|
id: "view-corporate",
|
||||||
filter: (x: User) => x.type === "corporate",
|
filter: (x: User) => x.type === "corporate",
|
||||||
});
|
});
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "belongs-to-admin",
|
id: "belongs-to-admin",
|
||||||
filter: belongsToAdminFilter
|
filter: belongsToAdminFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push("/users");
|
router.push("/users");
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onClose={(shouldReload) => {
|
onClose={(shouldReload) => {
|
||||||
setSelectedUser(undefined);
|
setSelectedUser(undefined);
|
||||||
if (shouldReload) reload();
|
if (shouldReload) reload();
|
||||||
}}
|
}}
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderHeader && renderHeader(displayUsers.length)}
|
{renderHeader && renderHeader(displayUsers.length)}
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
|
<Modal
|
||||||
{selectedUser && renderUserCard(selectedUser)}
|
isOpen={!!selectedUser}
|
||||||
</Modal>
|
onClose={() => setSelectedUser(undefined)}
|
||||||
<Table<WithLabeledEntities<User>>
|
>
|
||||||
data={displayUsers}
|
{selectedUser && renderUserCard(selectedUser)}
|
||||||
columns={(!showDemographicInformation ? defaultColumns : demographicColumns) as any}
|
</Modal>
|
||||||
searchFields={searchFields}
|
<Table<WithLabeledEntities<User>>
|
||||||
onDownload={entitiesDownloadUsers.length > 0 ? downloadExcel : undefined}
|
data={displayUsers}
|
||||||
/>
|
columns={
|
||||||
</div>
|
(!showDemographicInformation
|
||||||
</>
|
? defaultColumns
|
||||||
);
|
: demographicColumns) as any
|
||||||
|
}
|
||||||
|
searchFields={searchFields}
|
||||||
|
onDownload={
|
||||||
|
entitiesDownloadUsers.length > 0 ? downloadExcel : undefined
|
||||||
|
}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ export default function ExamPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
userSolutions.forEach((x) => {
|
userSolutions.forEach((x) => {
|
||||||
if (x.isPractice === isPractice) {
|
if (isPractice ? x.isPractice : !x.isPractice) {
|
||||||
const examModule =
|
const examModule =
|
||||||
x.module ||
|
x.module ||
|
||||||
(x.type === "writing"
|
(x.type === "writing"
|
||||||
@@ -329,7 +329,7 @@ export default function ExamPage({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOnFocusLayerMouseEnter(() => () => setShowAbandonPopup(true));
|
setOnFocusLayerMouseEnter(() => () => setShowAbandonPopup(true));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -355,107 +355,107 @@ export default function ExamPage({
|
|||||||
<>
|
<>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
{/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/}
|
{/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/}
|
||||||
{selectedModules.length === 0 && (
|
{selectedModules.length === 0 && (
|
||||||
<Selection
|
<Selection
|
||||||
page={page}
|
page={page}
|
||||||
user={user!}
|
user={user!}
|
||||||
onStart={(
|
onStart={(
|
||||||
modules: Module[],
|
modules: Module[],
|
||||||
avoid: boolean,
|
avoid: boolean,
|
||||||
variant: Variant
|
variant: Variant
|
||||||
) => {
|
) => {
|
||||||
|
setModuleIndex(0);
|
||||||
|
setAvoidRepeated(avoid);
|
||||||
|
setSelectedModules(modules);
|
||||||
|
setVariant(variant);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isFetchingExams && (
|
||||||
|
<div className="flex flex-grow flex-col items-center justify-center animate-pulse">
|
||||||
|
<span
|
||||||
|
className={`loading loading-infinity w-32 bg-ielts-${selectedModules[0]}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`font-bold text-2xl text-ielts-${selectedModules[0]}`}
|
||||||
|
>
|
||||||
|
Loading Exam ...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{moduleIndex === -1 && selectedModules.length !== 0 && (
|
||||||
|
<Finish
|
||||||
|
isLoading={userSolutions.some((s) => s.isDisabled)}
|
||||||
|
user={user!}
|
||||||
|
modules={selectedModules}
|
||||||
|
solutions={userSolutions}
|
||||||
|
assignment={assignment}
|
||||||
|
information={{
|
||||||
|
timeSpent,
|
||||||
|
inactivity,
|
||||||
|
}}
|
||||||
|
destination={destination}
|
||||||
|
onViewResults={(index?: number) => {
|
||||||
|
if (exams[0].module === "level") {
|
||||||
|
const levelExam = exams[0] as LevelExam;
|
||||||
|
const allExercises = levelExam.parts.flatMap(
|
||||||
|
(part) => part.exercises
|
||||||
|
);
|
||||||
|
const exerciseOrderMap = new Map(
|
||||||
|
allExercises.map((ex, index) => [ex.id, index])
|
||||||
|
);
|
||||||
|
const orderedSolutions = userSolutions
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => {
|
||||||
|
const indexA =
|
||||||
|
exerciseOrderMap.get(a.exercise) ?? Infinity;
|
||||||
|
const indexB =
|
||||||
|
exerciseOrderMap.get(b.exercise) ?? Infinity;
|
||||||
|
return indexA - indexB;
|
||||||
|
});
|
||||||
|
setUserSolutions(orderedSolutions);
|
||||||
|
} else {
|
||||||
|
setUserSolutions(userSolutions);
|
||||||
|
}
|
||||||
|
setShuffles([]);
|
||||||
|
if (index === undefined) {
|
||||||
|
setFlags({ reviewAll: true });
|
||||||
setModuleIndex(0);
|
setModuleIndex(0);
|
||||||
setAvoidRepeated(avoid);
|
setExam(exams[0]);
|
||||||
setSelectedModules(modules);
|
} else {
|
||||||
setVariant(variant);
|
setModuleIndex(index);
|
||||||
}}
|
setExam(exams[index]);
|
||||||
/>
|
}
|
||||||
)}
|
setShowSolutions(true);
|
||||||
{isFetchingExams && (
|
setQuestionIndex(0);
|
||||||
<div className="flex flex-grow flex-col items-center justify-center animate-pulse">
|
setExerciseIndex(0);
|
||||||
<span
|
setPartIndex(0);
|
||||||
className={`loading loading-infinity w-32 bg-ielts-${selectedModules[0]}`}
|
}}
|
||||||
|
scores={aggregateScoresByModule()}
|
||||||
|
practiceScores={aggregateScoresByModule(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Exam is on going, display it and the abandon modal */}
|
||||||
|
{isExamLoaded && moduleIndex !== -1 && (
|
||||||
|
<>
|
||||||
|
{exam && CurrentExam && (
|
||||||
|
<CurrentExam exam={exam} showSolutions={showSolutions} />
|
||||||
|
)}
|
||||||
|
{!showSolutions && (
|
||||||
|
<AbandonPopup
|
||||||
|
isOpen={showAbandonPopup}
|
||||||
|
abandonPopupTitle="Leave Exercise"
|
||||||
|
abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard."
|
||||||
|
abandonConfirmButtonText="Confirm"
|
||||||
|
onAbandon={onAbandon}
|
||||||
|
onCancel={() => setShowAbandonPopup(false)}
|
||||||
/>
|
/>
|
||||||
<span
|
)}
|
||||||
className={`font-bold text-2xl text-ielts-${selectedModules[0]}`}
|
</>
|
||||||
>
|
)}
|
||||||
Loading Exam ...
|
</>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{moduleIndex === -1 && selectedModules.length !== 0 && (
|
|
||||||
<Finish
|
|
||||||
isLoading={userSolutions.some((s) => s.isDisabled)}
|
|
||||||
user={user!}
|
|
||||||
modules={selectedModules}
|
|
||||||
solutions={userSolutions}
|
|
||||||
assignment={assignment}
|
|
||||||
information={{
|
|
||||||
timeSpent,
|
|
||||||
inactivity,
|
|
||||||
}}
|
|
||||||
destination={destination}
|
|
||||||
onViewResults={(index?: number) => {
|
|
||||||
if (exams[0].module === "level") {
|
|
||||||
const levelExam = exams[0] as LevelExam;
|
|
||||||
const allExercises = levelExam.parts.flatMap(
|
|
||||||
(part) => part.exercises
|
|
||||||
);
|
|
||||||
const exerciseOrderMap = new Map(
|
|
||||||
allExercises.map((ex, index) => [ex.id, index])
|
|
||||||
);
|
|
||||||
const orderedSolutions = userSolutions
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => {
|
|
||||||
const indexA =
|
|
||||||
exerciseOrderMap.get(a.exercise) ?? Infinity;
|
|
||||||
const indexB =
|
|
||||||
exerciseOrderMap.get(b.exercise) ?? Infinity;
|
|
||||||
return indexA - indexB;
|
|
||||||
});
|
|
||||||
setUserSolutions(orderedSolutions);
|
|
||||||
} else {
|
|
||||||
setUserSolutions(userSolutions);
|
|
||||||
}
|
|
||||||
setShuffles([]);
|
|
||||||
if (index === undefined) {
|
|
||||||
setFlags({ reviewAll: true });
|
|
||||||
setModuleIndex(0);
|
|
||||||
setExam(exams[0]);
|
|
||||||
} else {
|
|
||||||
setModuleIndex(index);
|
|
||||||
setExam(exams[index]);
|
|
||||||
}
|
|
||||||
setShowSolutions(true);
|
|
||||||
setQuestionIndex(0);
|
|
||||||
setExerciseIndex(0);
|
|
||||||
setPartIndex(0);
|
|
||||||
}}
|
|
||||||
scores={aggregateScoresByModule()}
|
|
||||||
practiceScores={aggregateScoresByModule(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Exam is on going, display it and the abandon modal */}
|
|
||||||
{isExamLoaded && moduleIndex !== -1 && (
|
|
||||||
<>
|
|
||||||
{exam && CurrentExam && (
|
|
||||||
<CurrentExam exam={exam} showSolutions={showSolutions} />
|
|
||||||
)}
|
|
||||||
{!showSolutions && (
|
|
||||||
<AbandonPopup
|
|
||||||
isOpen={showAbandonPopup}
|
|
||||||
abandonPopupTitle="Leave Exercise"
|
|
||||||
abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard."
|
|
||||||
abandonConfirmButtonText="Confirm"
|
|
||||||
onAbandon={onAbandon}
|
|
||||||
onCancel={() => setShowAbandonPopup(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import useExamStore from "@/stores/exam";
|
|||||||
import usePreferencesStore from "@/stores/preferencesStore";
|
import usePreferencesStore from "@/stores/preferencesStore";
|
||||||
import Layout from "../components/High/Layout";
|
import Layout from "../components/High/Layout";
|
||||||
import useEntities from "../hooks/useEntities";
|
import useEntities from "../hooks/useEntities";
|
||||||
import UserProfileSkeleton from "../components/Medium/UserProfileSkeleton";
|
|
||||||
|
|
||||||
export default function App({ Component, pageProps }: AppProps) {
|
export default function App({ Component, pageProps }: AppProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -61,7 +59,14 @@ export default function App({ Component, pageProps }: AppProps) {
|
|||||||
|
|
||||||
return pageProps?.user ? (
|
return pageProps?.user ? (
|
||||||
<Layout user={pageProps.user} entities={entities} refreshPage={loading}>
|
<Layout user={pageProps.user} entities={entities} refreshPage={loading}>
|
||||||
{loading ? <UserProfileSkeleton /> : <Component {...pageProps} />}
|
{loading ? (
|
||||||
|
// TODO: Change this later to a better loading screen (example: skeletons for each page)
|
||||||
|
<div className="min-h-screen flex justify-center items-start">
|
||||||
|
<span className="loading loading-infinity w-32" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Component entities={entities} {...pageProps} />
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
) : (
|
) : (
|
||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
|
|||||||
32
src/pages/api/approval-workflows/[id]/edit.ts
Normal file
32
src/pages/api/approval-workflows/[id]/edit.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { updateApprovalWorkflow } from "@/utils/approval.workflows.be";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { ObjectId } from "mongodb";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "PUT") return await put(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function put(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const user = await requestUser(req, res);
|
||||||
|
if (!user) return res.status(401).json({ ok: false });
|
||||||
|
|
||||||
|
if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) {
|
||||||
|
return res.status(403).json({ ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.query as { id?: string };
|
||||||
|
const approvalWorkflow: ApprovalWorkflow = req.body;
|
||||||
|
|
||||||
|
if (id && approvalWorkflow) {
|
||||||
|
approvalWorkflow._id = new ObjectId(id);
|
||||||
|
await updateApprovalWorkflow("active-workflows", approvalWorkflow);
|
||||||
|
return res.status(204).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/pages/api/approval-workflows/[id]/index.ts
Normal file
74
src/pages/api/approval-workflows/[id]/index.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { deleteApprovalWorkflow, getApprovalWorkflow, updateApprovalWorkflow } from "@/utils/approval.workflows.be";
|
||||||
|
import { getEntityWithRoles } from "@/utils/entities.be";
|
||||||
|
import { doesEntityAllow } from "@/utils/permissions";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { ObjectId } from "mongodb";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "DELETE") return await del(req, res);
|
||||||
|
if (req.method === "PUT") return await put(req, res);
|
||||||
|
if (req.method === "GET") return await get(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const user = await requestUser(req, res);
|
||||||
|
if (!user) return res.status(401).json({ ok: false });
|
||||||
|
|
||||||
|
if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) {
|
||||||
|
return res.status(403).json({ ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.query as { id: string };
|
||||||
|
const workflow = await getApprovalWorkflow("active-workflows", id);
|
||||||
|
|
||||||
|
if (!workflow) return res.status(404).json({ ok: false });
|
||||||
|
|
||||||
|
const entity = await getEntityWithRoles(workflow.entityId);
|
||||||
|
if (!entity) return res.status(404).json({ ok: false });
|
||||||
|
|
||||||
|
if (!doesEntityAllow(user, entity, "delete_workflow") && !["admin", "developer"].includes(user.type)) {
|
||||||
|
return res.status(403).json({ ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json(await deleteApprovalWorkflow("active-workflows", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function put(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const user = await requestUser(req, res);
|
||||||
|
if (!user) return res.status(401).json({ ok: false });
|
||||||
|
|
||||||
|
if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) {
|
||||||
|
return res.status(403).json({ ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.query as { id?: string };
|
||||||
|
const workflow: ApprovalWorkflow = req.body;
|
||||||
|
|
||||||
|
if (id && workflow) {
|
||||||
|
workflow._id = new ObjectId(id);
|
||||||
|
await updateApprovalWorkflow("active-workflows", workflow);
|
||||||
|
return res.status(204).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const user = await requestUser(req, res);
|
||||||
|
if (!user) return res.status(401).json({ ok: false });
|
||||||
|
|
||||||
|
if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) {
|
||||||
|
return res.status(403).json({ ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.query as { id?: string };
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
return res.status(200).json(await getApprovalWorkflow("active-workflows", id));
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/pages/api/approval-workflows/create.ts
Normal file
37
src/pages/api/approval-workflows/create.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
|
||||||
|
import { Entity } from "@/interfaces/entity";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { replaceApprovalWorkflowsByEntities } from "@/utils/approval.workflows.be";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
interface ReplaceApprovalWorkflowsRequest {
|
||||||
|
filteredWorkflows: ApprovalWorkflow[];
|
||||||
|
userEntitiesWithLabel: Entity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "POST") return await post(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const user = await requestUser(req, res);
|
||||||
|
if (!user) return res.status(401).json({ ok: false });
|
||||||
|
|
||||||
|
if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) {
|
||||||
|
return res.status(403).json({ ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { filteredWorkflows, userEntitiesWithLabel } = req.body as ReplaceApprovalWorkflowsRequest;
|
||||||
|
|
||||||
|
const configuredWorkflows: ApprovalWorkflow[] = filteredWorkflows;
|
||||||
|
const entitiesIds: string[] = userEntitiesWithLabel.map((e) => e.id);
|
||||||
|
|
||||||
|
await replaceApprovalWorkflowsByEntities(configuredWorkflows, entitiesIds);
|
||||||
|
|
||||||
|
return res.status(204).end();
|
||||||
|
}
|
||||||
23
src/pages/api/approval-workflows/index.ts
Normal file
23
src/pages/api/approval-workflows/index.ts
Normal 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"));
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import client from "@/lib/mongodb";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
|
||||||
import { sessionOptions } from "@/lib/session";
|
|
||||||
import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam";
|
|
||||||
import { getExams } from "@/utils/exams.be";
|
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { getUserCorporate } from "@/utils/groups.be";
|
import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam";
|
||||||
import { requestUser } from "@/utils/api";
|
import { createApprovalWorkflowsOnExamCreation } from "@/lib/createWorkflowsOnExamCreation";
|
||||||
import { isAdmin } from "@/utils/users";
|
import client from "@/lib/mongodb";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { mapBy } from "@/utils";
|
import { mapBy } from "@/utils";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { getApprovalWorkflowsByExamId, updateApprovalWorkflows } from "@/utils/approval.workflows.be";
|
||||||
|
import { generateExamDifferences } from "@/utils/exam.differences";
|
||||||
|
import { getExams } from "@/utils/exams.be";
|
||||||
|
import { isAdmin } from "@/utils/users";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
const db = client.db(process.env.MONGODB_DB);
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
@@ -40,13 +42,13 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return res.status(401).json({ ok: false });
|
if (!user) return res.status(401).json({ ok: false });
|
||||||
|
|
||||||
const { module } = req.query as { module: string };
|
const { module } = req.query as { module: string };
|
||||||
|
|
||||||
const session = client.startSession();
|
const session = client.startSession();
|
||||||
const entities = isAdmin(user) ? [] : mapBy(user.entities, 'id')
|
const entities = isAdmin(user) ? [] : mapBy(user.entities, "id"); // 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 {
|
try {
|
||||||
const exam = {
|
const exam = {
|
||||||
@@ -57,14 +59,20 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let responseStatus: number;
|
||||||
|
let responseMessage: string;
|
||||||
|
|
||||||
await session.withTransaction(async () => {
|
await session.withTransaction(async () => {
|
||||||
const docSnap = await db.collection(module).findOne<ExamBase>({ id: req.body.id }, { session });
|
const docSnap = await db.collection(module).findOne<ExamBase>({ id: req.body.id }, { session });
|
||||||
|
|
||||||
// Check whether the id of the exam matches another exam with different
|
// Check whether the id of the exam matches another exam with different
|
||||||
// owners, throw exception if there is, else allow editing
|
// owners, throw exception if there is, else allow editing
|
||||||
const ownersSet = new Set(docSnap?.owners || []);
|
const existingExamOwners = docSnap?.owners ?? [];
|
||||||
|
const newExamOwners = exam.owners ?? [];
|
||||||
|
|
||||||
if (docSnap !== null && docSnap?.owners?.length === exam.owners.lenght && exam.owners.every((e: string) => ownersSet.has(e))) {
|
const ownersSet = new Set(existingExamOwners);
|
||||||
|
|
||||||
|
if (docSnap !== null && (existingExamOwners.length !== newExamOwners.length || !newExamOwners.every((e: string) => ownersSet.has(e)))) {
|
||||||
throw new Error("Name already exists");
|
throw new Error("Name already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,13 +81,59 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
{ $set: { id: req.body.id, ...exam } },
|
{ $set: { id: req.body.id, ...exam } },
|
||||||
{
|
{
|
||||||
upsert: true,
|
upsert: true,
|
||||||
session
|
session,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// if it doesn't enter the next if condition it means the exam was updated and not created, so we can send this response.
|
||||||
|
responseStatus = 200;
|
||||||
|
responseMessage = `Successfully updated exam with ID: "${exam.id}"`;
|
||||||
|
// 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(responseStatus).json({
|
||||||
|
message: responseMessage,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(200).json(exam);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Transaction failed: ", error);
|
console.error("Transaction failed: ", error);
|
||||||
res.status(500).json({ ok: false, error: (error as any).message });
|
res.status(500).json({ ok: false, error: (error as any).message });
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const docs = await db.collection("tickets").find<Ticket>({ assignedTo: req.session.user.id }).toArray();
|
const docs = await db.collection("tickets").find<Ticket>({ assignedTo: req.session.user.id, status: { $ne: "completed" } }).toArray();
|
||||||
|
|
||||||
res.status(200).json(docs);
|
res.status(200).json(docs);
|
||||||
}
|
}
|
||||||
|
|||||||
197
src/pages/approval-workflows/[id]/edit.tsx
Normal file
197
src/pages/approval-workflows/[id]/edit.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
608
src/pages/approval-workflows/[id]/index.tsx
Normal file
608
src/pages/approval-workflows/[id]/index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
415
src/pages/approval-workflows/create.tsx
Normal file
415
src/pages/approval-workflows/create.tsx
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
import Tip from "@/components/ApprovalWorkflows/Tip";
|
||||||
|
import WorkflowForm from "@/components/ApprovalWorkflows/WorkflowForm";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
import { ApprovalWorkflow, EditableApprovalWorkflow } from "@/interfaces/approval.workflow";
|
||||||
|
import { Entity } from "@/interfaces/entity";
|
||||||
|
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser, User } from "@/interfaces/user";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { mapBy, redirect, serialize } from "@/utils";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { getApprovalWorkflowsByEntities } from "@/utils/approval.workflows.be";
|
||||||
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
|
import { findAllowedEntities } from "@/utils/permissions";
|
||||||
|
import { isAdmin } from "@/utils/users";
|
||||||
|
import { getEntitiesUsers } from "@/utils/users.be";
|
||||||
|
import axios from "axios";
|
||||||
|
import { AnimatePresence, LayoutGroup, motion } from "framer-motion";
|
||||||
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
|
import Head from "next/head";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { BsChevronLeft, BsTrash } from "react-icons/bs";
|
||||||
|
import { FaRegClone } from "react-icons/fa6";
|
||||||
|
import { MdFormatListBulletedAdd } from "react-icons/md";
|
||||||
|
import { toast, ToastContainer } from "react-toastify";
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
|
const user = await requestUser(req, res)
|
||||||
|
if (!user) return redirect("/login")
|
||||||
|
|
||||||
|
if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type))
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
const entityIDS = mapBy(user.entities, "id");
|
||||||
|
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
|
||||||
|
const userEntitiesWithLabel = findAllowedEntities(user, entities, "configure_workflows");
|
||||||
|
|
||||||
|
const allConfiguredWorkflows = await getApprovalWorkflowsByEntities("configured-workflows", userEntitiesWithLabel.map(entity => entity.id));
|
||||||
|
|
||||||
|
const approverTypes = ["teacher", "corporate", "mastercorporate"];
|
||||||
|
|
||||||
|
if (user.type === "developer") {
|
||||||
|
approverTypes.push("developer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userEntitiesApprovers = await getEntitiesUsers(userEntitiesWithLabel.map(entity => entity.id), { type: { $in: approverTypes } }) as (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: serialize({
|
||||||
|
user,
|
||||||
|
allConfiguredWorkflows,
|
||||||
|
userEntitiesWithLabel,
|
||||||
|
userEntitiesApprovers,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}, sessionOptions);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User,
|
||||||
|
allConfiguredWorkflows: EditableApprovalWorkflow[],
|
||||||
|
userEntitiesWithLabel: Entity[],
|
||||||
|
userEntitiesApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home({ user, allConfiguredWorkflows, userEntitiesWithLabel, userEntitiesApprovers }: Props) {
|
||||||
|
const [workflows, setWorkflows] = useState<EditableApprovalWorkflow[]>(allConfiguredWorkflows);
|
||||||
|
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | undefined>(undefined);
|
||||||
|
const [entityId, setEntityId] = useState<string | null | undefined>(null);
|
||||||
|
const [entityApprovers, setEntityApprovers] = useState<(TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[]>([]);
|
||||||
|
const [entityAvailableFormIntakers, setEntityAvailableFormIntakers] = useState<(TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[]>([]);
|
||||||
|
const [isAdding, setIsAdding] = useState<boolean>(false); // used to temporary timeout new workflow button. With animations, clicking too fast might cause state inconsistencies between renders.
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [isRedirecting, setIsRedirecting] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (entityId) {
|
||||||
|
setEntityApprovers(
|
||||||
|
userEntitiesApprovers.filter(approver =>
|
||||||
|
approver.entities.some(entity => entity.id === entityId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [entityId, userEntitiesApprovers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (entityId) {
|
||||||
|
// Get all workflows for the selected entity
|
||||||
|
const workflowsForEntity = workflows.filter(wf => wf.entityId === entityId);
|
||||||
|
|
||||||
|
// For all workflows except the current one, collect the first step assignees
|
||||||
|
const assignedFormIntakers = workflowsForEntity.reduce<string[]>((acc, wf) => {
|
||||||
|
if (wf.id === selectedWorkflowId) return acc; // skip current workflow so its selection isn’t removed
|
||||||
|
|
||||||
|
const formIntakeStep = wf.steps.find(step => step.stepType === "form-intake");
|
||||||
|
if (formIntakeStep) {
|
||||||
|
// Only consider non-null assignees
|
||||||
|
const validAssignees = formIntakeStep.assignees.filter(
|
||||||
|
(assignee): assignee is string => !!assignee
|
||||||
|
);
|
||||||
|
return acc.concat(validAssignees);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Now filter out any user from entityApprovers whose id is in the assignedFormIntakers list.
|
||||||
|
// (The selected one in the current workflow is allowed even if it is in the list.)
|
||||||
|
const availableFormIntakers = entityApprovers.filter(assignee =>
|
||||||
|
!assignedFormIntakers.includes(assignee.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
setEntityAvailableFormIntakers(availableFormIntakers);
|
||||||
|
}
|
||||||
|
}, [entityId, entityApprovers, workflows, selectedWorkflowId]);
|
||||||
|
|
||||||
|
|
||||||
|
const currentWorkflow = workflows.find(wf => wf.id === selectedWorkflowId);
|
||||||
|
|
||||||
|
const ENTITY_OPTIONS = userEntitiesWithLabel.map(entity => ({
|
||||||
|
label: entity.label,
|
||||||
|
value: entity.id,
|
||||||
|
filter: (x: EditableApprovalWorkflow) => x.entityId === entity.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
if (workflows.length === 0) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const workflow of workflows) {
|
||||||
|
for (const step of workflow.steps) {
|
||||||
|
if (step.assignees.every(x => !x)) {
|
||||||
|
toast.warning("There are empty steps in at least one of the configured workflows.");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredWorkflows: ApprovalWorkflow[] = workflows.map(workflow => ({
|
||||||
|
...workflow,
|
||||||
|
steps: workflow.steps.map(step => ({
|
||||||
|
...step,
|
||||||
|
assignees: step.assignees.filter((assignee): assignee is string => assignee !== null && assignee !== undefined)
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
const requestData = { filteredWorkflows, userEntitiesWithLabel };
|
||||||
|
|
||||||
|
axios
|
||||||
|
.post(`/api/approval-workflows/create`, requestData)
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Approval Workflows created successfully.");
|
||||||
|
setIsRedirecting(true);
|
||||||
|
router.push("/approval-workflows");
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
if (reason.response.status === 401) {
|
||||||
|
toast.error("Not logged in!");
|
||||||
|
}
|
||||||
|
else if (reason.response.status === 403) {
|
||||||
|
toast.error("You do not have permission to create Approval Workflows!");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error("Something went wrong, please try again later.");
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
console.log("Submitted Values:", filteredWorkflows);
|
||||||
|
return;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddNewWorkflow = () => {
|
||||||
|
if (isAdding) return;
|
||||||
|
setIsAdding(true);
|
||||||
|
|
||||||
|
const newId = uuidv4(); // this id is only used in UI. it is ommited on submission to DB and lets mongo handle unique id.
|
||||||
|
const newWorkflow: EditableApprovalWorkflow = {
|
||||||
|
id: newId,
|
||||||
|
name: "",
|
||||||
|
entityId: "",
|
||||||
|
modules: [],
|
||||||
|
requester: user.id,
|
||||||
|
startDate: Date.now(),
|
||||||
|
status: "pending",
|
||||||
|
steps: [
|
||||||
|
{ key: 9998, stepType: "form-intake", stepNumber: 1, completed: false, firstStep: true, finalStep: false, assignees: [null] },
|
||||||
|
{ key: 9999, stepType: "approval-by", stepNumber: 2, completed: false, firstStep: false, finalStep: true, assignees: [null] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
setWorkflows((prev) => [...prev, newWorkflow]);
|
||||||
|
handleSelectWorkflow(newId);
|
||||||
|
|
||||||
|
setTimeout(() => setIsAdding(false), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWorkflowChange = (updatedWorkflow: EditableApprovalWorkflow) => {
|
||||||
|
setWorkflows(prev =>
|
||||||
|
prev.map(wf => (wf.id === updatedWorkflow.id ? updatedWorkflow : wf))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectWorkflow = (id: string | undefined) => {
|
||||||
|
setSelectedWorkflowId(id);
|
||||||
|
const selectedWorkflow = workflows.find(wf => wf.id === id);
|
||||||
|
if (selectedWorkflow) {
|
||||||
|
setEntityId(selectedWorkflow.entityId || null);
|
||||||
|
} else {
|
||||||
|
setEntityId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloneWorkflow = (id: string) => {
|
||||||
|
const workflowToClone = workflows.find(wf => wf.id === id);
|
||||||
|
if (!workflowToClone) return;
|
||||||
|
|
||||||
|
const newId = uuidv4();
|
||||||
|
|
||||||
|
const clonedWorkflow: EditableApprovalWorkflow = {
|
||||||
|
...workflowToClone,
|
||||||
|
id: newId,
|
||||||
|
steps: workflowToClone.steps.map(step => ({
|
||||||
|
...step,
|
||||||
|
assignees: step.firstStep ? [null] : [...step.assignees], // we can't have more than one form intaker per teacher per entity
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
setWorkflows(prev => {
|
||||||
|
const updatedWorkflows = [...prev, clonedWorkflow];
|
||||||
|
setSelectedWorkflowId(newId);
|
||||||
|
setEntityId(clonedWorkflow.entityId || null);
|
||||||
|
return updatedWorkflows;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteWorkflow = (id: string) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete this Approval Workflow?`)) return;
|
||||||
|
|
||||||
|
const updatedWorkflows = workflows.filter(wf => wf.id !== id);
|
||||||
|
|
||||||
|
setWorkflows(updatedWorkflows);
|
||||||
|
|
||||||
|
if (selectedWorkflowId === id) {
|
||||||
|
handleSelectWorkflow(updatedWorkflows.find(wf => wf.id)?.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEntityChange = (wf: EditableApprovalWorkflow, entityId: string) => {
|
||||||
|
const updatedWorkflow = {
|
||||||
|
...wf,
|
||||||
|
entityId: entityId,
|
||||||
|
steps: wf.steps.map(step => ({
|
||||||
|
...step,
|
||||||
|
assignees: step.assignees.map(() => null)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onWorkflowChange(updatedWorkflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title> Configure Workflows | EnCoach</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
|
/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
<ToastContainer />
|
||||||
|
<section className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/approval-workflows"
|
||||||
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
||||||
|
<BsChevronLeft />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-semibold">{"Configure Approval Workflows"}</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Tip text="Setting a teacher as a Form Intaker means the configured workflow will be instantiated when said teacher publishes an exam. Only one Form Intake per teacher per entity is allowed." />
|
||||||
|
|
||||||
|
<section className="flex flex-row gap-6">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="solid"
|
||||||
|
onClick={handleAddNewWorkflow}
|
||||||
|
className="min-w-fit max-h-fit text-lg font-medium flex items-center gap-2 text-left"
|
||||||
|
>
|
||||||
|
<MdFormatListBulletedAdd className="size-6" />
|
||||||
|
Add New Workflow
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{workflows.length !== 0 && <div className="bg-gray-300 w-[1px]"></div>}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{workflows.map((workflow) => (
|
||||||
|
<Button
|
||||||
|
key={workflow.id}
|
||||||
|
color="purple"
|
||||||
|
variant={
|
||||||
|
selectedWorkflowId === workflow.id
|
||||||
|
? "solid"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
onClick={() => handleSelectWorkflow(workflow.id)}
|
||||||
|
className="min-w-fit text-lg font-medium flex items-center gap-2 text-left"
|
||||||
|
>
|
||||||
|
{workflow.name.trim() === "" ? "Workflow" : workflow.name}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{currentWorkflow && (
|
||||||
|
<>
|
||||||
|
<div className="mb-8 flex flex-row gap-6 items-end">
|
||||||
|
<Input
|
||||||
|
label="Name:"
|
||||||
|
type="text"
|
||||||
|
name={currentWorkflow.name}
|
||||||
|
placeholder="Enter workflow name"
|
||||||
|
value={currentWorkflow.name}
|
||||||
|
onChange={(updatedName) => {
|
||||||
|
const updatedWorkflow = {
|
||||||
|
...currentWorkflow,
|
||||||
|
name: updatedName,
|
||||||
|
};
|
||||||
|
onWorkflowChange(updatedWorkflow);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Entity:"
|
||||||
|
options={ENTITY_OPTIONS}
|
||||||
|
value={
|
||||||
|
currentWorkflow.entityId === ""
|
||||||
|
? null
|
||||||
|
: ENTITY_OPTIONS.find(option => option.value === currentWorkflow.entityId)
|
||||||
|
}
|
||||||
|
onChange={(selectedEntity) => {
|
||||||
|
if (currentWorkflow.entityId) {
|
||||||
|
if (!confirm("Clearing or changing entity will clear all the assignees for all steps in this workflow. Are you sure you want to proceed?")) return;
|
||||||
|
}
|
||||||
|
if (selectedEntity?.value) {
|
||||||
|
setEntityId(selectedEntity.value);
|
||||||
|
handleEntityChange(currentWorkflow, selectedEntity.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
placeholder="Enter workflow entity"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="gray"
|
||||||
|
variant="solid"
|
||||||
|
onClick={() => handleCloneWorkflow(currentWorkflow.id)}
|
||||||
|
type="button"
|
||||||
|
className="min-w-fit h-[72px] text-lg font-medium flex items-center gap-2 text-left"
|
||||||
|
>
|
||||||
|
Clone Workflow
|
||||||
|
<FaRegClone className="size-6" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
variant="solid"
|
||||||
|
onClick={() => handleDeleteWorkflow(currentWorkflow.id)}
|
||||||
|
type="button"
|
||||||
|
className="min-w-fit h-[72px] text-lg font-medium flex items-center gap-2 text-left"
|
||||||
|
>
|
||||||
|
Delete Workflow
|
||||||
|
<BsTrash className="size-6" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<LayoutGroup key={currentWorkflow.id}>
|
||||||
|
<motion.div
|
||||||
|
key="form"
|
||||||
|
initial={{ opacity: 0, y: -30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 60 }}
|
||||||
|
transition={{ duration: 0.20 }}
|
||||||
|
>
|
||||||
|
{(!currentWorkflow.name || !currentWorkflow.entityId) && (
|
||||||
|
<Tip text="Please fill in workflow name and associated entity to start configuring workflow." />
|
||||||
|
)}
|
||||||
|
<WorkflowForm
|
||||||
|
workflow={currentWorkflow}
|
||||||
|
onWorkflowChange={onWorkflowChange}
|
||||||
|
entityApprovers={entityApprovers}
|
||||||
|
entityAvailableFormIntakers={entityAvailableFormIntakers}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isRedirecting={isRedirecting}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</LayoutGroup>
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
401
src/pages/approval-workflows/index.tsx
Normal file
401
src/pages/approval-workflows/index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,16 +5,16 @@ import { useListSearch } from "@/hooks/useListSearch";
|
|||||||
import usePagination from "@/hooks/usePagination";
|
import usePagination from "@/hooks/usePagination";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { Assignment } from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import {
|
import {
|
||||||
activeAssignmentFilter,
|
activeAssignmentFilter,
|
||||||
archivedAssignmentFilter,
|
archivedAssignmentFilter,
|
||||||
futureAssignmentFilter,
|
futureAssignmentFilter,
|
||||||
pastAssignmentFilter,
|
pastAssignmentFilter,
|
||||||
startHasExpiredAssignmentFilter,
|
startHasExpiredAssignmentFilter,
|
||||||
} from "@/utils/assignments";
|
} from "@/utils/assignments";
|
||||||
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
|
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
|
||||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
@@ -28,197 +28,309 @@ import { useMemo } from "react";
|
|||||||
import { BsChevronLeft, BsPlus } from "react-icons/bs";
|
import { BsChevronLeft, BsPlus } from "react-icons/bs";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]))
|
if (
|
||||||
return redirect("/")
|
!checkAccess(user, [
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"corporate",
|
||||||
|
"teacher",
|
||||||
|
"mastercorporate",
|
||||||
|
])
|
||||||
|
)
|
||||||
|
return redirect("/");
|
||||||
|
const isAdmin = checkAccess(user, ["developer", "admin"]);
|
||||||
|
const entityIDS = mapBy(user.entities, "id") || [];
|
||||||
|
const entities = await (isAdmin
|
||||||
|
? getEntitiesWithRoles()
|
||||||
|
: getEntitiesWithRoles(entityIDS));
|
||||||
|
|
||||||
const entityIDS = mapBy(user.entities, "id") || [];
|
const allowedEntities = findAllowedEntities(
|
||||||
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS));
|
user,
|
||||||
|
entities,
|
||||||
|
"view_assignments"
|
||||||
|
);
|
||||||
|
const [users, assignments] = await Promise.all([
|
||||||
|
await (isAdmin
|
||||||
|
? getUsers({}, 0, {}, { _id: 0, id: 1, name: 1 })
|
||||||
|
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
})),
|
||||||
|
await (isAdmin
|
||||||
|
? getAssignments()
|
||||||
|
: getEntitiesAssignments(mapBy(allowedEntities, "id"))),
|
||||||
|
]);
|
||||||
|
|
||||||
const allowedEntities = findAllowedEntities(user, entities, "view_assignments")
|
return {
|
||||||
|
props: serialize({ user, users, entities: allowedEntities, assignments }),
|
||||||
const users =
|
};
|
||||||
await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id')));
|
|
||||||
|
|
||||||
const assignments =
|
|
||||||
await (checkAccess(user, ["developer", "admin"]) ? getAssignments() : getEntitiesAssignments(mapBy(allowedEntities, 'id')));
|
|
||||||
|
|
||||||
return { props: serialize({ user, users, entities: allowedEntities, assignments }) };
|
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
const SEARCH_FIELDS = [["name"]];
|
const SEARCH_FIELDS = [["name"]];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assignments: Assignment[];
|
assignments: Assignment[];
|
||||||
entities: EntityWithRoles[]
|
entities: EntityWithRoles[];
|
||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
users: User[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentsPage({ assignments, entities, user, users }: Props) {
|
export default function AssignmentsPage({
|
||||||
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_assignment')
|
assignments,
|
||||||
const entitiesAllowEdit = useAllowedEntities(user, entities, 'edit_assignment')
|
entities,
|
||||||
const entitiesAllowArchive = useAllowedEntities(user, entities, 'archive_assignment')
|
user,
|
||||||
|
users,
|
||||||
|
}: Props) {
|
||||||
|
const entitiesAllowCreate = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"create_assignment"
|
||||||
|
);
|
||||||
|
const entitiesAllowEdit = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"edit_assignment"
|
||||||
|
);
|
||||||
|
const entitiesAllowArchive = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"archive_assignment"
|
||||||
|
);
|
||||||
|
|
||||||
const activeAssignments = useMemo(() => assignments.filter(activeAssignmentFilter), [assignments]);
|
const activeAssignments = useMemo(
|
||||||
const plannedAssignments = useMemo(() => assignments.filter(futureAssignmentFilter), [assignments]);
|
() => assignments.filter(activeAssignmentFilter),
|
||||||
const pastAssignments = useMemo(() => assignments.filter(pastAssignmentFilter), [assignments]);
|
[assignments]
|
||||||
const startExpiredAssignments = useMemo(() => assignments.filter(startHasExpiredAssignmentFilter), [assignments]);
|
);
|
||||||
const archivedAssignments = useMemo(() => assignments.filter(archivedAssignmentFilter), [assignments]);
|
const plannedAssignments = useMemo(
|
||||||
|
() => assignments.filter(futureAssignmentFilter),
|
||||||
|
[assignments]
|
||||||
|
);
|
||||||
|
const pastAssignments = useMemo(
|
||||||
|
() => assignments.filter(pastAssignmentFilter),
|
||||||
|
[assignments]
|
||||||
|
);
|
||||||
|
const startExpiredAssignments = useMemo(
|
||||||
|
() => assignments.filter(startHasExpiredAssignmentFilter),
|
||||||
|
[assignments]
|
||||||
|
);
|
||||||
|
const archivedAssignments = useMemo(
|
||||||
|
() => assignments.filter(archivedAssignmentFilter),
|
||||||
|
[assignments]
|
||||||
|
);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { rows: activeRows, renderSearch: renderActive } = useListSearch(SEARCH_FIELDS, activeAssignments);
|
const { rows: activeRows, renderSearch: renderActive } = useListSearch(
|
||||||
const { rows: plannedRows, renderSearch: renderPlanned } = useListSearch(SEARCH_FIELDS, plannedAssignments);
|
SEARCH_FIELDS,
|
||||||
const { rows: pastRows, renderSearch: renderPast } = useListSearch(SEARCH_FIELDS, pastAssignments);
|
activeAssignments
|
||||||
const { rows: expiredRows, renderSearch: renderExpired } = useListSearch(SEARCH_FIELDS, startExpiredAssignments);
|
);
|
||||||
const { rows: archivedRows, renderSearch: renderArchived } = useListSearch(SEARCH_FIELDS, archivedAssignments);
|
const { rows: plannedRows, renderSearch: renderPlanned } = useListSearch(
|
||||||
|
SEARCH_FIELDS,
|
||||||
|
plannedAssignments
|
||||||
|
);
|
||||||
|
const { rows: pastRows, renderSearch: renderPast } = useListSearch(
|
||||||
|
SEARCH_FIELDS,
|
||||||
|
pastAssignments
|
||||||
|
);
|
||||||
|
const { rows: expiredRows, renderSearch: renderExpired } = useListSearch(
|
||||||
|
SEARCH_FIELDS,
|
||||||
|
startExpiredAssignments
|
||||||
|
);
|
||||||
|
const { rows: archivedRows, renderSearch: renderArchived } = useListSearch(
|
||||||
|
SEARCH_FIELDS,
|
||||||
|
archivedAssignments
|
||||||
|
);
|
||||||
|
|
||||||
const { items: activeItems, renderMinimal: paginationActive } = usePagination(activeRows, 16);
|
const { items: activeItems, renderMinimal: paginationActive } = usePagination(
|
||||||
const { items: plannedItems, renderMinimal: paginationPlanned } = usePagination(plannedRows, 16);
|
activeRows,
|
||||||
const { items: pastItems, renderMinimal: paginationPast } = usePagination(pastRows, 16);
|
16
|
||||||
const { items: expiredItems, renderMinimal: paginationExpired } = usePagination(expiredRows, 16);
|
);
|
||||||
const { items: archivedItems, renderMinimal: paginationArchived } = usePagination(archivedRows, 16);
|
const { items: plannedItems, renderMinimal: paginationPlanned } =
|
||||||
|
usePagination(plannedRows, 16);
|
||||||
|
const { items: pastItems, renderMinimal: paginationPast } = usePagination(
|
||||||
|
pastRows,
|
||||||
|
16
|
||||||
|
);
|
||||||
|
const { items: expiredItems, renderMinimal: paginationExpired } =
|
||||||
|
usePagination(expiredRows, 16);
|
||||||
|
const { items: archivedItems, renderMinimal: paginationArchived } =
|
||||||
|
usePagination(archivedRows, 16);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Assignments | EnCoach</title>
|
<title>Assignments | EnCoach</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link href="/dashboard" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
<Link
|
||||||
<BsChevronLeft />
|
href="/dashboard"
|
||||||
</Link>
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||||
<h2 className="font-bold text-2xl">Assignments</h2>
|
>
|
||||||
</div>
|
<BsChevronLeft />
|
||||||
<Separator />
|
</Link>
|
||||||
</div>
|
<h2 className="font-bold text-2xl">Assignments</h2>
|
||||||
<div className="flex flex-col gap-2">
|
</div>
|
||||||
<span className="text-lg font-bold">Active Assignments Status</span>
|
<Separator />
|
||||||
<div className="flex items-center gap-4">
|
</div>
|
||||||
<span>
|
<div className="flex flex-col gap-2">
|
||||||
<b>Total:</b> {activeAssignments.reduce((acc, curr) => acc + curr.results.length, 0)}/
|
<span className="text-lg font-bold">Active Assignments Status</span>
|
||||||
{activeAssignments.reduce((acc, curr) => curr.exams.length + acc, 0)}
|
<div className="flex items-center gap-4">
|
||||||
</span>
|
<span>
|
||||||
</div>
|
<b>Total:</b>{" "}
|
||||||
</div>
|
{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">
|
<section className="flex flex-col gap-4">
|
||||||
<h2 className="text-2xl font-semibold">Active Assignments ({activeAssignments.length})</h2>
|
<h2 className="text-2xl font-semibold">
|
||||||
<div className="w-full flex items-center gap-4">
|
Active Assignments ({activeAssignments.length})
|
||||||
{renderActive()}
|
</h2>
|
||||||
{paginationActive()}
|
<div className="w-full flex items-center gap-4">
|
||||||
</div>
|
{renderActive()}
|
||||||
<div className="flex flex-wrap gap-2">
|
{paginationActive()}
|
||||||
{activeItems.map((a) => (
|
</div>
|
||||||
<AssignmentCard {...a} entityObj={findBy(entities, 'id', a.entity)} users={users} onClick={() => router.push(`/assignments/${a.id}`)} key={a.id} />
|
<div className="flex flex-wrap gap-2">
|
||||||
))}
|
{activeItems.map((a) => (
|
||||||
</div>
|
<AssignmentCard
|
||||||
</section>
|
{...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">
|
<section className="flex flex-col gap-4">
|
||||||
<h2 className="text-2xl font-semibold">Planned Assignments ({plannedAssignments.length})</h2>
|
<h2 className="text-2xl font-semibold">
|
||||||
<div className="w-full flex items-center gap-4">
|
Planned Assignments ({plannedAssignments.length})
|
||||||
{renderPlanned()}
|
</h2>
|
||||||
{paginationPlanned()}
|
<div className="w-full flex items-center gap-4">
|
||||||
</div>
|
{renderPlanned()}
|
||||||
<div className="flex flex-wrap gap-2">
|
{paginationPlanned()}
|
||||||
<Link
|
</div>
|
||||||
href={entitiesAllowCreate.length > 0 ? "/assignments/creator" : ""}
|
<div className="flex flex-wrap gap-2">
|
||||||
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">
|
<Link
|
||||||
<BsPlus className="text-6xl" />
|
href={
|
||||||
<span className="text-lg">New Assignment</span>
|
entitiesAllowCreate.length > 0 ? "/assignments/creator" : ""
|
||||||
</Link>
|
}
|
||||||
{plannedItems.map((a) => (
|
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"
|
||||||
<AssignmentCard
|
>
|
||||||
{...a}
|
<BsPlus className="text-6xl" />
|
||||||
users={users}
|
<span className="text-lg">New Assignment</span>
|
||||||
entityObj={findBy(entities, 'id', a.entity)}
|
</Link>
|
||||||
onClick={
|
{plannedItems.map((a) => (
|
||||||
mapBy(entitiesAllowEdit, 'id').includes(a.entity || "")
|
<AssignmentCard
|
||||||
? () => router.push(`/assignments/creator/${a.id}`)
|
{...a}
|
||||||
: () => router.push(`/assignments/${a.id}`)
|
users={users}
|
||||||
}
|
entityObj={findBy(entities, "id", a.entity)}
|
||||||
key={a.id}
|
onClick={
|
||||||
/>
|
mapBy(entitiesAllowEdit, "id").includes(a.entity || "")
|
||||||
))}
|
? () => router.push(`/assignments/creator/${a.id}`)
|
||||||
</div>
|
: () => router.push(`/assignments/${a.id}`)
|
||||||
</section>
|
}
|
||||||
|
key={a.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<h2 className="text-2xl font-semibold">Past Assignments ({pastAssignments.length})</h2>
|
<h2 className="text-2xl font-semibold">
|
||||||
<div className="w-full flex items-center gap-4">
|
Past Assignments ({pastAssignments.length})
|
||||||
{renderPast()}
|
</h2>
|
||||||
{paginationPast()}
|
<div className="w-full flex items-center gap-4">
|
||||||
</div>
|
{renderPast()}
|
||||||
<div className="flex flex-wrap gap-2">
|
{paginationPast()}
|
||||||
{pastItems.map((a) => (
|
</div>
|
||||||
<AssignmentCard
|
<div className="flex flex-wrap gap-2">
|
||||||
{...a}
|
{pastItems.map((a) => (
|
||||||
users={users}
|
<AssignmentCard
|
||||||
entityObj={findBy(entities, 'id', a.entity)}
|
{...a}
|
||||||
onClick={() => router.push(`/assignments/${a.id}`)}
|
users={users}
|
||||||
key={a.id}
|
entityObj={findBy(entities, "id", a.entity)}
|
||||||
allowDownload
|
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||||
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")}
|
key={a.id}
|
||||||
allowExcelDownload
|
allowDownload
|
||||||
/>
|
allowArchive={mapBy(entitiesAllowArchive, "id").includes(
|
||||||
))}
|
a.entity || ""
|
||||||
</div>
|
)}
|
||||||
</section>
|
allowExcelDownload
|
||||||
<section className="flex flex-col gap-4">
|
/>
|
||||||
<h2 className="text-2xl font-semibold">Assignments start expired ({startExpiredAssignments.length})</h2>
|
))}
|
||||||
<div className="w-full flex items-center gap-4">
|
</div>
|
||||||
{renderExpired()}
|
</section>
|
||||||
{paginationExpired()}
|
<section className="flex flex-col gap-4">
|
||||||
</div>
|
<h2 className="text-2xl font-semibold">
|
||||||
<div className="flex flex-wrap gap-2">
|
Assignments start expired ({startExpiredAssignments.length})
|
||||||
{expiredItems.map((a) => (
|
</h2>
|
||||||
<AssignmentCard
|
<div className="w-full flex items-center gap-4">
|
||||||
{...a}
|
{renderExpired()}
|
||||||
users={users}
|
{paginationExpired()}
|
||||||
entityObj={findBy(entities, 'id', a.entity)}
|
</div>
|
||||||
onClick={() => router.push(`/assignments/${a.id}`)}
|
<div className="flex flex-wrap gap-2">
|
||||||
key={a.id}
|
{expiredItems.map((a) => (
|
||||||
allowDownload
|
<AssignmentCard
|
||||||
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")}
|
{...a}
|
||||||
allowExcelDownload
|
users={users}
|
||||||
/>
|
entityObj={findBy(entities, "id", a.entity)}
|
||||||
))}
|
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||||
</div>
|
key={a.id}
|
||||||
</section>
|
allowDownload
|
||||||
<section className="flex flex-col gap-4">
|
allowArchive={mapBy(entitiesAllowArchive, "id").includes(
|
||||||
<h2 className="text-2xl font-semibold">Archived Assignments ({archivedAssignments.length})</h2>
|
a.entity || ""
|
||||||
<div className="w-full flex items-center gap-4">
|
)}
|
||||||
{renderArchived()}
|
allowExcelDownload
|
||||||
{paginationArchived()}
|
/>
|
||||||
</div>
|
))}
|
||||||
<div className="flex flex-wrap gap-2">
|
</div>
|
||||||
{archivedItems.map((a) => (
|
</section>
|
||||||
<AssignmentCard
|
<section className="flex flex-col gap-4">
|
||||||
{...a}
|
<h2 className="text-2xl font-semibold">
|
||||||
users={users}
|
Archived Assignments ({archivedAssignments.length})
|
||||||
onClick={() => router.push(`/assignments/${a.id}`)}
|
</h2>
|
||||||
key={a.id}
|
<div className="w-full flex items-center gap-4">
|
||||||
entityObj={findBy(entities, 'id', a.entity)}
|
{renderArchived()}
|
||||||
allowDownload
|
{paginationArchived()}
|
||||||
allowUnarchive
|
</div>
|
||||||
allowExcelDownload
|
<div className="flex flex-wrap gap-2">
|
||||||
/>
|
{archivedItems.map((a) => (
|
||||||
))}
|
<AssignmentCard
|
||||||
</div>
|
{...a}
|
||||||
</section>
|
users={users}
|
||||||
</>
|
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||||
</>
|
key={a.id}
|
||||||
);
|
entityObj={findBy(entities, "id", a.entity)}
|
||||||
|
allowDownload
|
||||||
|
allowUnarchive
|
||||||
|
allowExcelDownload
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,328 +18,476 @@ import { getEntityUsers, getSpecificUsers } from "@/utils/users.be";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import { capitalize } from "lodash";
|
import { capitalize, last } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Divider } from "primereact/divider";
|
import { Divider } from "primereact/divider";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { BsBuilding, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsFillPersonVcardFill, BsPlus, BsStopwatchFill, BsTag, BsTrash, BsX } from "react-icons/bs";
|
import {
|
||||||
|
BsBuilding,
|
||||||
|
BsChevronLeft,
|
||||||
|
BsClockFill,
|
||||||
|
BsEnvelopeFill,
|
||||||
|
BsFillPersonVcardFill,
|
||||||
|
BsPlus,
|
||||||
|
BsStopwatchFill,
|
||||||
|
BsTag,
|
||||||
|
BsTrash,
|
||||||
|
BsX,
|
||||||
|
} from "react-icons/bs";
|
||||||
import { toast, ToastContainer } from "react-toastify";
|
import { toast, ToastContainer } from "react-toastify";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
|
export const getServerSideProps = withIronSessionSsr(
|
||||||
const user = await requestUser(req, res)
|
async ({ req, res, params }) => {
|
||||||
if (!user) return redirect("/login")
|
const user = await requestUser(req, res);
|
||||||
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
|
|
||||||
const { id } = params as { id: string };
|
const { id } = params as { id: string };
|
||||||
|
|
||||||
const group = await getGroup(id);
|
const group = await getGroup(id);
|
||||||
if (!group || !group.entity) return redirect("/classrooms")
|
if (!group || !group.entity) return redirect("/classrooms");
|
||||||
|
|
||||||
const entity = await getEntityWithRoles(group.entity)
|
const entity = await getEntityWithRoles(group.entity);
|
||||||
if (!entity) return redirect("/classrooms")
|
if (!entity) return redirect("/classrooms");
|
||||||
|
|
||||||
const canView = doesEntityAllow(user, entity, "view_classrooms")
|
const canView = doesEntityAllow(user, entity, "view_classrooms");
|
||||||
if (!canView) return redirect("/")
|
if (!canView) return redirect("/");
|
||||||
|
const [linkedUsers, users] = await Promise.all([
|
||||||
|
getEntityUsers(
|
||||||
|
entity.id,
|
||||||
|
0,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
email: 1,
|
||||||
|
corporateInformation: 1,
|
||||||
|
type: 1,
|
||||||
|
profilePicture: 1,
|
||||||
|
subscriptionExpirationDate: 1,
|
||||||
|
lastLogin: 1,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
getSpecificUsers([...group.participants, group.admin], {
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
email: 1,
|
||||||
|
corporateInformation: 1,
|
||||||
|
type: 1,
|
||||||
|
profilePicture: 1,
|
||||||
|
subscriptionExpirationDate: 1,
|
||||||
|
lastLogin: 1,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const linkedUsers = await getEntityUsers(entity.id)
|
const groupWithUser = convertToUsers(group, users);
|
||||||
const users = await getSpecificUsers([...group.participants, group.admin]);
|
|
||||||
const groupWithUser = convertToUsers(group, users);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({ user, group: groupWithUser, users: linkedUsers.filter(x => isAdmin(user) ? true : !isAdmin(x)), entity }),
|
props: serialize({
|
||||||
};
|
user,
|
||||||
}, sessionOptions);
|
group: groupWithUser,
|
||||||
|
users: linkedUsers.filter((x) => (isAdmin(user) ? true : !isAdmin(x))),
|
||||||
|
entity,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
sessionOptions
|
||||||
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
group: GroupWithUsers;
|
group: GroupWithUsers;
|
||||||
users: User[];
|
users: User[];
|
||||||
entity: EntityWithRoles
|
entity: EntityWithRoles;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({ user, group, users, entity }: Props) {
|
export default function Home({ user, group, users, entity }: Props) {
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
|
|
||||||
const canAddParticipants = useEntityPermission(user, entity, "add_to_classroom")
|
const canAddParticipants = useEntityPermission(
|
||||||
const canRemoveParticipants = useEntityPermission(user, entity, "remove_from_classroom")
|
user,
|
||||||
const canRenameClassroom = useEntityPermission(user, entity, "rename_classrooms")
|
entity,
|
||||||
const canDeleteClassroom = useEntityPermission(user, entity, "delete_classroom")
|
"add_to_classroom"
|
||||||
|
);
|
||||||
|
const canRemoveParticipants = useEntityPermission(
|
||||||
|
user,
|
||||||
|
entity,
|
||||||
|
"remove_from_classroom"
|
||||||
|
);
|
||||||
|
const canRenameClassroom = useEntityPermission(
|
||||||
|
user,
|
||||||
|
entity,
|
||||||
|
"rename_classrooms"
|
||||||
|
);
|
||||||
|
const canDeleteClassroom = useEntityPermission(
|
||||||
|
user,
|
||||||
|
entity,
|
||||||
|
"delete_classroom"
|
||||||
|
);
|
||||||
|
|
||||||
const nonParticipantUsers = useMemo(
|
const nonParticipantUsers = useMemo(
|
||||||
() => users.filter((x) => ![...group.participants.map((g) => g.id), group.admin.id, user.id].includes(x.id)),
|
() =>
|
||||||
[users, group.participants, group.admin.id, user.id],
|
users.filter(
|
||||||
);
|
(x) =>
|
||||||
|
![
|
||||||
|
...group.participants.map((g) => g.id),
|
||||||
|
group.admin.id,
|
||||||
|
user.id,
|
||||||
|
].includes(x.id)
|
||||||
|
),
|
||||||
|
[users, group.participants, group.admin.id, user.id]
|
||||||
|
);
|
||||||
|
|
||||||
const { rows, renderSearch } = useListSearch<User>(
|
const { rows, renderSearch } = useListSearch<User>(
|
||||||
[["name"], ["corporateInformation", "companyInformation", "name"]],
|
[["name"], ["corporateInformation", "companyInformation", "name"]],
|
||||||
isAdding ? nonParticipantUsers : group.participants,
|
isAdding ? nonParticipantUsers : group.participants
|
||||||
);
|
);
|
||||||
const { items, renderMinimal } = usePagination<User>(rows, 20);
|
const { items, renderMinimal } = usePagination<User>(rows, 20);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
|
const toggleUser = (u: User) =>
|
||||||
|
setSelectedUsers((prev) =>
|
||||||
|
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
|
||||||
|
);
|
||||||
|
|
||||||
const removeParticipants = () => {
|
const removeParticipants = () => {
|
||||||
if (selectedUsers.length === 0) return;
|
if (selectedUsers.length === 0) return;
|
||||||
if (!canRemoveParticipants) return;
|
if (!canRemoveParticipants) return;
|
||||||
if (!confirm(`Are you sure you want to remove ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} from this group?`))
|
if (
|
||||||
return;
|
!confirm(
|
||||||
|
`Are you sure you want to remove ${selectedUsers.length} participant${
|
||||||
|
selectedUsers.length === 1 ? "" : "s"
|
||||||
|
} from this group?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch(`/api/groups/${group.id}`, { participants: group.participants.map((x) => x.id).filter((x) => !selectedUsers.includes(x)) })
|
.patch(`/api/groups/${group.id}`, {
|
||||||
.then(() => {
|
participants: group.participants
|
||||||
toast.success("The group has been updated successfully!");
|
.map((x) => x.id)
|
||||||
router.replace(router.asPath);
|
.filter((x) => !selectedUsers.includes(x)),
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.then(() => {
|
||||||
console.error(e);
|
toast.success("The group has been updated successfully!");
|
||||||
toast.error("Something went wrong!");
|
router.replace(router.asPath);
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.catch((e) => {
|
||||||
};
|
console.error(e);
|
||||||
|
toast.error("Something went wrong!");
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
const addParticipants = () => {
|
const addParticipants = () => {
|
||||||
if (selectedUsers.length === 0) return;
|
if (selectedUsers.length === 0) return;
|
||||||
if (!canAddParticipants || !isAdding) return;
|
if (!canAddParticipants || !isAdding) return;
|
||||||
if (!confirm(`Are you sure you want to add ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} to this group?`))
|
if (
|
||||||
return;
|
!confirm(
|
||||||
|
`Are you sure you want to add ${selectedUsers.length} participant${
|
||||||
|
selectedUsers.length === 1 ? "" : "s"
|
||||||
|
} to this group?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch(`/api/groups/${group.id}`, { participants: [...group.participants.map((x) => x.id), ...selectedUsers] })
|
.patch(`/api/groups/${group.id}`, {
|
||||||
.then(() => {
|
participants: [
|
||||||
toast.success("The group has been updated successfully!");
|
...group.participants.map((x) => x.id),
|
||||||
router.replace(router.asPath);
|
...selectedUsers,
|
||||||
})
|
],
|
||||||
.catch((e) => {
|
})
|
||||||
console.error(e);
|
.then(() => {
|
||||||
toast.error("Something went wrong!");
|
toast.success("The group has been updated successfully!");
|
||||||
})
|
router.replace(router.asPath);
|
||||||
.finally(() => setIsLoading(false));
|
})
|
||||||
};
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Something went wrong!");
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
const renameGroup = () => {
|
const renameGroup = () => {
|
||||||
if (!canRenameClassroom) return;
|
if (!canRenameClassroom) return;
|
||||||
|
|
||||||
const name = prompt("Rename this classroom:", group.name);
|
const name = prompt("Rename this classroom:", group.name);
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.patch(`/api/groups/${group.id}`, { name })
|
.patch(`/api/groups/${group.id}`, { name })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("The classroom has been updated successfully!");
|
toast.success("The classroom has been updated successfully!");
|
||||||
router.replace(router.asPath);
|
router.replace(router.asPath);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast.error("Something went wrong!");
|
toast.error("Something went wrong!");
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteGroup = () => {
|
const deleteGroup = () => {
|
||||||
if (!canDeleteClassroom) return;
|
if (!canDeleteClassroom) return;
|
||||||
if (!confirm("Are you sure you want to delete this classroom?")) return;
|
if (!confirm("Are you sure you want to delete this classroom?")) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete(`/api/groups/${group.id}`)
|
.delete(`/api/groups/${group.id}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("This classroom has been successfully deleted!");
|
toast.success("This classroom has been successfully deleted!");
|
||||||
router.replace("/classrooms");
|
router.replace("/classrooms");
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast.error("Something went wrong!");
|
toast.error("Something went wrong!");
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => setSelectedUsers([]), [isAdding]);
|
useEffect(() => setSelectedUsers([]), [isAdding]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{group.name} | EnCoach</title>
|
<title>{group.name} | EnCoach</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
<section className="flex flex-col gap-0">
|
<section className="flex flex-col gap-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/classrooms"
|
href="/classrooms"
|
||||||
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||||
<BsChevronLeft />
|
>
|
||||||
</Link>
|
<BsChevronLeft />
|
||||||
<h2 className="font-bold text-2xl">{group.name}</h2>
|
</Link>
|
||||||
</div>
|
<h2 className="font-bold text-2xl">{group.name}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!isAdding && (
|
{!isAdding && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={renameGroup}
|
onClick={renameGroup}
|
||||||
disabled={isLoading || !canRenameClassroom}
|
disabled={isLoading || !canRenameClassroom}
|
||||||
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
|
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
|
||||||
<BsTag />
|
>
|
||||||
<span className="text-xs">Rename Classroom</span>
|
<BsTag />
|
||||||
</button>
|
<span className="text-xs">Rename Classroom</span>
|
||||||
<button
|
</button>
|
||||||
onClick={deleteGroup}
|
<button
|
||||||
disabled={isLoading || !canDeleteClassroom}
|
onClick={deleteGroup}
|
||||||
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={isLoading || !canDeleteClassroom}
|
||||||
<BsTrash />
|
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"
|
||||||
<span className="text-xs">Delete Classroom</span>
|
>
|
||||||
</button>
|
<BsTrash />
|
||||||
</div>
|
<span className="text-xs">Delete Classroom</span>
|
||||||
)}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
)}
|
||||||
<span className="flex items-center gap-2">
|
</div>
|
||||||
<BsBuilding className="text-xl" /> {entity.label}
|
<div className="flex flex-col gap-2">
|
||||||
</span>
|
<span className="flex items-center gap-2">
|
||||||
<span className="flex items-center gap-2">
|
<BsBuilding className="text-xl" /> {entity.label}
|
||||||
<BsFillPersonVcardFill className="text-xl" /> {getUserName(group.admin)}
|
</span>
|
||||||
</span>
|
<span className="flex items-center gap-2">
|
||||||
</div>
|
<BsFillPersonVcardFill className="text-xl" />{" "}
|
||||||
</div>
|
{getUserName(group.admin)}
|
||||||
<Divider />
|
</span>
|
||||||
<div className="flex items-center justify-between mb-4">
|
</div>
|
||||||
<span className="font-semibold text-xl">Participants</span>
|
</div>
|
||||||
{!isAdding && (
|
<Divider />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button
|
<span className="font-semibold text-xl">Participants</span>
|
||||||
onClick={() => setIsAdding(true)}
|
{!isAdding && (
|
||||||
disabled={isLoading || !canAddParticipants}
|
<div className="flex items-center gap-2">
|
||||||
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">
|
<button
|
||||||
<BsPlus />
|
onClick={() => setIsAdding(true)}
|
||||||
<span className="text-xs">Add Participants</span>
|
disabled={isLoading || !canAddParticipants}
|
||||||
</button>
|
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"
|
||||||
<button
|
>
|
||||||
onClick={removeParticipants}
|
<BsPlus />
|
||||||
disabled={selectedUsers.length === 0 || isLoading || !canRemoveParticipants}
|
<span className="text-xs">Add Participants</span>
|
||||||
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">
|
</button>
|
||||||
<BsTrash />
|
<button
|
||||||
<span className="text-xs">Remove Participants</span>
|
onClick={removeParticipants}
|
||||||
</button>
|
disabled={
|
||||||
</div>
|
selectedUsers.length === 0 ||
|
||||||
)}
|
isLoading ||
|
||||||
{isAdding && (
|
!canRemoveParticipants
|
||||||
<div className="flex items-center gap-2">
|
}
|
||||||
<button
|
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"
|
||||||
onClick={() => setIsAdding(false)}
|
>
|
||||||
disabled={isLoading}
|
<BsTrash />
|
||||||
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">
|
<span className="text-xs">Remove Participants</span>
|
||||||
<BsX />
|
</button>
|
||||||
<span className="text-xs">Discard Selection</span>
|
</div>
|
||||||
</button>
|
)}
|
||||||
<button
|
{isAdding && (
|
||||||
onClick={addParticipants}
|
<div className="flex items-center gap-2">
|
||||||
disabled={selectedUsers.length === 0 || isLoading || !canAddParticipants}
|
<button
|
||||||
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">
|
onClick={() => setIsAdding(false)}
|
||||||
<BsPlus />
|
disabled={isLoading}
|
||||||
<span className="text-xs">Add Participants</span>
|
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"
|
||||||
</button>
|
>
|
||||||
</div>
|
<BsX />
|
||||||
)}
|
<span className="text-xs">Discard Selection</span>
|
||||||
</div>
|
</button>
|
||||||
<div className="w-full flex items-center gap-4">
|
<button
|
||||||
{renderSearch()}
|
onClick={addParticipants}
|
||||||
{renderMinimal()}
|
disabled={
|
||||||
</div>
|
selectedUsers.length === 0 ||
|
||||||
<div className="flex items-center gap-2 mt-4">
|
isLoading ||
|
||||||
{['student', 'teacher', 'corporate'].map((type) => (
|
!canAddParticipants
|
||||||
<button
|
}
|
||||||
key={type}
|
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"
|
||||||
onClick={() => {
|
>
|
||||||
const typeUsers = mapBy(filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type), 'id')
|
<BsPlus />
|
||||||
if (typeUsers.every((u) => selectedUsers.includes(u))) {
|
<span className="text-xs">Add Participants</span>
|
||||||
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a)));
|
</button>
|
||||||
} else {
|
</div>
|
||||||
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]);
|
)}
|
||||||
}
|
</div>
|
||||||
}}
|
<div className="w-full flex items-center gap-4">
|
||||||
disabled={filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length === 0}
|
{renderSearch()}
|
||||||
className={clsx(
|
{renderMinimal()}
|
||||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
</div>
|
||||||
"transition duration-300 ease-in-out",
|
<div className="flex items-center gap-2 mt-4">
|
||||||
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
|
{["student", "teacher", "corporate"].map((type) => (
|
||||||
filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length > 0 &&
|
<button
|
||||||
filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).every((u) => selectedUsers.includes(u.id)) &&
|
key={type}
|
||||||
"!bg-mti-purple-light !text-white",
|
onClick={() => {
|
||||||
)}>
|
const typeUsers = mapBy(
|
||||||
{capitalize(type)}
|
filterBy(
|
||||||
</button>
|
isAdding ? nonParticipantUsers : group.participants,
|
||||||
))}
|
"type",
|
||||||
</div>
|
type
|
||||||
</section>
|
),
|
||||||
|
"id"
|
||||||
|
);
|
||||||
|
if (typeUsers.every((u) => selectedUsers.includes(u))) {
|
||||||
|
setSelectedUsers((prev) =>
|
||||||
|
prev.filter((a) => !typeUsers.includes(a))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedUsers((prev) => [
|
||||||
|
...prev.filter((a) => !typeUsers.includes(a)),
|
||||||
|
...typeUsers,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
filterBy(
|
||||||
|
isAdding ? nonParticipantUsers : group.participants,
|
||||||
|
"type",
|
||||||
|
type
|
||||||
|
).length === 0
|
||||||
|
}
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
|
||||||
|
filterBy(
|
||||||
|
isAdding ? nonParticipantUsers : group.participants,
|
||||||
|
"type",
|
||||||
|
type
|
||||||
|
).length > 0 &&
|
||||||
|
filterBy(
|
||||||
|
isAdding ? nonParticipantUsers : group.participants,
|
||||||
|
"type",
|
||||||
|
type
|
||||||
|
).every((u) => selectedUsers.includes(u.id)) &&
|
||||||
|
"!bg-mti-purple-light !text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{capitalize(type)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{items.map((u) => (
|
{items.map((u) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleUser(u)}
|
onClick={() => toggleUser(u)}
|
||||||
disabled={isAdding ? !canAddParticipants : !canRemoveParticipants}
|
disabled={
|
||||||
key={u.id}
|
isAdding ? !canAddParticipants : !canRemoveParticipants
|
||||||
className={clsx(
|
}
|
||||||
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
|
key={u.id}
|
||||||
"hover:border-mti-purple transition ease-in-out duration-300",
|
className={clsx(
|
||||||
selectedUsers.includes(u.id) && "border-mti-purple",
|
"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",
|
||||||
<div className="flex items-center gap-2">
|
selectedUsers.includes(u.id) && "border-mti-purple"
|
||||||
<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 items-center gap-2">
|
||||||
<div className="flex flex-col">
|
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
|
||||||
<span className="font-semibold">{getUserName(u)}</span>
|
<img src={u.profilePicture} alt={u.name} />
|
||||||
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span>
|
</div>
|
||||||
</div>
|
<div className="flex flex-col">
|
||||||
</div>
|
<span className="font-semibold">{getUserName(u)}</span>
|
||||||
|
<span className="opacity-80 text-sm">
|
||||||
|
{USER_TYPE_LABELS[u.type]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Tooltip tooltip="E-mail address">
|
<Tooltip tooltip="E-mail address">
|
||||||
<BsEnvelopeFill />
|
<BsEnvelopeFill />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{u.email}
|
{u.email}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Tooltip tooltip="Expiration Date">
|
<Tooltip tooltip="Expiration Date">
|
||||||
<BsStopwatchFill />
|
<BsStopwatchFill />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
|
{u.subscriptionExpirationDate
|
||||||
</span>
|
? moment(u.subscriptionExpirationDate).format(
|
||||||
<span className="flex items-center gap-2">
|
"DD/MM/YYYY"
|
||||||
<Tooltip tooltip="Last Login">
|
)
|
||||||
<BsClockFill />
|
: "Unlimited"}
|
||||||
</Tooltip>
|
</span>
|
||||||
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
|
<span className="flex items-center gap-2">
|
||||||
</span>
|
<Tooltip tooltip="Last Login">
|
||||||
</div>
|
<BsClockFill />
|
||||||
</button>
|
</Tooltip>
|
||||||
))}
|
{u.lastLogin
|
||||||
</section>
|
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
|
||||||
</>
|
: "N/A"}
|
||||||
)}
|
</span>
|
||||||
</>
|
</div>
|
||||||
);
|
</button>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,221 +2,308 @@
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import Tooltip from "@/components/Low/Tooltip";
|
import Tooltip from "@/components/Low/Tooltip";
|
||||||
import {useListSearch} from "@/hooks/useListSearch";
|
import { useListSearch } from "@/hooks/useListSearch";
|
||||||
import usePagination from "@/hooks/usePagination";
|
import usePagination from "@/hooks/usePagination";
|
||||||
import {EntityWithRoles} from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import {filterBy, mapBy, redirect, serialize} from "@/utils";
|
import { filterBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { getEntitiesWithRoles} from "@/utils/entities.be";
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
import {getUserName, isAdmin} from "@/utils/users";
|
import { getUserName, isAdmin } from "@/utils/users";
|
||||||
import {getEntitiesUsers} from "@/utils/users.be";
|
import { getEntitiesUsers } from "@/utils/users.be";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {Divider} from "primereact/divider";
|
import { Divider } from "primereact/divider";
|
||||||
import {useEffect, useMemo, useState} from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill} from "react-icons/bs";
|
import {
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
BsCheck,
|
||||||
|
BsChevronLeft,
|
||||||
|
BsClockFill,
|
||||||
|
BsEnvelopeFill,
|
||||||
|
BsStopwatchFill,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import { toast, ToastContainer } from "react-toastify";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { findAllowedEntities } from "@/utils/permissions";
|
import { findAllowedEntities } from "@/utils/permissions";
|
||||||
import { capitalize } from "lodash";
|
import { capitalize } from "lodash";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
|
|
||||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : mapBy(user.entities, "id"));
|
const entities = await getEntitiesWithRoles(
|
||||||
const users = await getEntitiesUsers(mapBy(entities, 'id'))
|
isAdmin(user) ? undefined : mapBy(user.entities, "id")
|
||||||
const allowedEntities = findAllowedEntities(user, entities, "create_classroom")
|
);
|
||||||
|
const users = await getEntitiesUsers(
|
||||||
|
mapBy(entities, "id"),
|
||||||
|
{
|
||||||
|
id: { $ne: user.id },
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
email: 1,
|
||||||
|
profilePicture: 1,
|
||||||
|
type: 1,
|
||||||
|
corporateInformation: 1,
|
||||||
|
lastLogin: 1,
|
||||||
|
subscriptionExpirationDate: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const allowedEntities = findAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"create_classroom"
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({user, entities: allowedEntities, users: users.filter((x) => x.id !== user.id)}),
|
props: serialize({
|
||||||
};
|
user,
|
||||||
|
entities: allowedEntities,
|
||||||
|
users: users,
|
||||||
|
}),
|
||||||
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
users: User[];
|
||||||
entities: EntityWithRoles[];
|
entities: EntityWithRoles[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({user, users, entities}: Props) {
|
export default function Home({ user, users, entities }: Props) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [entity, setEntity] = useState<string | undefined>(entities[0]?.id);
|
const [entity, setEntity] = useState<string | undefined>(entities[0]?.id);
|
||||||
|
|
||||||
const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [entity, users])
|
const entityUsers = useMemo(
|
||||||
|
() =>
|
||||||
|
!entity
|
||||||
|
? users
|
||||||
|
: users.filter((u) => mapBy(u.entities, "id").includes(entity)),
|
||||||
|
[entity, users]
|
||||||
|
);
|
||||||
|
|
||||||
const {rows, renderSearch} = useListSearch<User>(
|
const { rows, renderSearch } = useListSearch<User>(
|
||||||
[["name"], ["type"], ["corporateInformation", "companyInformation", "name"]], entityUsers
|
[
|
||||||
);
|
["name"],
|
||||||
|
["type"],
|
||||||
|
["corporateInformation", "companyInformation", "name"],
|
||||||
|
],
|
||||||
|
entityUsers
|
||||||
|
);
|
||||||
|
|
||||||
const {items, renderMinimal} = usePagination<User>(rows, 16);
|
const { items, renderMinimal } = usePagination<User>(rows, 16);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => setSelectedUsers([]), [entity])
|
useEffect(() => setSelectedUsers([]), [entity]);
|
||||||
|
|
||||||
const createGroup = () => {
|
const createGroup = () => {
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
if (!entity) return;
|
if (!entity) return;
|
||||||
if (!confirm(`Are you sure you want to create this group with ${selectedUsers.length} participants?`)) return;
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Are you sure you want to create this group with ${selectedUsers.length} participants?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post<{id: string}>(`/api/groups`, {name, participants: selectedUsers, admin: user.id, entity})
|
.post<{ id: string }>(`/api/groups`, {
|
||||||
.then((result) => {
|
name,
|
||||||
toast.success("Your group has been created successfully!");
|
participants: selectedUsers,
|
||||||
router.replace(`/classrooms/${result.data.id}`);
|
admin: user.id,
|
||||||
})
|
entity,
|
||||||
.catch((e) => {
|
})
|
||||||
console.error(e);
|
.then((result) => {
|
||||||
toast.error("Something went wrong!");
|
toast.success("Your group has been created successfully!");
|
||||||
})
|
router.replace(`/classrooms/${result.data.id}`);
|
||||||
.finally(() => setIsLoading(false));
|
})
|
||||||
};
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Something went wrong!");
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
|
const toggleUser = (u: User) =>
|
||||||
|
setSelectedUsers((prev) =>
|
||||||
|
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Create Group | EnCoach</title>
|
<title>Create Group | EnCoach</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<>
|
<>
|
||||||
<section className="flex flex-col gap-0">
|
<section className="flex flex-col gap-0">
|
||||||
<div className="flex gap-3 justify-between">
|
<div className="flex gap-3 justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/classrooms"
|
href="/classrooms"
|
||||||
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||||
<BsChevronLeft />
|
>
|
||||||
</Link>
|
<BsChevronLeft />
|
||||||
<h2 className="font-bold text-2xl">Create Classroom</h2>
|
</Link>
|
||||||
</div>
|
<h2 className="font-bold text-2xl">Create Classroom</h2>
|
||||||
<div className="flex items-center gap-4">
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-4">
|
||||||
onClick={createGroup}
|
<button
|
||||||
disabled={!name.trim() || !entity || isLoading}
|
onClick={createGroup}
|
||||||
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={!name.trim() || !entity || isLoading}
|
||||||
<BsCheck />
|
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"
|
||||||
<span className="text-xs">Create Classroom</span>
|
>
|
||||||
</button>
|
<BsCheck />
|
||||||
</div>
|
<span className="text-xs">Create Classroom</span>
|
||||||
</div>
|
</button>
|
||||||
<Divider />
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4 place-items-end">
|
</div>
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<Divider />
|
||||||
<span className="font-semibold text-xl">Classroom Name:</span>
|
<div className="grid grid-cols-2 gap-4 place-items-end">
|
||||||
<Input name="name" onChange={setName} type="text" placeholder="Classroom A" />
|
<div className="flex flex-col gap-4 w-full">
|
||||||
</div>
|
<span className="font-semibold text-xl">Classroom Name:</span>
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<Input
|
||||||
<span className="font-semibold text-xl">Entity:</span>
|
name="name"
|
||||||
<Select
|
onChange={setName}
|
||||||
options={entities.map((e) => ({value: e.id, label: e.label}))}
|
type="text"
|
||||||
onChange={(v) => setEntity(v ? v.value! : undefined)}
|
placeholder="Classroom A"
|
||||||
defaultValue={{value: entities[0]?.id, label: entities[0]?.label}}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div className="flex flex-col gap-4 w-full">
|
||||||
</div>
|
<span className="font-semibold text-xl">Entity:</span>
|
||||||
<Divider />
|
<Select
|
||||||
<div className="flex items-center justify-between mb-4">
|
options={entities.map((e) => ({ value: e.id, label: e.label }))}
|
||||||
<span className="font-semibold text-xl">Participants ({selectedUsers.length} selected):</span>
|
onChange={(v) => setEntity(v ? v.value! : undefined)}
|
||||||
</div>
|
defaultValue={{
|
||||||
<div className="w-full flex items-center gap-4">
|
value: entities[0]?.id,
|
||||||
{renderSearch()}
|
label: entities[0]?.label,
|
||||||
{renderMinimal()}
|
}}
|
||||||
</div>
|
/>
|
||||||
<div className="flex items-center gap-2 mt-4">
|
</div>
|
||||||
{['student', 'teacher', 'corporate'].map((type) => (
|
</div>
|
||||||
<button
|
<Divider />
|
||||||
key={type}
|
<div className="flex items-center justify-between mb-4">
|
||||||
onClick={() => {
|
<span className="font-semibold text-xl">
|
||||||
const typeUsers = mapBy(filterBy(entityUsers, 'type', type), 'id')
|
Participants ({selectedUsers.length} selected):
|
||||||
if (typeUsers.every((u) => selectedUsers.includes(u))) {
|
</span>
|
||||||
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a)));
|
</div>
|
||||||
} else {
|
<div className="w-full flex items-center gap-4">
|
||||||
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]);
|
{renderSearch()}
|
||||||
}
|
{renderMinimal()}
|
||||||
}}
|
</div>
|
||||||
disabled={filterBy(entityUsers, 'type', type).length === 0}
|
<div className="flex items-center gap-2 mt-4">
|
||||||
className={clsx(
|
{["student", "teacher", "corporate"].map((type) => (
|
||||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
<button
|
||||||
"transition duration-300 ease-in-out",
|
key={type}
|
||||||
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
|
onClick={() => {
|
||||||
filterBy(entityUsers, 'type', type).length > 0 &&
|
const typeUsers = mapBy(
|
||||||
filterBy(entityUsers, 'type', type).every((u) => selectedUsers.includes(u.id)) &&
|
filterBy(entityUsers, "type", type),
|
||||||
"!bg-mti-purple-light !text-white",
|
"id"
|
||||||
)}>
|
);
|
||||||
{capitalize(type)}
|
if (typeUsers.every((u) => selectedUsers.includes(u))) {
|
||||||
</button>
|
setSelectedUsers((prev) =>
|
||||||
))}
|
prev.filter((a) => !typeUsers.includes(a))
|
||||||
</div>
|
);
|
||||||
</section>
|
} else {
|
||||||
|
setSelectedUsers((prev) => [
|
||||||
|
...prev.filter((a) => !typeUsers.includes(a)),
|
||||||
|
...typeUsers,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={filterBy(entityUsers, "type", type).length === 0}
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
|
||||||
|
filterBy(entityUsers, "type", type).length > 0 &&
|
||||||
|
filterBy(entityUsers, "type", type).every((u) =>
|
||||||
|
selectedUsers.includes(u.id)
|
||||||
|
) &&
|
||||||
|
"!bg-mti-purple-light !text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{capitalize(type)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{items.map((u) => (
|
{items.map((u) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleUser(u)}
|
onClick={() => toggleUser(u)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
key={u.id}
|
key={u.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
|
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
|
||||||
"hover:border-mti-purple transition ease-in-out duration-300",
|
"hover:border-mti-purple transition ease-in-out duration-300",
|
||||||
selectedUsers.includes(u.id) && "border-mti-purple",
|
selectedUsers.includes(u.id) && "border-mti-purple"
|
||||||
)}>
|
)}
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
|
<div className="flex items-center gap-2">
|
||||||
<img src={u.profilePicture} alt={u.name} />
|
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
|
||||||
</div>
|
<img src={u.profilePicture} alt={u.name} />
|
||||||
<div className="flex flex-col">
|
</div>
|
||||||
<span className="font-semibold">{getUserName(u)}</span>
|
<div className="flex flex-col">
|
||||||
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span>
|
<span className="font-semibold">{getUserName(u)}</span>
|
||||||
</div>
|
<span className="opacity-80 text-sm">
|
||||||
</div>
|
{USER_TYPE_LABELS[u.type]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Tooltip tooltip="E-mail address">
|
<Tooltip tooltip="E-mail address">
|
||||||
<BsEnvelopeFill />
|
<BsEnvelopeFill />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{u.email}
|
{u.email}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Tooltip tooltip="Expiration Date">
|
<Tooltip tooltip="Expiration Date">
|
||||||
<BsStopwatchFill />
|
<BsStopwatchFill />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
|
{u.subscriptionExpirationDate
|
||||||
</span>
|
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
|
||||||
<span className="flex items-center gap-2">
|
: "Unlimited"}
|
||||||
<Tooltip tooltip="Last Login">
|
</span>
|
||||||
<BsClockFill />
|
<span className="flex items-center gap-2">
|
||||||
</Tooltip>
|
<Tooltip tooltip="Last Login">
|
||||||
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
|
<BsClockFill />
|
||||||
</span>
|
</Tooltip>
|
||||||
</div>
|
{u.lastLogin
|
||||||
</button>
|
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
|
||||||
))}
|
: "N/A"}
|
||||||
</section>
|
</span>
|
||||||
</>
|
</div>
|
||||||
</>
|
</button>
|
||||||
);
|
))}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,132 +27,182 @@ import StudentClassroomTransfer from "@/components/Imports/StudentClassroomTrans
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
|
|
||||||
const entityIDS = mapBy(user.entities, "id");
|
const entityIDS = mapBy(user.entities, "id");
|
||||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS)
|
const entities = await getEntitiesWithRoles(
|
||||||
const allowedEntities = findAllowedEntities(user, entities, "view_classrooms")
|
isAdmin(user) ? undefined : entityIDS
|
||||||
|
);
|
||||||
|
|
||||||
const groups = await getGroupsForEntities(mapBy(allowedEntities, 'id'));
|
const allowedEntities = findAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"view_classrooms"
|
||||||
|
);
|
||||||
|
|
||||||
const users = await getSpecificUsers(uniq(groups.flatMap((g) => [...g.participants, g.admin])));
|
const groups = await getGroupsForEntities(mapBy(allowedEntities, "id"));
|
||||||
const groupsWithUsers: GroupWithUsers[] = groups.map((g) => convertToUsers(g, users.filter(x => isAdmin(user) ? true : !isAdmin(x))));
|
|
||||||
|
|
||||||
return {
|
const users = await getSpecificUsers(
|
||||||
props: serialize({ user, groups: groupsWithUsers, entities: allowedEntities }),
|
uniq(groups.flatMap((g) => [...g.participants, g.admin])),
|
||||||
};
|
{ _id: 0, id: 1, name: 1, email: 1, corporateInformation: 1, type: 1 }
|
||||||
|
);
|
||||||
|
const groupsWithUsers: GroupWithUsers[] = groups.map((g) =>
|
||||||
|
convertToUsers(
|
||||||
|
g,
|
||||||
|
users.filter((x) => (isAdmin(user) ? true : !isAdmin(x)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: serialize({
|
||||||
|
user,
|
||||||
|
groups: groupsWithUsers,
|
||||||
|
entities: allowedEntities,
|
||||||
|
}),
|
||||||
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
const SEARCH_FIELDS = [
|
const SEARCH_FIELDS = [
|
||||||
["name"],
|
["name"],
|
||||||
["admin", "name"],
|
["admin", "name"],
|
||||||
["admin", "email"],
|
["admin", "email"],
|
||||||
["admin", "corporateInformation", "companyInformation", "name"],
|
["admin", "corporateInformation", "companyInformation", "name"],
|
||||||
["participants", "name"],
|
["participants", "name"],
|
||||||
["participants", "email"],
|
["participants", "email"],
|
||||||
["participants", "corporateInformation", "companyInformation", "name"],
|
["participants", "corporateInformation", "companyInformation", "name"],
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
groups: GroupWithUsers[];
|
groups: GroupWithUsers[];
|
||||||
entities: EntityWithRoles[]
|
entities: EntityWithRoles[];
|
||||||
}
|
}
|
||||||
export default function Home({ user, groups, entities }: Props) {
|
export default function Home({ user, groups, entities }: Props) {
|
||||||
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_classroom');
|
const entitiesAllowCreate = useAllowedEntities(
|
||||||
const [showImport, setShowImport] = useState(false);
|
user,
|
||||||
|
entities,
|
||||||
|
"create_classroom"
|
||||||
|
);
|
||||||
|
const [showImport, setShowImport] = useState(false);
|
||||||
|
|
||||||
const renderCard = (group: GroupWithUsers) => (
|
const renderCard = (group: GroupWithUsers) => (
|
||||||
<Link
|
<Link
|
||||||
href={`/classrooms/${group.id}`}
|
href={`/classrooms/${group.id}`}
|
||||||
key={group.id}
|
key={group.id}
|
||||||
className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer">
|
className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer"
|
||||||
<div className="flex flex-col gap-2 w-full">
|
>
|
||||||
<span className="flex items-center gap-1">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
<span className="bg-mti-purple text-white font-semibold px-2">Classroom</span>
|
<span className="flex items-center gap-1">
|
||||||
{group.name}
|
<span className="bg-mti-purple text-white font-semibold px-2">
|
||||||
</span>
|
Classroom
|
||||||
<span className="flex items-center gap-1">
|
</span>
|
||||||
<span className="bg-mti-purple text-white font-semibold px-2">Admin</span>
|
{group.name}
|
||||||
{getUserName(group.admin)}
|
</span>
|
||||||
</span>
|
<span className="flex items-center gap-1">
|
||||||
{!!group.entity && (
|
<span className="bg-mti-purple text-white font-semibold px-2">
|
||||||
<span className="flex items-center gap-1">
|
Admin
|
||||||
<span className="bg-mti-purple text-white font-semibold px-2">Entity</span>
|
</span>
|
||||||
{findBy(entities, 'id', group.entity)?.label}
|
{getUserName(group.admin)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
{!!group.entity && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="bg-mti-purple text-white font-semibold px-2">Participants</span>
|
<span className="bg-mti-purple text-white font-semibold px-2">
|
||||||
<span className="bg-mti-purple-light/50 px-2">{group.participants.length}</span>
|
Entity
|
||||||
</span>
|
</span>
|
||||||
<span>
|
{findBy(entities, "id", group.entity)?.label}
|
||||||
{group.participants.slice(0, 3).map(getUserName).join(", ")}{' '}
|
</span>
|
||||||
{group.participants.length > 3 ? <span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">and {group.participants.length - 3} more</span> : ""}
|
)}
|
||||||
</span>
|
<span className="flex items-center gap-1">
|
||||||
</div>
|
<span className="bg-mti-purple text-white font-semibold px-2">
|
||||||
<div className="w-fit">
|
Participants
|
||||||
<FaPersonChalkboard className="w-full h-20 -translate-y-[15%] group-hover:text-mti-purple transition ease-in-out duration-300" />
|
</span>
|
||||||
</div>
|
<span className="bg-mti-purple-light/50 px-2">
|
||||||
</Link>
|
{group.participants.length}
|
||||||
);
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{group.participants.slice(0, 3).map(getUserName).join(", ")}{" "}
|
||||||
|
{group.participants.length > 3 ? (
|
||||||
|
<span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">
|
||||||
|
and {group.participants.length - 3} more
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-fit">
|
||||||
|
<FaPersonChalkboard className="w-full h-20 -translate-y-[15%] group-hover:text-mti-purple transition ease-in-out duration-300" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
const firstCard = () => (
|
const firstCard = () => (
|
||||||
<Link
|
<Link
|
||||||
href={`/classrooms/create`}
|
href={`/classrooms/create`}
|
||||||
className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer">
|
className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer"
|
||||||
<BsPlus size={40} />
|
>
|
||||||
<span className="font-semibold">Create Classroom</span>
|
<BsPlus size={40} />
|
||||||
</Link>
|
<span className="font-semibold">Create Classroom</span>
|
||||||
);
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Classrooms | EnCoach</title>
|
<title>Classrooms | EnCoach</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<>
|
<>
|
||||||
<section className="flex flex-col gap-4 w-full h-full">
|
<section className="flex flex-col gap-4 w-full h-full">
|
||||||
<Modal isOpen={showImport} onClose={() => setShowImport(false)} maxWidth="max-w-[85%]">
|
<Modal
|
||||||
<StudentClassroomTransfer user={user} entities={entities} onFinish={() => setShowImport(false)} />
|
isOpen={showImport}
|
||||||
</Modal>
|
onClose={() => setShowImport(false)}
|
||||||
<div className="flex flex-col gap-4">
|
maxWidth="max-w-[85%]"
|
||||||
<div className="flex justify-between">
|
>
|
||||||
<h2 className="font-bold text-2xl">Classrooms</h2>
|
<StudentClassroomTransfer
|
||||||
{entitiesAllowCreate.length !== 0 && <button
|
user={user}
|
||||||
className={clsx(
|
entities={entities}
|
||||||
"flex flex-row gap-3 items-center py-1.5 px-4 text-lg",
|
onFinish={() => setShowImport(false)}
|
||||||
"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",
|
</Modal>
|
||||||
)}
|
<div className="flex flex-col gap-4">
|
||||||
onClick={() => setShowImport(true)}
|
<div className="flex justify-between">
|
||||||
>
|
<h2 className="font-bold text-2xl">Classrooms</h2>
|
||||||
<FaFileUpload className="w-5 h-5" />
|
{entitiesAllowCreate.length !== 0 && (
|
||||||
Transfer Students
|
<button
|
||||||
</button>
|
className={clsx(
|
||||||
}
|
"flex flex-row gap-3 items-center py-1.5 px-4 text-lg",
|
||||||
</div>
|
"bg-mti-purple-light border border-mti-purple-light rounded-xl text-white",
|
||||||
<Separator />
|
"hover:bg-white hover:text-mti-purple-light transition duration-300 ease-in-out"
|
||||||
</div>
|
)}
|
||||||
|
onClick={() => setShowImport(true)}
|
||||||
|
>
|
||||||
|
<FaFileUpload className="w-5 h-5" />
|
||||||
|
Transfer Students
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
<CardList<GroupWithUsers>
|
<CardList<GroupWithUsers>
|
||||||
list={groups}
|
list={groups}
|
||||||
searchFields={SEARCH_FIELDS}
|
searchFields={SEARCH_FIELDS}
|
||||||
renderCard={renderCard}
|
renderCard={renderCard}
|
||||||
firstCard={entitiesAllowCreate.length === 0 ? undefined : firstCard}
|
firstCard={entitiesAllowCreate.length === 0 ? undefined : firstCard}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,15 @@ import IconCard from "@/components/IconCard";
|
|||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { Stat, Type, User } from "@/interfaces/user";
|
import { Stat, Type, User } from "@/interfaces/user";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { filterBy, mapBy, redirect, serialize } from "@/utils";
|
import { filterBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import {
|
import { countEntitiesAssignments } from "@/utils/assignments.be";
|
||||||
countEntitiesAssignments,
|
|
||||||
} from "@/utils/assignments.be";
|
|
||||||
import { getEntities } from "@/utils/entities.be";
|
import { getEntities } from "@/utils/entities.be";
|
||||||
import { countGroups } from "@/utils/groups.be";
|
import { countGroups } from "@/utils/groups.be";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import { groupByExam } from "@/utils/stats";
|
import { groupByExam } from "@/utils/stats";
|
||||||
import { getStatsByUsers } from "@/utils/stats.be";
|
import { getStatsByUsers } from "@/utils/stats.be";
|
||||||
import {
|
import { countUsersByTypes, getUsers } from "@/utils/users.be";
|
||||||
countUsersByTypes,
|
|
||||||
getUsers,
|
|
||||||
} from "@/utils/users.be";
|
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -49,50 +44,48 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
if (!user || !user.isVerified) return redirect("/login");
|
if (!user || !user.isVerified) return redirect("/login");
|
||||||
|
|
||||||
if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
|
if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
|
||||||
|
const [
|
||||||
const students = await getUsers(
|
entities,
|
||||||
{ type: "student" },
|
usersCount,
|
||||||
10,
|
groupsCount,
|
||||||
{
|
students,
|
||||||
averageLevel: -1,
|
latestStudents,
|
||||||
},
|
latestTeachers,
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
] = await Promise.all([
|
||||||
);
|
getEntities(undefined, { _id: 0, id: 1, label: 1 }),
|
||||||
|
countUsersByTypes(["student", "teacher", "corporate", "mastercorporate"]),
|
||||||
const usersCount = await countUsersByTypes([
|
countGroups(),
|
||||||
"student",
|
getUsers(
|
||||||
"teacher",
|
{ type: "student" },
|
||||||
"corporate",
|
10,
|
||||||
"mastercorporate",
|
{
|
||||||
|
averageLevel: -1,
|
||||||
|
},
|
||||||
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
|
),
|
||||||
|
getUsers(
|
||||||
|
{ type: "student" },
|
||||||
|
10,
|
||||||
|
{
|
||||||
|
registrationDate: -1,
|
||||||
|
},
|
||||||
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
|
),
|
||||||
|
getUsers(
|
||||||
|
{ type: "teacher" },
|
||||||
|
10,
|
||||||
|
{
|
||||||
|
registrationDate: -1,
|
||||||
|
},
|
||||||
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const [assignmentsCount, stats] = await Promise.all([
|
||||||
|
countEntitiesAssignments(mapBy(entities, "id"), {
|
||||||
|
archived: { $ne: true },
|
||||||
|
}),
|
||||||
|
getStatsByUsers(mapBy(students, "id")),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const latestStudents = await getUsers(
|
|
||||||
{ type: "student" },
|
|
||||||
10,
|
|
||||||
{
|
|
||||||
registrationDate: -1,
|
|
||||||
},
|
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
|
||||||
);
|
|
||||||
const latestTeachers = await 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"));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({
|
props: serialize({
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ import {
|
|||||||
groupAllowedEntitiesByPermissions,
|
groupAllowedEntitiesByPermissions,
|
||||||
} from "@/utils/permissions";
|
} from "@/utils/permissions";
|
||||||
import { groupByExam } from "@/utils/stats";
|
import { groupByExam } from "@/utils/stats";
|
||||||
import {
|
import { countAllowedUsers, getUsers } from "@/utils/users.be";
|
||||||
countAllowedUsers,
|
|
||||||
getUsers,
|
|
||||||
} from "@/utils/users.be";
|
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
@@ -68,40 +65,44 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
"view_teachers",
|
"view_teachers",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
|
const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
|
||||||
const entitiesIDS = mapBy(entities, "id") || [];
|
const entitiesIDS = mapBy(entities, "id") || [];
|
||||||
|
|
||||||
|
const [
|
||||||
const students = await getUsers(
|
students,
|
||||||
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
|
latestStudents,
|
||||||
10,
|
latestTeachers,
|
||||||
{ averageLevel: -1 },
|
userCounts,
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
assignmentsCount,
|
||||||
);
|
groupsCount,
|
||||||
const latestStudents = await getUsers(
|
] = await Promise.all([
|
||||||
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
|
getUsers(
|
||||||
10,
|
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
|
||||||
{ registrationDate: -1 },
|
10,
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
{ averageLevel: -1 },
|
||||||
);
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
const latestTeachers = await getUsers(
|
),
|
||||||
{
|
getUsers(
|
||||||
type: "teacher",
|
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
|
||||||
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
|
10,
|
||||||
},
|
{ registrationDate: -1 },
|
||||||
10,
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
{ registrationDate: -1 },
|
),
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
getUsers(
|
||||||
);
|
{
|
||||||
|
type: "teacher",
|
||||||
const userCounts = await countAllowedUsers(user, entities);
|
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
|
||||||
|
},
|
||||||
const assignmentsCount = await countEntitiesAssignments(
|
10,
|
||||||
entitiesIDS,
|
{ registrationDate: -1 },
|
||||||
{ archived: { $ne: true } }
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
);
|
),
|
||||||
|
countAllowedUsers(user, entities),
|
||||||
const groupsCount = await countGroupsByEntities(entitiesIDS);
|
countEntitiesAssignments(entitiesIDS, {
|
||||||
|
archived: { $ne: true },
|
||||||
|
}),
|
||||||
|
countGroupsByEntities(entitiesIDS),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({
|
props: serialize({
|
||||||
@@ -135,8 +136,8 @@ export default function Dashboard({
|
|||||||
userCounts.student +
|
userCounts.student +
|
||||||
userCounts.teacher,
|
userCounts.teacher,
|
||||||
[userCounts]
|
[userCounts]
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalLicenses = useMemo(
|
const totalLicenses = useMemo(
|
||||||
() =>
|
() =>
|
||||||
entities.reduce(
|
entities.reduce(
|
||||||
|
|||||||
@@ -2,21 +2,16 @@
|
|||||||
import UserDisplayList from "@/components/UserDisplayList";
|
import UserDisplayList from "@/components/UserDisplayList";
|
||||||
import IconCard from "@/components/IconCard";
|
import IconCard from "@/components/IconCard";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { Stat, Type, User } from "@/interfaces/user";
|
import { Stat, Type, User } from "@/interfaces/user";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { filterBy, mapBy, redirect, serialize } from "@/utils";
|
import { filterBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import {
|
import { countEntitiesAssignments } from "@/utils/assignments.be";
|
||||||
countEntitiesAssignments,
|
|
||||||
} from "@/utils/assignments.be";
|
|
||||||
import { getEntities } from "@/utils/entities.be";
|
import { getEntities } from "@/utils/entities.be";
|
||||||
import { countGroups } from "@/utils/groups.be";
|
import { countGroups } from "@/utils/groups.be";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import { groupByExam } from "@/utils/stats";
|
import { groupByExam } from "@/utils/stats";
|
||||||
import {
|
import { countUsersByTypes, getUsers } from "@/utils/users.be";
|
||||||
countUsersByTypes,
|
|
||||||
getUsers,
|
|
||||||
} from "@/utils/users.be";
|
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -49,45 +44,41 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
|
|
||||||
if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
|
if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
|
||||||
|
|
||||||
const students = await getUsers(
|
const [
|
||||||
{ type: "student" },
|
students,
|
||||||
10,
|
latestStudents,
|
||||||
{
|
latestTeachers,
|
||||||
averageLevel: -1,
|
usersCount,
|
||||||
},
|
entities,
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
groupsCount,
|
||||||
);
|
] = await Promise.all([
|
||||||
|
getUsers(
|
||||||
const usersCount = await countUsersByTypes([
|
{ type: "student" },
|
||||||
"student",
|
10,
|
||||||
"teacher",
|
{ averageLevel: -1 },
|
||||||
"corporate",
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
"mastercorporate",
|
),
|
||||||
|
getUsers(
|
||||||
|
{ type: "student" },
|
||||||
|
10,
|
||||||
|
{ registrationDate: -1 },
|
||||||
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
|
),
|
||||||
|
getUsers(
|
||||||
|
{ type: "teacher" },
|
||||||
|
10,
|
||||||
|
{ registrationDate: -1 },
|
||||||
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
|
),
|
||||||
|
countUsersByTypes(["student", "teacher", "corporate", "mastercorporate"]),
|
||||||
|
getEntities(undefined, { _id: 0, id: 1, label: 1 }),
|
||||||
|
countGroups(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const latestStudents = await getUsers(
|
|
||||||
{ type: "student" },
|
|
||||||
10,
|
|
||||||
{
|
|
||||||
registrationDate: -1,
|
|
||||||
},
|
|
||||||
{id:1, name: 1, email: 1, profilePicture: 1 }
|
|
||||||
);
|
|
||||||
const latestTeachers = await 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(
|
const assignmentsCount = await countEntitiesAssignments(
|
||||||
mapBy(entities, "id"),
|
mapBy(entities, "id"),
|
||||||
{ archived: { $ne: true } }
|
{ archived: { $ne: true } }
|
||||||
);
|
);
|
||||||
const groupsCount = await countGroups();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({
|
props: serialize({
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { User } from "@/interfaces/user";
|
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { redirect } from "@/utils";
|
import { redirect } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
|
|||||||
@@ -70,37 +70,39 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
|
|
||||||
const entitiesIDS = mapBy(entities, "id") || [];
|
const entitiesIDS = mapBy(entities, "id") || [];
|
||||||
|
|
||||||
const students = await getUsers(
|
const [
|
||||||
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
|
students,
|
||||||
10,
|
latestStudents,
|
||||||
{ averageLevel: -1 },
|
latestTeachers,
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
userCounts,
|
||||||
);
|
assignmentsCount,
|
||||||
|
groupsCount,
|
||||||
const latestStudents = await getUsers(
|
] = await Promise.all([
|
||||||
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
|
getUsers(
|
||||||
10,
|
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
|
||||||
{ registrationDate: -1 },
|
10,
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
{ averageLevel: -1 },
|
||||||
);
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
|
),
|
||||||
const latestTeachers = await getUsers(
|
getUsers(
|
||||||
{
|
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
|
||||||
type: "teacher",
|
10,
|
||||||
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
|
{ registrationDate: -1 },
|
||||||
},
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
10,
|
),
|
||||||
{ registrationDate: -1 },
|
getUsers(
|
||||||
{ id: 1, name: 1, email: 1, profilePicture: 1 }
|
{
|
||||||
);
|
type: "teacher",
|
||||||
|
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
|
||||||
const userCounts = await countAllowedUsers(user, entities);
|
},
|
||||||
|
10,
|
||||||
const assignmentsCount = await countEntitiesAssignments(entitiesIDS, {
|
{ registrationDate: -1 },
|
||||||
archived: { $ne: true },
|
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
|
||||||
});
|
),
|
||||||
|
countAllowedUsers(user, entities),
|
||||||
const groupsCount = await countGroupsByEntities(entitiesIDS);
|
countEntitiesAssignments(entitiesIDS, { archived: { $ne: true } }),
|
||||||
|
countGroupsByEntities(entitiesIDS),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({
|
props: serialize({
|
||||||
@@ -127,6 +129,7 @@ export default function Dashboard({
|
|||||||
stats = [],
|
stats = [],
|
||||||
groupsCount,
|
groupsCount,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|
||||||
const totalCount = useMemo(
|
const totalCount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
userCounts.corporate +
|
userCounts.corporate +
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import InviteWithUserCard from "@/components/Medium/InviteWithUserCard";
|
|||||||
import ModuleBadge from "@/components/ModuleBadge";
|
import ModuleBadge from "@/components/ModuleBadge";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import { Session } from "@/hooks/useSessions";
|
import { Session } from "@/hooks/useSessions";
|
||||||
import { Grading } from "@/interfaces";
|
import { Grading, Module } from "@/interfaces";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { Exam } from "@/interfaces/exam";
|
import { Exam } from "@/interfaces/exam";
|
||||||
import { InviteWithEntity } from "@/interfaces/invite";
|
import { InviteWithEntity } from "@/interfaces/invite";
|
||||||
@@ -16,7 +16,6 @@ import useExamStore from "@/stores/exam";
|
|||||||
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { getAssignmentsForStudent } from "@/utils/assignments.be";
|
import { getAssignmentsForStudent } from "@/utils/assignments.be";
|
||||||
import { getEntities } from "@/utils/entities.be";
|
|
||||||
import { getExamsByIds } from "@/utils/exams.be";
|
import { getExamsByIds } from "@/utils/exams.be";
|
||||||
import { getGradingSystemByEntity } from "@/utils/grading.be";
|
import { getGradingSystemByEntity } from "@/utils/grading.be";
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +33,7 @@ import { capitalize, uniqBy } from "lodash";
|
|||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
BsBook,
|
BsBook,
|
||||||
BsClipboard,
|
BsClipboard,
|
||||||
@@ -65,44 +65,49 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
return redirect("/");
|
return redirect("/");
|
||||||
|
|
||||||
const entityIDS = mapBy(user.entities, "id") || [];
|
const entityIDS = mapBy(user.entities, "id") || [];
|
||||||
|
|
||||||
const entities = await getEntities(entityIDS, { _id: 0, label: 1 });
|
|
||||||
const currentDate = moment().toISOString();
|
const currentDate = moment().toISOString();
|
||||||
const assignments = await getAssignmentsForStudent(user.id, currentDate);
|
|
||||||
const stats = await getDetailedStatsByUser(user.id, "stats");
|
|
||||||
|
|
||||||
|
const [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 assignmentsIDs = mapBy(assignments, "id");
|
||||||
|
const [sessions, ...formattedInvites] = await Promise.all([
|
||||||
|
getSessionsByUser(user.id, 10, {
|
||||||
|
["assignment.id"]: { $in: assignmentsIDs },
|
||||||
|
}),
|
||||||
|
...invites.map(convertInvitersToEntity),
|
||||||
|
]);
|
||||||
|
|
||||||
const sessions = await getSessionsByUser(user.id, 10, {
|
|
||||||
["assignment.id"]: { $in: assignmentsIDs },
|
|
||||||
});
|
|
||||||
const invites = await getInvitesByInvitee(user.id);
|
|
||||||
const grading = await getGradingSystemByEntity(entityIDS[0] || "", {
|
|
||||||
_id: 0,
|
|
||||||
steps: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const formattedInvites = await Promise.all(
|
|
||||||
invites.map(convertInvitersToEntity)
|
|
||||||
);
|
|
||||||
const examIDs = uniqBy(
|
const examIDs = uniqBy(
|
||||||
assignments.flatMap((a) =>
|
assignments.reduce<{ module: Module; id: string; key: string }[]>(
|
||||||
a.exams.map((e: { module: string; id: string }) => ({
|
(acc, a) => {
|
||||||
module: e.module,
|
a.exams.forEach((e: { module: Module; id: string }) => {
|
||||||
id: e.id,
|
acc.push({
|
||||||
key: `${e.module}_${e.id}`,
|
module: e.module,
|
||||||
}))
|
id: e.id,
|
||||||
|
key: `${e.module}_${e.id}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[]
|
||||||
),
|
),
|
||||||
"key"
|
"key"
|
||||||
);
|
);
|
||||||
|
|
||||||
const exams = examIDs.length > 0 ? await getExamsByIds(examIDs) : [];
|
const exams = examIDs.length > 0 ? await getExamsByIds(examIDs) : [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({
|
props: serialize({
|
||||||
user,
|
user,
|
||||||
entities,
|
|
||||||
assignments,
|
assignments,
|
||||||
stats,
|
stats: stats ,
|
||||||
exams,
|
exams,
|
||||||
sessions,
|
sessions,
|
||||||
invites: formattedInvites,
|
invites: formattedInvites,
|
||||||
@@ -145,6 +150,11 @@ export default function Dashboard({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const entitiesLabels = useMemo(
|
||||||
|
() => (entities.length > 0 ? mapBy(entities, "label")?.join(", ") : ""),
|
||||||
|
[entities]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@@ -160,7 +170,7 @@ export default function Dashboard({
|
|||||||
<>
|
<>
|
||||||
{entities.length > 0 && (
|
{entities.length > 0 && (
|
||||||
<div className="rounded-lg bg-neutral-200 px-2 py-1 ">
|
<div className="rounded-lg bg-neutral-200 px-2 py-1 ">
|
||||||
<b>{mapBy(entities, "label")?.join(", ")}</b>
|
<b>{entitiesLabels}</b>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -171,7 +181,7 @@ export default function Dashboard({
|
|||||||
icon: (
|
icon: (
|
||||||
<BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
|
<BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
|
||||||
),
|
),
|
||||||
value: stats.fullExams,
|
value: stats?.fullExams || 0,
|
||||||
label: "Exams",
|
label: "Exams",
|
||||||
tooltip: "Number of all conducted completed exams",
|
tooltip: "Number of all conducted completed exams",
|
||||||
},
|
},
|
||||||
@@ -179,7 +189,7 @@ export default function Dashboard({
|
|||||||
icon: (
|
icon: (
|
||||||
<BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
|
<BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
|
||||||
),
|
),
|
||||||
value: stats.uniqueModules,
|
value: stats?.uniqueModules || 0,
|
||||||
label: "Modules",
|
label: "Modules",
|
||||||
tooltip:
|
tooltip:
|
||||||
"Number of all exam modules performed including Level Test",
|
"Number of all exam modules performed including Level Test",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { requestUser } from "@/utils/api";
|
|||||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||||
import { getEntitiesUsers } from "@/utils/users.be";
|
import { getEntitiesUsers } from "@/utils/users.be";
|
||||||
import { isAdmin } from "@/utils/users";
|
import { isAdmin } from "@/utils/users";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -52,29 +53,29 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
|
|
||||||
const filteredEntities = findAllowedEntities(user, entities, "view_students");
|
const filteredEntities = findAllowedEntities(user, entities, "view_students");
|
||||||
|
|
||||||
const students = await getEntitiesUsers(
|
const [students, assignments, groups] = await Promise.all([
|
||||||
mapBy(filteredEntities, "id"),
|
getEntitiesUsers(
|
||||||
{
|
mapBy(filteredEntities, "id"),
|
||||||
type: "student",
|
{
|
||||||
},
|
type: "student",
|
||||||
0,
|
},
|
||||||
{
|
0,
|
||||||
_id: 0,
|
{
|
||||||
id: 1,
|
_id: 0,
|
||||||
name: 1,
|
id: 1,
|
||||||
email: 1,
|
name: 1,
|
||||||
profilePicture: 1,
|
email: 1,
|
||||||
levels: 1,
|
profilePicture: 1,
|
||||||
registrationDate: 1,
|
levels: 1,
|
||||||
}
|
registrationDate: 1,
|
||||||
);
|
}
|
||||||
|
),
|
||||||
const assignments = await getEntitiesAssignments(entityIDS);
|
getEntitiesAssignments(entityIDS),
|
||||||
|
getGroupsByEntities(entityIDS),
|
||||||
|
]);
|
||||||
|
|
||||||
const stats = await getStatsByUsers(students.map((u) => u.id));
|
const stats = await getStatsByUsers(students.map((u) => u.id));
|
||||||
|
|
||||||
const groups = await getGroupsByEntities(entityIDS);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({ user, students, entities, assignments, stats, groups }),
|
props: serialize({ user, students, entities, assignments, stats, groups }),
|
||||||
};
|
};
|
||||||
@@ -100,6 +101,10 @@ export default function Dashboard({
|
|||||||
entities,
|
entities,
|
||||||
"view_student_performance"
|
"view_student_performance"
|
||||||
);
|
);
|
||||||
|
const entitiesLabels = useMemo(
|
||||||
|
() => mapBy(entities, "label")?.join(", "),
|
||||||
|
[entities]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -117,7 +122,7 @@ export default function Dashboard({
|
|||||||
<div className="w-full flex flex-col gap-4">
|
<div className="w-full flex flex-col gap-4">
|
||||||
{entities.length > 0 && (
|
{entities.length > 0 && (
|
||||||
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
|
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
|
||||||
<b>{mapBy(entities, "label")?.join(", ")}</b>
|
<b>{entitiesLabels}</b>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
|
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,136 +1,139 @@
|
|||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import Separator from "@/components/Low/Separator";
|
import Separator from "@/components/Low/Separator";
|
||||||
import { useEntityPermission } from "@/hooks/useEntityPermissions";
|
import {useEntityPermission} from "@/hooks/useEntityPermissions";
|
||||||
import { EntityWithRoles, Role } from "@/interfaces/entity";
|
import {EntityWithRoles, Role} from "@/interfaces/entity";
|
||||||
import { User } from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import { RolePermission } from "@/resources/entityPermissions";
|
import {RolePermission} from "@/resources/entityPermissions";
|
||||||
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
import {findBy, mapBy, redirect, serialize} from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import {requestUser} from "@/utils/api";
|
||||||
import { getEntityWithRoles } from "@/utils/entities.be";
|
import {getEntityWithRoles} from "@/utils/entities.be";
|
||||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||||
import { doesEntityAllow } from "@/utils/permissions";
|
import {doesEntityAllow} from "@/utils/permissions";
|
||||||
import { isAdmin } from "@/utils/users";
|
import {isAdmin} from "@/utils/users";
|
||||||
import { countEntityUsers } from "@/utils/users.be";
|
import {countEntityUsers} from "@/utils/users.be";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import { Divider } from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
import { useState } from "react";
|
import {useState} from "react";
|
||||||
import {
|
import {BsCheck, BsChevronLeft, BsTag, BsTrash} from "react-icons/bs";
|
||||||
BsCheck,
|
import {toast} from "react-toastify";
|
||||||
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[] = [
|
const USER_MANAGEMENT: PermissionLayout[] = [
|
||||||
{ label: "View Students", key: "view_students" },
|
{label: "View Students", key: "view_students"},
|
||||||
{ label: "View Teachers", key: "view_teachers" },
|
{label: "View Teachers", key: "view_teachers"},
|
||||||
{ label: "View Corporate Accounts", key: "view_corporates" },
|
{label: "View Corporate Accounts", key: "view_corporates"},
|
||||||
{ label: "View Master Corporate Accounts", key: "view_mastercorporates" },
|
{label: "View Master Corporate Accounts", key: "view_mastercorporates"},
|
||||||
{ label: "Edit Students", key: "edit_students" },
|
{label: "Edit Students", key: "edit_students"},
|
||||||
{ label: "Edit Teachers", key: "edit_teachers" },
|
{label: "Edit Teachers", key: "edit_teachers"},
|
||||||
{ label: "Edit Corporate Accounts", key: "edit_corporates" },
|
{label: "Edit Corporate Accounts", key: "edit_corporates"},
|
||||||
{ label: "Edit Master Corporate Accounts", key: "edit_mastercorporates" },
|
{label: "Edit Master Corporate Accounts", key: "edit_mastercorporates"},
|
||||||
{ label: "Delete Students", key: "delete_students" },
|
{label: "Delete Students", key: "delete_students"},
|
||||||
{ label: "Delete Teachers", key: "delete_teachers" },
|
{label: "Delete Teachers", key: "delete_teachers"},
|
||||||
{ label: "Delete Corporate Accounts", key: "delete_corporates" },
|
{label: "Delete Corporate Accounts", key: "delete_corporates"},
|
||||||
{ label: "Delete Master Corporate Accounts", key: "delete_mastercorporates" },
|
{label: "Delete Master Corporate Accounts", key: "delete_mastercorporates"},
|
||||||
{ label: "Create a Single User", key: "create_user" },
|
{label: "Create a Single User", key: "create_user"},
|
||||||
{ label: "Create Users in Batch", key: "create_user_batch" },
|
{label: "Create Users in Batch", key: "create_user_batch"},
|
||||||
{ label: "Create a Single Code", key: "create_code" },
|
{label: "Create a Single Code", key: "create_code"},
|
||||||
{ label: "Create Codes in Batch", key: "create_code_batch" },
|
{label: "Create Codes in Batch", key: "create_code_batch"},
|
||||||
{ label: "Download User List", key: "download_user_list" },
|
{label: "Download User List", key: "download_user_list"},
|
||||||
{ label: "View Code List", key: "view_code_list" },
|
{label: "View Code List", key: "view_code_list"},
|
||||||
{ label: "Delete Code", key: "delete_code" },
|
{label: "Delete Code", key: "delete_code"},
|
||||||
]
|
];
|
||||||
|
|
||||||
const EXAM_MANAGEMENT: PermissionLayout[] = [
|
const EXAM_MANAGEMENT: PermissionLayout[] = [
|
||||||
{ label: "View Reading", key: "view_reading" },
|
{label: "View Reading", key: "view_reading"},
|
||||||
{ label: "Generate Reading", key: "generate_reading" },
|
{label: "Generate Reading", key: "generate_reading"},
|
||||||
{ label: "Delete Reading", key: "delete_reading" },
|
{label: "Delete Reading", key: "delete_reading"},
|
||||||
{ label: "View Listening", key: "view_listening" },
|
{label: "View Listening", key: "view_listening"},
|
||||||
{ label: "Generate Listening", key: "generate_listening" },
|
{label: "Generate Listening", key: "generate_listening"},
|
||||||
{ label: "Delete Listening", key: "delete_listening" },
|
{label: "Delete Listening", key: "delete_listening"},
|
||||||
{ label: "View Writing", key: "view_writing" },
|
{label: "View Writing", key: "view_writing"},
|
||||||
{ label: "Generate Writing", key: "generate_writing" },
|
{label: "Generate Writing", key: "generate_writing"},
|
||||||
{ label: "Delete Writing", key: "delete_writing" },
|
{label: "Delete Writing", key: "delete_writing"},
|
||||||
{ label: "View Speaking", key: "view_speaking" },
|
{label: "View Speaking", key: "view_speaking"},
|
||||||
{ label: "Generate Speaking", key: "generate_speaking" },
|
{label: "Generate Speaking", key: "generate_speaking"},
|
||||||
{ label: "Delete Speaking", key: "delete_speaking" },
|
{label: "Delete Speaking", key: "delete_speaking"},
|
||||||
{ label: "View Level", key: "view_level" },
|
{label: "View Level", key: "view_level"},
|
||||||
{ label: "Generate Level", key: "generate_level" },
|
{label: "Generate Level", key: "generate_level"},
|
||||||
{ label: "Delete Level", key: "delete_level" },
|
{label: "Delete Level", key: "delete_level"},
|
||||||
{ label: "View Statistics", key: "view_statistics" },
|
{label: "Set as Private/Public", key: "update_exam_privacy"},
|
||||||
]
|
{label: "View Statistics", key: "view_statistics"},
|
||||||
|
];
|
||||||
|
|
||||||
const CLASSROOM_MANAGEMENT: PermissionLayout[] = [
|
const CLASSROOM_MANAGEMENT: PermissionLayout[] = [
|
||||||
{ label: "View Classrooms", key: "view_classrooms" },
|
{label: "View Classrooms", key: "view_classrooms"},
|
||||||
{ label: "Create Classrooms", key: "create_classroom" },
|
{label: "Create Classrooms", key: "create_classroom"},
|
||||||
{ label: "Rename Classrooms", key: "rename_classrooms" },
|
{label: "Rename Classrooms", key: "rename_classrooms"},
|
||||||
{ label: "Add to Classroom", key: "add_to_classroom" },
|
{label: "Add to Classroom", key: "add_to_classroom"},
|
||||||
{ label: "Upload to Classroom", key: "upload_classroom" },
|
{label: "Upload to Classroom", key: "upload_classroom"},
|
||||||
{ label: "Remove from Classroom", key: "remove_from_classroom" },
|
{label: "Remove from Classroom", key: "remove_from_classroom"},
|
||||||
{ label: "Delete Classroom", key: "delete_classroom" },
|
{label: "Delete Classroom", key: "delete_classroom"},
|
||||||
{ label: "View Student Record", key: "view_student_record" },
|
{label: "View Student Record", key: "view_student_record"},
|
||||||
{ label: "Download Student Report", key: "download_student_record" },
|
{label: "Download Student Report", key: "download_student_record"},
|
||||||
]
|
];
|
||||||
|
|
||||||
const ENTITY_MANAGEMENT: PermissionLayout[] = [
|
const ENTITY_MANAGEMENT: PermissionLayout[] = [
|
||||||
{ label: "View Entities", key: "view_entities" },
|
{label: "View Entities", key: "view_entities"},
|
||||||
{ label: "View Entity Statistics", key: "view_entity_statistics" },
|
{label: "View Entity Statistics", key: "view_entity_statistics"},
|
||||||
{ label: "Rename Entity", key: "rename_entity" },
|
{label: "Rename Entity", key: "rename_entity"},
|
||||||
{ label: "Add to Entity", key: "add_to_entity" },
|
{label: "Add to Entity", key: "add_to_entity"},
|
||||||
{ label: "Remove from Entity", key: "remove_from_entity" },
|
{label: "Remove from Entity", key: "remove_from_entity"},
|
||||||
{ label: "Delete Entity", key: "delete_entity" },
|
{label: "Delete Entity", key: "delete_entity"},
|
||||||
{ label: "View Entity Roles", key: "view_entity_roles" },
|
{label: "View Entity Roles", key: "view_entity_roles"},
|
||||||
{ label: "Create Entity Role", key: "create_entity_role" },
|
{label: "Create Entity Role", key: "create_entity_role"},
|
||||||
{ label: "Rename Entity Role", key: "rename_entity_role" },
|
{label: "Rename Entity Role", key: "rename_entity_role"},
|
||||||
{ label: "Edit Role Permissions", key: "edit_role_permissions" },
|
{label: "Edit Role Permissions", key: "edit_role_permissions"},
|
||||||
{ label: "Assign Role to User", key: "assign_to_role" },
|
{label: "Assign Role to User", key: "assign_to_role"},
|
||||||
{ label: "Delete Entity Role", key: "delete_entity_role" },
|
{label: "Delete Entity Role", key: "delete_entity_role"},
|
||||||
{ label: "Download Statistics Report", key: "download_statistics_report" },
|
{label: "Download Statistics Report", key: "download_statistics_report"},
|
||||||
{ label: "Edit Grading System", key: "edit_grading_system" },
|
{label: "Edit Grading System", key: "edit_grading_system"},
|
||||||
{ label: "View Student Performance", key: "view_student_performance" },
|
{label: "View Student Performance", key: "view_student_performance"},
|
||||||
{ label: "Pay for Entity", key: "pay_entity" },
|
{label: "Pay for Entity", key: "pay_entity"},
|
||||||
{ label: "View Payment Record", key: "view_payment_record" }
|
{label: "View Payment Record", key: "view_payment_record"},
|
||||||
]
|
];
|
||||||
|
|
||||||
const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [
|
const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [
|
||||||
{ label: "View Assignments", key: "view_assignments" },
|
{label: "View Assignments", key: "view_assignments"},
|
||||||
{ label: "Create Assignments", key: "create_assignment" },
|
{label: "Create Assignments", key: "create_assignment"},
|
||||||
{ label: "Start Assignments", key: "start_assignment" },
|
{label: "Start Assignments", key: "start_assignment"},
|
||||||
{ label: "Edit Assignments", key: "edit_assignment" },
|
{label: "Edit Assignments", key: "edit_assignment"},
|
||||||
{ label: "Delete Assignments", key: "delete_assignment" },
|
{label: "Delete Assignments", key: "delete_assignment"},
|
||||||
{ label: "Archive Assignments", key: "archive_assignment" },
|
{label: "Archive Assignments", key: "archive_assignment"},
|
||||||
]
|
];
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
|
const WORKFLOW_MANAGEMENT: PermissionLayout[] = [
|
||||||
const user = await requestUser(req, res)
|
{label: "View Workflows", key: "view_workflows"},
|
||||||
if (!user) return redirect("/login")
|
{label: "Configure Workflows", key: "configure_workflows"},
|
||||||
|
{label: "Edit Workflow", key: "edit_workflow"},
|
||||||
|
{label: "Delete Workflow", key: "delete_workflow"},
|
||||||
|
];
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
|
||||||
|
const user = await requestUser(req, res);
|
||||||
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
const { id, role } = params as { id: string, role: string };
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
|
|
||||||
if (!mapBy(user.entities, 'id').includes(id) && !["admin", "developer"].includes(user.type)) return redirect("/entities")
|
const {id, role} = params as {id: string; role: string};
|
||||||
|
|
||||||
|
if (!mapBy(user.entities, "id").includes(id) && !["admin", "developer"].includes(user.type)) return redirect("/entities");
|
||||||
|
|
||||||
const entity = await getEntityWithRoles(id);
|
const entity = await getEntityWithRoles(id);
|
||||||
if (!entity) return redirect("/entities")
|
if (!entity) return redirect("/entities");
|
||||||
|
|
||||||
const entityRole = findBy(entity.roles, 'id', role)
|
const entityRole = findBy(entity.roles, "id", role);
|
||||||
if (!entityRole) return redirect(`/entities/${id}/roles`)
|
if (!entityRole) return redirect(`/entities/${id}/roles`);
|
||||||
|
|
||||||
if (!doesEntityAllow(user, entity, "view_entity_roles")) return redirect(`/entities/${id}`)
|
if (!doesEntityAllow(user, entity, "view_entity_roles")) return redirect(`/entities/${id}`);
|
||||||
const disableEdit = !isAdmin(user) && findBy(user.entities, 'id', entity.id)?.role === entityRole.id
|
const disableEdit = !isAdmin(user) && findBy(user.entities, "id", entity.id)?.role === entityRole.id;
|
||||||
|
|
||||||
const userCount = await countEntityUsers(id, { "entities.role": role });
|
const userCount = await countEntityUsers(id, {"entities.role": role});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({
|
props: serialize({
|
||||||
@@ -138,7 +141,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }
|
|||||||
entity,
|
entity,
|
||||||
role: entityRole,
|
role: entityRole,
|
||||||
userCount,
|
userCount,
|
||||||
disableEdit
|
disableEdit,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
@@ -148,19 +151,18 @@ interface Props {
|
|||||||
entity: EntityWithRoles;
|
entity: EntityWithRoles;
|
||||||
role: Role;
|
role: Role;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
disableEdit?: boolean
|
disableEdit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EntityRole({ user, entity, role, userCount, disableEdit }: Props) {
|
export default function EntityRole({user, entity, role, userCount, disableEdit}: Props) {
|
||||||
const [permissions, setPermissions] = useState(role.permissions)
|
const [permissions, setPermissions] = useState(role.permissions);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const canEditPermissions = useEntityPermission(user, entity, "edit_role_permissions")
|
const canEditPermissions = useEntityPermission(user, entity, "edit_role_permissions");
|
||||||
const canRenameRole = useEntityPermission(user, entity, "rename_entity_role")
|
const canRenameRole = useEntityPermission(user, entity, "rename_entity_role");
|
||||||
const canDeleteRole = useEntityPermission(user, entity, "delete_entity_role")
|
const canDeleteRole = useEntityPermission(user, entity, "delete_entity_role");
|
||||||
|
|
||||||
|
|
||||||
const renameRole = () => {
|
const renameRole = () => {
|
||||||
if (!canRenameRole || disableEdit) return;
|
if (!canRenameRole || disableEdit) return;
|
||||||
@@ -170,7 +172,7 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.patch(`/api/roles/${role.id}`, { label })
|
.patch(`/api/roles/${role.id}`, {label})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("The role has been updated successfully!");
|
toast.success("The role has been updated successfully!");
|
||||||
router.replace(router.asPath);
|
router.replace(router.asPath);
|
||||||
@@ -202,12 +204,12 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
};
|
};
|
||||||
|
|
||||||
const editPermissions = () => {
|
const editPermissions = () => {
|
||||||
if (!canEditPermissions || disableEdit) return
|
if (!canEditPermissions || disableEdit) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch(`/api/roles/${role.id}`, { permissions })
|
.patch(`/api/roles/${role.id}`, {permissions})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("This role has been successfully updated!");
|
toast.success("This role has been successfully updated!");
|
||||||
router.replace(router.asPath);
|
router.replace(router.asPath);
|
||||||
@@ -217,21 +219,23 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
toast.error("Something went wrong!");
|
toast.error("Something went wrong!");
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
}
|
};
|
||||||
|
|
||||||
const enableCheckbox = (permission: RolePermission) => {
|
const enableCheckbox = (permission: RolePermission) => {
|
||||||
if (!canEditPermissions || disableEdit) return false
|
if (!canEditPermissions || disableEdit) return false;
|
||||||
return doesEntityAllow(user, entity, permission)
|
return doesEntityAllow(user, entity, permission);
|
||||||
}
|
};
|
||||||
|
|
||||||
const togglePermissions = (p: RolePermission) => setPermissions(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p])
|
const togglePermissions = (p: RolePermission) => setPermissions((prev) => (prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p]));
|
||||||
const toggleMultiplePermissions = (p: RolePermission[]) =>
|
const toggleMultiplePermissions = (p: RolePermission[]) =>
|
||||||
setPermissions(prev => [...prev.filter(x => !p.includes(x)), ...(p.every(x => prev.includes(x)) ? [] : p)])
|
setPermissions((prev) => [...prev.filter((x) => !p.includes(x)), ...(p.every((x) => prev.includes(x)) ? [] : p)]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{role.label} | {entity.label} | EnCoach</title>
|
<title>
|
||||||
|
{role.label} | {entity.label} | EnCoach
|
||||||
|
</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
@@ -249,7 +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">
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
||||||
<BsChevronLeft />
|
<BsChevronLeft />
|
||||||
</Link>
|
</Link>
|
||||||
<h2 className="font-bold text-2xl">{role.label} Role ({userCount} users)</h2>
|
<h2 className="font-bold text-2xl">
|
||||||
|
{role.label} Role ({userCount} users)
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
@@ -286,16 +292,19 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
<b>User Management</b>
|
<b>User Management</b>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
disabled={!canEditPermissions || disableEdit}
|
disabled={!canEditPermissions || disableEdit}
|
||||||
isChecked={mapBy(USER_MANAGEMENT, 'key').every(k => permissions.includes(k))}
|
isChecked={mapBy(USER_MANAGEMENT, "key").every((k) => permissions.includes(k))}
|
||||||
onChange={() => toggleMultiplePermissions(mapBy(USER_MANAGEMENT, 'key').filter(enableCheckbox))}
|
onChange={() => toggleMultiplePermissions(mapBy(USER_MANAGEMENT, "key").filter(enableCheckbox))}>
|
||||||
>
|
|
||||||
Select all
|
Select all
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{USER_MANAGEMENT.map(({ label, key }) => (
|
{USER_MANAGEMENT.map(({label, key}) => (
|
||||||
<Checkbox disabled={!enableCheckbox(key)} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
|
<Checkbox
|
||||||
|
disabled={!enableCheckbox(key)}
|
||||||
|
key={key}
|
||||||
|
isChecked={permissions.includes(key)}
|
||||||
|
onChange={() => togglePermissions(key)}>
|
||||||
{label}
|
{label}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
@@ -307,16 +316,19 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
<b>Exam Management</b>
|
<b>Exam Management</b>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
disabled={!canEditPermissions || disableEdit}
|
disabled={!canEditPermissions || disableEdit}
|
||||||
isChecked={mapBy(EXAM_MANAGEMENT, 'key').every(k => permissions.includes(k))}
|
isChecked={mapBy(EXAM_MANAGEMENT, "key").every((k) => permissions.includes(k))}
|
||||||
onChange={() => toggleMultiplePermissions(mapBy(EXAM_MANAGEMENT, 'key').filter(enableCheckbox))}
|
onChange={() => toggleMultiplePermissions(mapBy(EXAM_MANAGEMENT, "key").filter(enableCheckbox))}>
|
||||||
>
|
|
||||||
Select all
|
Select all
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{EXAM_MANAGEMENT.map(({ label, key }) => (
|
{EXAM_MANAGEMENT.map(({label, key}) => (
|
||||||
<Checkbox disabled={!enableCheckbox(key)} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
|
<Checkbox
|
||||||
|
disabled={!enableCheckbox(key)}
|
||||||
|
key={key}
|
||||||
|
isChecked={permissions.includes(key)}
|
||||||
|
onChange={() => togglePermissions(key)}>
|
||||||
{label}
|
{label}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
@@ -328,16 +340,19 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
<b>Clasroom Management</b>
|
<b>Clasroom Management</b>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
disabled={!canEditPermissions || disableEdit}
|
disabled={!canEditPermissions || disableEdit}
|
||||||
isChecked={mapBy(CLASSROOM_MANAGEMENT, 'key').every(k => permissions.includes(k))}
|
isChecked={mapBy(CLASSROOM_MANAGEMENT, "key").every((k) => permissions.includes(k))}
|
||||||
onChange={() => toggleMultiplePermissions(mapBy(CLASSROOM_MANAGEMENT, 'key').filter(enableCheckbox))}
|
onChange={() => toggleMultiplePermissions(mapBy(CLASSROOM_MANAGEMENT, "key").filter(enableCheckbox))}>
|
||||||
>
|
|
||||||
Select all
|
Select all
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{CLASSROOM_MANAGEMENT.map(({ label, key }) => (
|
{CLASSROOM_MANAGEMENT.map(({label, key}) => (
|
||||||
<Checkbox disabled={!enableCheckbox(key)} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
|
<Checkbox
|
||||||
|
disabled={!enableCheckbox(key)}
|
||||||
|
key={key}
|
||||||
|
isChecked={permissions.includes(key)}
|
||||||
|
onChange={() => togglePermissions(key)}>
|
||||||
{label}
|
{label}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
@@ -349,16 +364,19 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
<b>Entity Management</b>
|
<b>Entity Management</b>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
disabled={!canEditPermissions || disableEdit}
|
disabled={!canEditPermissions || disableEdit}
|
||||||
isChecked={mapBy(ENTITY_MANAGEMENT, 'key').every(k => permissions.includes(k))}
|
isChecked={mapBy(ENTITY_MANAGEMENT, "key").every((k) => permissions.includes(k))}
|
||||||
onChange={() => toggleMultiplePermissions(mapBy(ENTITY_MANAGEMENT, 'key').filter(enableCheckbox))}
|
onChange={() => toggleMultiplePermissions(mapBy(ENTITY_MANAGEMENT, "key").filter(enableCheckbox))}>
|
||||||
>
|
|
||||||
Select all
|
Select all
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{ENTITY_MANAGEMENT.map(({ label, key }) => (
|
{ENTITY_MANAGEMENT.map(({label, key}) => (
|
||||||
<Checkbox disabled={!enableCheckbox(key)} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
|
<Checkbox
|
||||||
|
disabled={!enableCheckbox(key)}
|
||||||
|
key={key}
|
||||||
|
isChecked={permissions.includes(key)}
|
||||||
|
onChange={() => togglePermissions(key)}>
|
||||||
{label}
|
{label}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
@@ -370,16 +388,43 @@ export default function EntityRole({ user, entity, role, userCount, disableEdit
|
|||||||
<b>Assignment Management</b>
|
<b>Assignment Management</b>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
disabled={!canEditPermissions || disableEdit}
|
disabled={!canEditPermissions || disableEdit}
|
||||||
isChecked={mapBy(ASSIGNMENT_MANAGEMENT, 'key').every(k => permissions.includes(k))}
|
isChecked={mapBy(ASSIGNMENT_MANAGEMENT, "key").every((k) => permissions.includes(k))}
|
||||||
onChange={() => toggleMultiplePermissions(mapBy(ASSIGNMENT_MANAGEMENT, 'key').filter(enableCheckbox))}
|
onChange={() => toggleMultiplePermissions(mapBy(ASSIGNMENT_MANAGEMENT, "key").filter(enableCheckbox))}>
|
||||||
>
|
|
||||||
Select all
|
Select all
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{ASSIGNMENT_MANAGEMENT.map(({ label, key }) => (
|
{ASSIGNMENT_MANAGEMENT.map(({label, key}) => (
|
||||||
<Checkbox disabled={!enableCheckbox(key)} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
|
<Checkbox
|
||||||
|
disabled={!enableCheckbox(key)}
|
||||||
|
key={key}
|
||||||
|
isChecked={permissions.includes(key)}
|
||||||
|
onChange={() => togglePermissions(key)}>
|
||||||
|
{label}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="w-full flex items-center justify-between">
|
||||||
|
<b>Workflow Management</b>
|
||||||
|
<Checkbox
|
||||||
|
disabled={!canEditPermissions || disableEdit}
|
||||||
|
isChecked={mapBy(WORKFLOW_MANAGEMENT, "key").every((k) => permissions.includes(k))}
|
||||||
|
onChange={() => toggleMultiplePermissions(mapBy(WORKFLOW_MANAGEMENT, "key").filter(enableCheckbox))}>
|
||||||
|
Select all
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{WORKFLOW_MANAGEMENT.map(({label, key}) => (
|
||||||
|
<Checkbox
|
||||||
|
disabled={!enableCheckbox(key)}
|
||||||
|
key={key}
|
||||||
|
isChecked={permissions.includes(key)}
|
||||||
|
onChange={() => togglePermissions(key)}>
|
||||||
{label}
|
{label}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { USER_TYPE_LABELS } from "@/resources/user";
|
|||||||
import { redirect, serialize } from "@/utils";
|
import { redirect, serialize } from "@/utils";
|
||||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
import { getUserName } from "@/utils/users";
|
import { getUserName } from "@/utils/users";
|
||||||
import { getUsers } from "@/utils/users.be";
|
import { getUsers } from "@/utils/users.be";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
@@ -20,161 +20,218 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Divider } from "primereact/divider";
|
import { Divider } from "primereact/divider";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill } from "react-icons/bs";
|
import {
|
||||||
|
BsCheck,
|
||||||
|
BsChevronLeft,
|
||||||
|
BsClockFill,
|
||||||
|
BsEnvelopeFill,
|
||||||
|
BsStopwatchFill,
|
||||||
|
} from "react-icons/bs";
|
||||||
import { toast, ToastContainer } from "react-toastify";
|
import { toast, ToastContainer } from "react-toastify";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
if (!["admin", "developer"].includes(user.type)) return redirect("/entities")
|
if (!["admin", "developer"].includes(user.type)) return redirect("/entities");
|
||||||
|
|
||||||
const users = await getUsers()
|
const users = await getUsers(
|
||||||
|
{ id: { $ne: user.id } },
|
||||||
|
0,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
type: 1,
|
||||||
|
profilePicture: 1,
|
||||||
|
email: 1,
|
||||||
|
lastLogin: 1,
|
||||||
|
subscriptionExpirationDate: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({ user, users: users.filter((x) => x.id !== user.id) }),
|
props: serialize({ user, users }),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
users: User[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({ user, users }: Props) {
|
export default function Home({ user, users }: Props) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
const [label, setLabel] = useState("");
|
const [label, setLabel] = useState("");
|
||||||
const [licenses, setLicenses] = useState(0);
|
const [licenses, setLicenses] = useState(0);
|
||||||
|
|
||||||
const { rows, renderSearch } = useListSearch<User>([["name"], ["corporateInformation", "companyInformation", "name"]], users);
|
const { rows, renderSearch } = useListSearch<User>(
|
||||||
const { items, renderMinimal } = usePagination<User>(rows, 16);
|
[["name"], ["corporateInformation", "companyInformation", "name"]],
|
||||||
|
users
|
||||||
|
);
|
||||||
|
const { items, renderMinimal } = usePagination<User>(rows, 16);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const createGroup = () => {
|
const createGroup = () => {
|
||||||
if (!label.trim()) return;
|
if (!label.trim()) return;
|
||||||
if (!confirm(`Are you sure you want to create this entity with ${selectedUsers.length} members?`)) return;
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Are you sure you want to create this entity with ${selectedUsers.length} members?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post<Entity>(`/api/entities`, { label, licenses, members: selectedUsers })
|
.post<Entity>(`/api/entities`, {
|
||||||
.then((result) => {
|
label,
|
||||||
toast.success("Your entity has been created successfully!");
|
licenses,
|
||||||
router.replace(`/entities/${result.data.id}`);
|
members: selectedUsers,
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.then((result) => {
|
||||||
console.error(e);
|
toast.success("Your entity has been created successfully!");
|
||||||
toast.error("Something went wrong!");
|
router.replace(`/entities/${result.data.id}`);
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.catch((e) => {
|
||||||
};
|
console.error(e);
|
||||||
|
toast.error("Something went wrong!");
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
|
const toggleUser = (u: User) =>
|
||||||
|
setSelectedUsers((prev) =>
|
||||||
|
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Create Entity | EnCoach</title>
|
<title>Create Entity | EnCoach</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<>
|
<>
|
||||||
<section className="flex flex-col gap-0">
|
<section className="flex flex-col gap-0">
|
||||||
<div className="flex gap-3 justify-between">
|
<div className="flex gap-3 justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/classrooms"
|
href="/classrooms"
|
||||||
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||||
<BsChevronLeft />
|
>
|
||||||
</Link>
|
<BsChevronLeft />
|
||||||
<h2 className="font-bold text-2xl">Create Entity</h2>
|
</Link>
|
||||||
</div>
|
<h2 className="font-bold text-2xl">Create Entity</h2>
|
||||||
<div className="flex items-center gap-4">
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-4">
|
||||||
onClick={createGroup}
|
<button
|
||||||
disabled={!label.trim() || licenses <= 0 || isLoading}
|
onClick={createGroup}
|
||||||
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={!label.trim() || licenses <= 0 || isLoading}
|
||||||
<BsCheck />
|
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"
|
||||||
<span className="text-xs">Create Entity</span>
|
>
|
||||||
</button>
|
<BsCheck />
|
||||||
</div>
|
<span className="text-xs">Create Entity</span>
|
||||||
</div>
|
</button>
|
||||||
<Divider />
|
</div>
|
||||||
<div className="w-full grid grid-cols-2 gap-4">
|
</div>
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<Divider />
|
||||||
<span className="font-semibold text-xl">Entity Label:</span>
|
<div className="w-full grid grid-cols-2 gap-4">
|
||||||
<Input name="name" onChange={setLabel} type="text" placeholder="Entity A" />
|
<div className="flex flex-col gap-4 w-full">
|
||||||
</div>
|
<span className="font-semibold text-xl">Entity Label:</span>
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
onChange={setLabel}
|
||||||
|
type="text"
|
||||||
|
placeholder="Entity A"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<span className="font-semibold text-xl">Licenses:</span>
|
<span className="font-semibold text-xl">Licenses:</span>
|
||||||
<Input name="licenses" min={0} onChange={(v) => setLicenses(parseInt(v))} type="number" placeholder="12" />
|
<Input
|
||||||
</div>
|
name="licenses"
|
||||||
</div>
|
min={0}
|
||||||
<Divider />
|
onChange={(v) => setLicenses(parseInt(v))}
|
||||||
<div className="flex items-center justify-between mb-4">
|
type="number"
|
||||||
<span className="font-semibold text-xl">Members ({selectedUsers.length} selected):</span>
|
placeholder="12"
|
||||||
</div>
|
/>
|
||||||
<div className="w-full flex items-center gap-4">
|
</div>
|
||||||
{renderSearch()}
|
</div>
|
||||||
{renderMinimal()}
|
<Divider />
|
||||||
</div>
|
<div className="flex items-center justify-between mb-4">
|
||||||
</section>
|
<span className="font-semibold text-xl">
|
||||||
|
Members ({selectedUsers.length} selected):
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex items-center gap-4">
|
||||||
|
{renderSearch()}
|
||||||
|
{renderMinimal()}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{items.map((u) => (
|
{items.map((u) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleUser(u)}
|
onClick={() => toggleUser(u)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
key={u.id}
|
key={u.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
|
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
|
||||||
"hover:border-mti-purple transition ease-in-out duration-300",
|
"hover:border-mti-purple transition ease-in-out duration-300",
|
||||||
selectedUsers.includes(u.id) && "border-mti-purple",
|
selectedUsers.includes(u.id) && "border-mti-purple"
|
||||||
)}>
|
)}
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
|
<div className="flex items-center gap-2">
|
||||||
<img src={u.profilePicture} alt={u.name} />
|
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
|
||||||
</div>
|
<img src={u.profilePicture} alt={u.name} />
|
||||||
<div className="flex flex-col">
|
</div>
|
||||||
<span className="font-semibold">{getUserName(u)}</span>
|
<div className="flex flex-col">
|
||||||
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span>
|
<span className="font-semibold">{getUserName(u)}</span>
|
||||||
</div>
|
<span className="opacity-80 text-sm">
|
||||||
</div>
|
{USER_TYPE_LABELS[u.type]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Tooltip tooltip="E-mail address">
|
<Tooltip tooltip="E-mail address">
|
||||||
<BsEnvelopeFill />
|
<BsEnvelopeFill />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{u.email}
|
{u.email}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Tooltip tooltip="Expiration Date">
|
<Tooltip tooltip="Expiration Date">
|
||||||
<BsStopwatchFill />
|
<BsStopwatchFill />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
|
{u.subscriptionExpirationDate
|
||||||
</span>
|
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
|
||||||
<span className="flex items-center gap-2">
|
: "Unlimited"}
|
||||||
<Tooltip tooltip="Last Login">
|
</span>
|
||||||
<BsClockFill />
|
<span className="flex items-center gap-2">
|
||||||
</Tooltip>
|
<Tooltip tooltip="Last Login">
|
||||||
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
|
<BsClockFill />
|
||||||
</span>
|
</Tooltip>
|
||||||
</div>
|
{u.lastLogin
|
||||||
</button>
|
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
|
||||||
))}
|
: "N/A"}
|
||||||
</section>
|
</span>
|
||||||
</>
|
</div>
|
||||||
</>
|
</button>
|
||||||
);
|
))}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,17 +35,35 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
);
|
);
|
||||||
const allowedEntities = findAllowedEntities(user, entities, "view_entities");
|
const allowedEntities = findAllowedEntities(user, entities, "view_entities");
|
||||||
|
|
||||||
const entitiesWithCount = await Promise.all(
|
const [counts, users] = await Promise.all([
|
||||||
allowedEntities.map(async (e) => ({
|
await Promise.all(
|
||||||
entity: e,
|
allowedEntities.map(async (e) =>
|
||||||
count: await countEntityUsers(e.id, {
|
countEntityUsers(e.id, {
|
||||||
type: { $in: ["student", "teacher", "corporate", "mastercorporate"] },
|
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 {
|
return {
|
||||||
props: serialize({ user, entities: entitiesWithCount }),
|
props: serialize({ user, entities: entitiesWithCount }),
|
||||||
|
|||||||
@@ -21,93 +21,124 @@ import { getSessionByAssignment } from "@/utils/sessions.be";
|
|||||||
import { Session } from "@/hooks/useSessions";
|
import { Session } from "@/hooks/useSessions";
|
||||||
import { activeAssignmentFilter } from "@/utils/assignments";
|
import { activeAssignmentFilter } from "@/utils/assignments";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
|
export const getServerSideProps = withIronSessionSsr(
|
||||||
const user = await requestUser(req, res)
|
async ({ req, res, query }) => {
|
||||||
const loginDestination = Buffer.from(req.url || "/").toString("base64")
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect(`/login?destination=${loginDestination}`)
|
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 { assignment: assignmentID, destination } = query as {
|
||||||
const destinationURL = !!destination ? Buffer.from(destination, 'base64').toString() : undefined
|
assignment?: string;
|
||||||
|
destination?: string;
|
||||||
|
};
|
||||||
|
const destinationURL = !!destination
|
||||||
|
? Buffer.from(destination, "base64").toString()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (!!assignmentID) {
|
if (!!assignmentID) {
|
||||||
const assignment = await getAssignment(assignmentID)
|
const assignment = await getAssignment(assignmentID);
|
||||||
|
|
||||||
if (!assignment) return redirect(destinationURL || "/exam")
|
if (!assignment) return redirect(destinationURL || "/exam");
|
||||||
if (!assignment.assignees.includes(user.id) && !["admin", "developer"].includes(user.type))
|
if (
|
||||||
return redirect(destinationURL || "/exam")
|
!assignment.assignees.includes(user.id) &&
|
||||||
|
!["admin", "developer"].includes(user.type)
|
||||||
|
)
|
||||||
|
return redirect(destinationURL || "/exam");
|
||||||
|
|
||||||
if (filterBy(assignment.results, 'user', user.id).length > 0)
|
if (filterBy(assignment.results, "user", user.id).length > 0)
|
||||||
return redirect(destinationURL || "/exam")
|
return redirect(destinationURL || "/exam");
|
||||||
|
|
||||||
|
const [exams, session] = await Promise.all([
|
||||||
|
getExamsByIds(uniqBy(assignment.exams, "id")),
|
||||||
|
getSessionByAssignment(assignmentID),
|
||||||
|
]);
|
||||||
|
|
||||||
const exams = await getExamsByIds(uniqBy(assignment.exams, "id"))
|
return {
|
||||||
const session = await getSessionByAssignment(assignmentID)
|
props: serialize({
|
||||||
|
user,
|
||||||
|
assignment,
|
||||||
|
exams,
|
||||||
|
destinationURL,
|
||||||
|
session: session ?? undefined,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({ user, assignment, exams, destinationURL, session: session ?? undefined })
|
props: serialize({ user, destinationURL }),
|
||||||
}
|
};
|
||||||
}
|
},
|
||||||
|
sessionOptions
|
||||||
return {
|
);
|
||||||
props: serialize({ user, destinationURL }),
|
|
||||||
};
|
|
||||||
}, sessionOptions);
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
assignment?: Assignment
|
assignment?: Assignment;
|
||||||
exams?: Exam[]
|
exams?: Exam[];
|
||||||
session?: Session
|
session?: Session;
|
||||||
destinationURL?: string
|
destinationURL?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Page: React.FC<Props> = ({ user, assignment, exams = [], destinationURL = "/exam", session }) => {
|
const Page: React.FC<Props> = ({
|
||||||
const router = useRouter()
|
user,
|
||||||
const { assignment: storeAssignment, dispatch } = useExamStore();
|
assignment,
|
||||||
|
exams = [],
|
||||||
|
destinationURL = "/exam",
|
||||||
|
session,
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { assignment: storeAssignment, dispatch } = useExamStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (assignment && exams.length > 0 && !storeAssignment && !session) {
|
if (assignment && exams.length > 0 && !storeAssignment && !session) {
|
||||||
if (!activeAssignmentFilter(assignment)) return
|
if (!activeAssignmentFilter(assignment)) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "INIT_EXAM", payload: {
|
type: "INIT_EXAM",
|
||||||
exams: exams.sort(sortByModule),
|
payload: {
|
||||||
modules: exams
|
exams: exams.sort(sortByModule),
|
||||||
.map((x) => x!)
|
modules: exams
|
||||||
.sort(sortByModule)
|
.map((x) => x!)
|
||||||
.map((x) => x!.module),
|
.sort(sortByModule)
|
||||||
assignment
|
.map((x) => x!.module),
|
||||||
}
|
assignment,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
router.replace(router.asPath)
|
router.replace(router.asPath);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [assignment, exams, session])
|
}, [assignment, exams, session]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
|
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
|
||||||
dispatch({ type: "SET_SESSION", payload: { session } })
|
dispatch({ type: "SET_SESSION", payload: { session } });
|
||||||
router.replace(router.asPath)
|
router.replace(router.asPath);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [assignment, exams, session])
|
}, [assignment, exams, session]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Exams | EnCoach</title>
|
<title>Exams | EnCoach</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<ExamPage page="exams" destination={destinationURL} user={user} hideSidebar={!!assignment || !!storeAssignment} />
|
<ExamPage
|
||||||
</>
|
page="exams"
|
||||||
);
|
destination={destinationURL}
|
||||||
}
|
user={user}
|
||||||
|
hideSidebar={!!assignment || !!storeAssignment}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
//Page.whyDidYouRender = true;
|
//Page.whyDidYouRender = true;
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
@@ -21,92 +21,100 @@ import { getSessionByAssignment } from "@/utils/sessions.be";
|
|||||||
import { Session } from "@/hooks/useSessions";
|
import { Session } from "@/hooks/useSessions";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => {
|
export const getServerSideProps = withIronSessionSsr(
|
||||||
const user = await requestUser(req, res)
|
async ({ req, res, query }) => {
|
||||||
const destination = Buffer.from(req.url || "/").toString("base64")
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect(`/login?destination=${destination}`)
|
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) {
|
if (assignmentID) {
|
||||||
const assignment = await getAssignment(assignmentID)
|
const assignment = await getAssignment(assignmentID);
|
||||||
|
|
||||||
if (!assignment) return redirect("/exam")
|
if (!assignment) return redirect("/exam");
|
||||||
if (!["admin", "developer"].includes(user.type) && !assignment.assignees.includes(user.id)) return redirect("/exercises")
|
if (
|
||||||
|
!["admin", "developer"].includes(user.type) &&
|
||||||
|
!assignment.assignees.includes(user.id)
|
||||||
|
)
|
||||||
|
return redirect("/exercises");
|
||||||
|
if (
|
||||||
|
filterBy(assignment.results, "user", user.id) ||
|
||||||
|
moment(assignment.startDate).isBefore(moment()) ||
|
||||||
|
moment(assignment.endDate).isAfter(moment())
|
||||||
|
)
|
||||||
|
return redirect("/exam");
|
||||||
|
const [exams, session] = await Promise.all([
|
||||||
|
getExamsByIds(uniqBy(assignment.exams, "id")),
|
||||||
|
getSessionByAssignment(assignmentID),
|
||||||
|
]);
|
||||||
|
|
||||||
const exams = await getExamsByIds(uniqBy(assignment.exams, "id"))
|
return {
|
||||||
const session = await getSessionByAssignment(assignmentID)
|
props: serialize({ user, assignment, exams, session }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
return {
|
||||||
filterBy(assignment.results, 'user', user.id) ||
|
props: serialize({ user }),
|
||||||
moment(assignment.startDate).isBefore(moment()) ||
|
};
|
||||||
moment(assignment.endDate).isAfter(moment())
|
},
|
||||||
)
|
sessionOptions
|
||||||
return redirect("/exam")
|
);
|
||||||
|
|
||||||
return {
|
|
||||||
props: serialize({ user, assignment, exams, session })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: serialize({ user }),
|
|
||||||
};
|
|
||||||
}, sessionOptions);
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
assignment?: Assignment
|
assignment?: Assignment;
|
||||||
exams?: Exam[]
|
exams?: Exam[];
|
||||||
session?: Session
|
session?: Session;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page({ user, assignment, exams = [], session }: Props) {
|
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(() => {
|
useEffect(() => {
|
||||||
if (assignment && exams.length > 0 && !storeAssignment && !session) {
|
if (assignment && exams.length > 0 && !storeAssignment && !session) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "INIT_EXAM", payload: {
|
type: "INIT_EXAM",
|
||||||
exams: exams.sort(sortByModule),
|
payload: {
|
||||||
modules: exams
|
exams: exams.sort(sortByModule),
|
||||||
.map((x) => x!)
|
modules: exams
|
||||||
.sort(sortByModule)
|
.map((x) => x!)
|
||||||
.map((x) => x!.module),
|
.sort(sortByModule)
|
||||||
assignment
|
.map((x) => x!.module),
|
||||||
}
|
assignment,
|
||||||
})
|
},
|
||||||
|
});
|
||||||
|
|
||||||
router.replace(router.asPath)
|
router.replace(router.asPath);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [assignment, exams, session])
|
}, [assignment, exams, session]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
|
if (assignment && exams.length > 0 && !storeAssignment && !!session) {
|
||||||
dispatch({ type: "SET_SESSION", payload: { session } });
|
dispatch({ type: "SET_SESSION", payload: { session } });
|
||||||
|
|
||||||
router.replace(router.asPath)
|
router.replace(router.asPath);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [assignment, exams, session])
|
}, [assignment, exams, session]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Exams | EnCoach</title>
|
<title>Exams | EnCoach</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<ExamPage page="exams" user={user} hideSidebar={!!assignment} />
|
<ExamPage page="exams" user={user} hideSidebar={!!assignment} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,40 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import { ToastContainer } from "react-toastify";
|
import {ToastContainer} from "react-toastify";
|
||||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||||
import { Radio, RadioGroup } from "@headlessui/react";
|
import {Radio, RadioGroup} from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
import { capitalize } from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import { findAllowedEntities } from "@/utils/permissions";
|
import {findAllowedEntities} from "@/utils/permissions";
|
||||||
import { User } from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import ExamEditorStore from "@/stores/examEditor/types";
|
import ExamEditorStore from "@/stores/examEditor/types";
|
||||||
import ExamEditor from "@/components/ExamEditor";
|
import ExamEditor from "@/components/ExamEditor";
|
||||||
import { mapBy, redirect, serialize } from "@/utils";
|
import {mapBy, redirect, serialize} from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import {requestUser} from "@/utils/api";
|
||||||
import { Module } from "@/interfaces";
|
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 {Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise} from "@/interfaces/exam";
|
||||||
import { useEffect, useState } from "react";
|
import {useEffect, useState} from "react";
|
||||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
import {getEntitiesWithRoles} from "@/utils/entities.be";
|
||||||
import { isAdmin } from "@/utils/users";
|
import {isAdmin} from "@/utils/users";
|
||||||
import axios from "axios";
|
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 }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
|
|
||||||
const entityIDs = mapBy(user.entities, 'id')
|
const entityIDs = mapBy(user.entities, "id");
|
||||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs)
|
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs);
|
||||||
|
|
||||||
const permissions: Permission = {
|
const permissions: Permission = {
|
||||||
reading: findAllowedEntities(user, entities, `generate_reading`).length > 0,
|
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,
|
writing: findAllowedEntities(user, entities, `generate_writing`).length > 0,
|
||||||
speaking: findAllowedEntities(user, entities, `generate_speaking`).length > 0,
|
speaking: findAllowedEntities(user, entities, `generate_speaking`).length > 0,
|
||||||
level: findAllowedEntities(user, entities, `generate_level`).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("/");
|
||||||
if (!id || !examModule) return { props: serialize({ user, permissions }) };
|
|
||||||
|
const {id, module: examModule} = query as {id?: string; module?: Module};
|
||||||
|
if (!id || !examModule) return {props: serialize({user, permissions})};
|
||||||
|
|
||||||
//if (!permissions[module]) return redirect("/generation")
|
//if (!permissions[module]) return redirect("/generation")
|
||||||
|
|
||||||
const exam = await getExam(examModule, id)
|
const exam = await getExam(examModule, id);
|
||||||
if (!exam) return redirect("/generation")
|
if (!exam) return redirect("/generation");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({ id, user, exam, examModule, permissions }),
|
props: serialize({id, user, exam, examModule, permissions, entitiesAllowEditPrivacy}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Generation({ id, user, exam, examModule, permissions }: { id: string, user: User; exam?: Exam, examModule?: Module, permissions: Permission }) {
|
export default function Generation({
|
||||||
const { title, currentModule, modules, dispatch } = useExamEditorStore();
|
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 [examLevelParts, setExamLevelParts] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
const updateRoot = (updates: Partial<ExamEditorStore>) => {
|
const updateRoot = (updates: Partial<ExamEditorStore>) => {
|
||||||
dispatch({ type: 'UPDATE_ROOT', payload: { updates } });
|
dispatch({type: "UPDATE_ROOT", payload: {updates}});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -71,16 +89,16 @@ export default function Generation({ id, user, exam, examModule, permissions }:
|
|||||||
if (examModule === "level" && exam.module === "level") {
|
if (examModule === "level" && exam.module === "level") {
|
||||||
setExamLevelParts(exam.parts.length);
|
setExamLevelParts(exam.parts.length);
|
||||||
}
|
}
|
||||||
updateRoot({currentModule: examModule})
|
updateRoot({currentModule: examModule});
|
||||||
dispatch({ type: "INIT_EXAM_EDIT", payload: { exam, id, examModule } })
|
dispatch({type: "INIT_EXAM_EDIT", payload: {exam, id, examModule}});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id, exam, module])
|
}, [id, exam, module]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAvatars = async () => {
|
const fetchAvatars = async () => {
|
||||||
const response = await axios.get("/api/exam/avatars");
|
const response = await axios.get("/api/exam/avatars");
|
||||||
updateRoot({ speakingAvatars: response.data });
|
updateRoot({speakingAvatars: response.data});
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAvatars();
|
fetchAvatars();
|
||||||
@@ -96,48 +114,61 @@ export default function Generation({ id, user, exam, examModule, permissions }:
|
|||||||
URL.revokeObjectURL(state.writing.academic_url);
|
URL.revokeObjectURL(state.writing.academic_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.listening.sections.forEach(section => {
|
state.listening.sections.forEach((section) => {
|
||||||
const listeningPart = section.state as ListeningPart;
|
const listeningPart = section.state as ListeningPart;
|
||||||
if (listeningPart.audio?.source) {
|
if (listeningPart.audio?.source) {
|
||||||
URL.revokeObjectURL(listeningPart.audio.source);
|
URL.revokeObjectURL(listeningPart.audio.source);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
sectionId: section.sectionId, module: "listening", field: "state", value: { ...listeningPart, audio: undefined }
|
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);
|
URL.revokeObjectURL(state.listening.instructionsState.customInstructionsURL);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.speaking.sections.forEach(section => {
|
state.speaking.sections.forEach((section) => {
|
||||||
const sectionState = section.state as Exercise;
|
const sectionState = section.state as Exercise;
|
||||||
if (sectionState.type === 'speaking') {
|
if (sectionState.type === "speaking") {
|
||||||
const speakingExercise = sectionState as SpeakingExercise;
|
const speakingExercise = sectionState as SpeakingExercise;
|
||||||
URL.revokeObjectURL(speakingExercise.video_url);
|
URL.revokeObjectURL(speakingExercise.video_url);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
sectionId: section.sectionId, module: "listening", field: "state", value: { ...speakingExercise, video_url: undefined }
|
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;
|
const interactiveSpeaking = sectionState as InteractiveSpeakingExercise;
|
||||||
interactiveSpeaking.prompts.forEach(prompt => {
|
interactiveSpeaking.prompts.forEach((prompt) => {
|
||||||
URL.revokeObjectURL(prompt.video_url);
|
URL.revokeObjectURL(prompt.video_url);
|
||||||
});
|
});
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
sectionId: section.sectionId, module: "listening", field: "state", value: {
|
payload: {
|
||||||
...interactiveSpeaking, prompts: interactiveSpeaking.prompts.map((p) => ({ ...p, video_url: undefined }))
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -163,7 +194,7 @@ export default function Generation({ id, user, exam, examModule, permissions }:
|
|||||||
placeholder="Insert a title here"
|
placeholder="Insert a title here"
|
||||||
name="title"
|
name="title"
|
||||||
label="Title"
|
label="Title"
|
||||||
onChange={(title) => updateRoot({ title })}
|
onChange={(title) => updateRoot({title})}
|
||||||
roundness="xl"
|
roundness="xl"
|
||||||
value={title}
|
value={title}
|
||||||
defaultValue={title}
|
defaultValue={title}
|
||||||
@@ -172,44 +203,46 @@ export default function Generation({ id, user, exam, examModule, permissions }:
|
|||||||
<label className="font-normal text-base text-mti-gray-dim">Module</label>
|
<label className="font-normal text-base text-mti-gray-dim">Module</label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={currentModule}
|
value={currentModule}
|
||||||
onChange={(currentModule) => updateRoot({ currentModule })}
|
onChange={(currentModule) => updateRoot({currentModule})}
|
||||||
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between">
|
||||||
{[...MODULE_ARRAY].filter(m => permissions[m]).map((x) => (
|
{[...MODULE_ARRAY]
|
||||||
<Radio value={x} key={x}>
|
.filter((m) => permissions[m])
|
||||||
{({ checked }) => (
|
.map((x) => (
|
||||||
<span
|
<Radio value={x} key={x}>
|
||||||
className={clsx(
|
{({checked}) => (
|
||||||
"px-6 py-4 w-64 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
<span
|
||||||
"transition duration-300 ease-in-out",
|
className={clsx(
|
||||||
x === "reading" &&
|
"px-6 py-4 w-64 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
(!checked
|
"transition duration-300 ease-in-out",
|
||||||
? "bg-white border-mti-gray-platinum"
|
x === "reading" &&
|
||||||
: "bg-ielts-reading/70 border-ielts-reading text-white"),
|
(!checked
|
||||||
x === "listening" &&
|
? "bg-white border-mti-gray-platinum"
|
||||||
(!checked
|
: "bg-ielts-reading/70 border-ielts-reading text-white"),
|
||||||
? "bg-white border-mti-gray-platinum"
|
x === "listening" &&
|
||||||
: "bg-ielts-listening/70 border-ielts-listening text-white"),
|
(!checked
|
||||||
x === "writing" &&
|
? "bg-white border-mti-gray-platinum"
|
||||||
(!checked
|
: "bg-ielts-listening/70 border-ielts-listening text-white"),
|
||||||
? "bg-white border-mti-gray-platinum"
|
x === "writing" &&
|
||||||
: "bg-ielts-writing/70 border-ielts-writing text-white"),
|
(!checked
|
||||||
x === "speaking" &&
|
? "bg-white border-mti-gray-platinum"
|
||||||
(!checked
|
: "bg-ielts-writing/70 border-ielts-writing text-white"),
|
||||||
? "bg-white border-mti-gray-platinum"
|
x === "speaking" &&
|
||||||
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
|
(!checked
|
||||||
x === "level" &&
|
? "bg-white border-mti-gray-platinum"
|
||||||
(!checked
|
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
|
||||||
? "bg-white border-mti-gray-platinum"
|
x === "level" &&
|
||||||
: "bg-ielts-level/70 border-ielts-level text-white"),
|
(!checked
|
||||||
)}>
|
? "bg-white border-mti-gray-platinum"
|
||||||
{capitalize(x)}
|
: "bg-ielts-level/70 border-ielts-level text-white"),
|
||||||
</span>
|
)}>
|
||||||
)}
|
{capitalize(x)}
|
||||||
</Radio>
|
</span>
|
||||||
))}
|
)}
|
||||||
|
</Radio>
|
||||||
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
<ExamEditor levelParts={examLevelParts} />
|
<ExamEditor levelParts={examLevelParts} entitiesAllowEditPrivacy={entitiesAllowEditPrivacy} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Button from "@/components/Low/Button";
|
|||||||
import Separator from "@/components/Low/Separator";
|
import Separator from "@/components/Low/Separator";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import { Session } from "@/hooks/useSessions";
|
import { Session } from "@/hooks/useSessions";
|
||||||
import { Grading } from "@/interfaces";
|
import { Grading, Module } from "@/interfaces";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { Exam } from "@/interfaces/exam";
|
import { Exam } from "@/interfaces/exam";
|
||||||
import { InviteWithEntity } from "@/interfaces/invite";
|
import { InviteWithEntity } from "@/interfaces/invite";
|
||||||
@@ -12,14 +12,13 @@ import { Assignment } from "@/interfaces/results";
|
|||||||
import { Stat, User } from "@/interfaces/user";
|
import { Stat, User } from "@/interfaces/user";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import useExamStore from "@/stores/exam";
|
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 { requestUser } from "@/utils/api";
|
||||||
import {
|
import {
|
||||||
activeAssignmentFilter,
|
activeAssignmentFilter,
|
||||||
futureAssignmentFilter,
|
futureAssignmentFilter,
|
||||||
} from "@/utils/assignments";
|
} from "@/utils/assignments";
|
||||||
import { getAssignmentsByAssignee } from "@/utils/assignments.be";
|
import { getAssignmentsByAssignee } from "@/utils/assignments.be";
|
||||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
|
||||||
import { getExamsByIds } from "@/utils/exams.be";
|
import { getExamsByIds } from "@/utils/exams.be";
|
||||||
import { sortByModule } from "@/utils/moduleUtils";
|
import { sortByModule } from "@/utils/moduleUtils";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
@@ -28,7 +27,6 @@ import axios from "axios";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import { uniqBy } from "lodash";
|
import { uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
@@ -53,32 +51,59 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
|
|
||||||
if (!checkAccess(user, ["admin", "developer", "student"]))
|
if (!checkAccess(user, ["admin", "developer", "student"]))
|
||||||
return redirect("/");
|
return redirect("/");
|
||||||
|
const assignments = (await getAssignmentsByAssignee(
|
||||||
|
user.id,
|
||||||
|
{
|
||||||
|
archived: { $ne: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 0,
|
||||||
|
id: 1,
|
||||||
|
name: 1,
|
||||||
|
startDate: 1,
|
||||||
|
endDate: 1,
|
||||||
|
exams: 1,
|
||||||
|
results: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sort: { startDate: 1 },
|
||||||
|
}
|
||||||
|
)) as Assignment[];
|
||||||
|
|
||||||
const entityIDS = mapBy(user.entities, "id") || [];
|
const sessions = await getSessionsByUser(
|
||||||
|
user.id,
|
||||||
const entities = await getEntitiesWithRoles(entityIDS);
|
0,
|
||||||
const assignments = await getAssignmentsByAssignee(user.id, {
|
{
|
||||||
archived: { $ne: true },
|
"assignment.id": { $in: mapBy(assignments, "id") },
|
||||||
});
|
},
|
||||||
const sessions = await getSessionsByUser(user.id, 0, {
|
{
|
||||||
"assignment.id": { $in: mapBy(assignments, "id") },
|
_id: 0,
|
||||||
});
|
id: 1,
|
||||||
|
assignment: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const examIDs = uniqBy(
|
const examIDs = uniqBy(
|
||||||
assignments.flatMap((a) =>
|
assignments.reduce<{ module: Module; id: string; key: string }[]>(
|
||||||
filterBy(a.exams, "assignee", user.id).map(
|
(acc, a) => {
|
||||||
(e: any) => ({
|
a.exams.forEach((e) => {
|
||||||
module: e.module,
|
if (e.assignee === user.id)
|
||||||
id: e.id,
|
acc.push({
|
||||||
key: `${e.module}_${e.id}`,
|
module: e.module,
|
||||||
})
|
id: e.id,
|
||||||
)
|
key: `${e.module}_${e.id}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[]
|
||||||
),
|
),
|
||||||
"key"
|
"key"
|
||||||
);
|
);
|
||||||
|
|
||||||
const exams = await getExamsByIds(examIDs);
|
const exams = await getExamsByIds(examIDs);
|
||||||
|
|
||||||
return { props: serialize({ user, entities, assignments, exams, sessions }) };
|
return { props: serialize({ user, assignments, exams, sessions }) };
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
const destination = Buffer.from("/official-exam").toString("base64");
|
const destination = Buffer.from("/official-exam").toString("base64");
|
||||||
@@ -109,11 +134,12 @@ export default function OfficialExam({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (assignmentExams.every((x) => !!x)) {
|
if (assignmentExams.every((x) => !!x)) {
|
||||||
|
const sortedAssignmentExams = assignmentExams.sort(sortByModule);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "INIT_EXAM",
|
type: "INIT_EXAM",
|
||||||
payload: {
|
payload: {
|
||||||
exams: assignmentExams.sort(sortByModule),
|
exams: sortedAssignmentExams,
|
||||||
modules: mapBy(assignmentExams.sort(sortByModule), "module"),
|
modules: mapBy(sortedAssignmentExams, "module"),
|
||||||
assignment,
|
assignment,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -144,12 +170,16 @@ export default function OfficialExam({
|
|||||||
[assignments]
|
[assignments]
|
||||||
);
|
);
|
||||||
|
|
||||||
const assignmentSessions = useMemo(
|
const assignmentSessions = useMemo(() => {
|
||||||
() =>
|
const studentAssignmentsIDs = mapBy(studentAssignments, "id");
|
||||||
sessions.filter((s) =>
|
return sessions.filter((s) =>
|
||||||
mapBy(studentAssignments, "id").includes(s.assignment?.id || "")
|
studentAssignmentsIDs.includes(s.assignment?.id || "")
|
||||||
),
|
);
|
||||||
[sessions, studentAssignments]
|
}, [sessions, studentAssignments]);
|
||||||
|
|
||||||
|
const entityLabels = useMemo(
|
||||||
|
() => mapBy(entities, "label")?.join(","),
|
||||||
|
[entities]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -167,7 +197,7 @@ export default function OfficialExam({
|
|||||||
<>
|
<>
|
||||||
{entities.length > 0 && (
|
{entities.length > 0 && (
|
||||||
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -191,20 +221,18 @@ export default function OfficialExam({
|
|||||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||||
{studentAssignments.length === 0 &&
|
{studentAssignments.length === 0 &&
|
||||||
"Assignments will appear here. It seems that for now there are no assignments for you."}
|
"Assignments will appear here. It seems that for now there are no assignments for you."}
|
||||||
{studentAssignments
|
{studentAssignments.map((a) => (
|
||||||
.sort((a, b) => moment(a.startDate).diff(b.startDate))
|
<AssignmentCard
|
||||||
.map((a) => (
|
key={a.id}
|
||||||
<AssignmentCard
|
assignment={a}
|
||||||
key={a.id}
|
user={user}
|
||||||
assignment={a}
|
session={assignmentSessions.find(
|
||||||
user={user}
|
(s) => s.assignment?.id === a.id
|
||||||
session={assignmentSessions.find(
|
)}
|
||||||
(s) => s.assignment?.id === a.id
|
startAssignment={startAssignment}
|
||||||
)}
|
resumeAssignment={loadSession}
|
||||||
startAssignment={startAssignment}
|
/>
|
||||||
resumeAssignment={loadSession}
|
))}
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { useListSearch } from "@/hooks/useListSearch";
|
|||||||
import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions";
|
import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { mapBy, redirect, serialize } from "@/utils";
|
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 { isAdmin } from "@/utils/users";
|
||||||
import { Entity, EntityWithRoles } from "@/interfaces/entity";
|
import { Entity, EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login")
|
||||||
|
|
||||||
const entityIDs = mapBy(user.entities, 'id')
|
const entityIDs = mapBy(user.entities, 'id')
|
||||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs)
|
|
||||||
|
|
||||||
const domain = user.email.split("@").pop()
|
const domain = user.email.split("@").pop()
|
||||||
const discounts = await db.collection<Discount>("discounts").find({ domain }).toArray()
|
const [entities, discounts, packages] = await Promise.all([
|
||||||
const packages = await db.collection<Package>("packages").find().toArray()
|
getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs),
|
||||||
|
db.collection<Discount>("discounts").find({ domain }).toArray(),
|
||||||
|
db.collection<Package>("packages").find().toArray(),
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({ user, entities, discounts, packages }),
|
props: serialize({ user, entities, discounts, packages }),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
|||||||
import { Permission, PermissionType } from "@/interfaces/permissions";
|
import { Permission, PermissionType } from "@/interfaces/permissions";
|
||||||
import { getPermissionDoc } from "@/utils/permissions.be";
|
import { getPermissionDoc } from "@/utils/permissions.be";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import { LayoutContext } from "@/components/High/Layout";
|
import { LayoutContext } from "@/components/High/Layout";
|
||||||
import { getUsers } from "@/utils/users.be";
|
import { getUsers } from "@/utils/users.be";
|
||||||
import { BsTrash } from "react-icons/bs";
|
import { BsTrash } from "react-icons/bs";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
@@ -18,6 +18,7 @@ import { Type as UserType } from "@/interfaces/user";
|
|||||||
import { getGroups } from "@/utils/groups.be";
|
import { getGroups } from "@/utils/groups.be";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { redirect } from "@/utils";
|
import { redirect } from "@/utils";
|
||||||
|
import { G } from "@react-pdf/renderer";
|
||||||
interface BasicUser {
|
interface BasicUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -40,31 +41,25 @@ export const getServerSideProps = withIronSessionSsr(
|
|||||||
if (!params?.id) return redirect("/permissions");
|
if (!params?.id) return redirect("/permissions");
|
||||||
|
|
||||||
// Fetch data from external API
|
// Fetch data from external API
|
||||||
const permission: Permission = await getPermissionDoc(params.id as string);
|
const [permission, users, groups] = await Promise.all([
|
||||||
|
getPermissionDoc(params.id as string),
|
||||||
const allUserData: User[] = await getUsers();
|
getUsers({}, 0, {}, { _id: 0, id: 1, name: 1, type: 1 }),
|
||||||
const groups = await getGroups();
|
getGroups(),
|
||||||
|
]);
|
||||||
|
|
||||||
const userGroups = groups.filter((x) => x.admin === user.id);
|
const userGroups = groups.filter((x) => x.admin === user.id);
|
||||||
|
const userGroupsParticipants = userGroups.flatMap((x) => x.participants);
|
||||||
const filteredGroups =
|
const filteredGroups =
|
||||||
user.type === "corporate"
|
user.type === "corporate"
|
||||||
? userGroups
|
? userGroups
|
||||||
: user.type === "mastercorporate"
|
: user.type === "mastercorporate"
|
||||||
? groups.filter((x) =>
|
? groups.filter((x) => userGroupsParticipants.includes(x.admin))
|
||||||
userGroups.flatMap((y) => y.participants).includes(x.admin)
|
|
||||||
)
|
|
||||||
: groups;
|
: groups;
|
||||||
|
const filteredGroupsParticipants = filteredGroups.flatMap(
|
||||||
const users = allUserData.map((u) => ({
|
(g) => g.participants
|
||||||
id: u.id,
|
);
|
||||||
name: u.name,
|
|
||||||
type: u.type,
|
|
||||||
})) as BasicUser[];
|
|
||||||
|
|
||||||
const filteredUsers = ["mastercorporate", "corporate"].includes(user.type)
|
const filteredUsers = ["mastercorporate", "corporate"].includes(user.type)
|
||||||
? users.filter((u) =>
|
? users.filter((u) => filteredGroupsParticipants.includes(u.id))
|
||||||
filteredGroups.flatMap((g) => g.participants).includes(u.id)
|
|
||||||
)
|
|
||||||
: users;
|
: users;
|
||||||
|
|
||||||
// const res = await fetch("api/permissions");
|
// const res = await fetch("api/permissions");
|
||||||
@@ -158,12 +153,14 @@ export default function Page(props: Props) {
|
|||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Select
|
<Select
|
||||||
value={null}
|
value={null}
|
||||||
options={users
|
options={users.reduce<{ label: string; value: string }[]>(
|
||||||
.filter((u) => !selectedUsers.includes(u.id))
|
(acc, u) => {
|
||||||
.map((u) => ({
|
if (!selectedUsers.includes(u.id))
|
||||||
label: `${u?.type}-${u?.name}`,
|
acc.push({ label: `${u?.type}-${u?.name}`, value: u.id });
|
||||||
value: u.id,
|
return acc;
|
||||||
}))}
|
},
|
||||||
|
[]
|
||||||
|
)}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
<Button onClick={update}>Update</Button>
|
<Button onClick={update}>Update</Button>
|
||||||
@@ -195,9 +192,8 @@ export default function Page(props: Props) {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h2>Whitelisted Users</h2>
|
<h2>Whitelisted Users</h2>
|
||||||
<div className="flex flex-col gap-3 flex-wrap">
|
<div className="flex flex-col gap-3 flex-wrap">
|
||||||
{users
|
{users.map((user) => {
|
||||||
.filter((user) => !selectedUsers.includes(user.id))
|
if (!selectedUsers.includes(user.id))
|
||||||
.map((user) => {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
|
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
|
||||||
@@ -208,7 +204,8 @@ export default function Page(props: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
return null;
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,23 +53,23 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
|||||||
if (!user) return redirect("/login");
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/");
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
const linkedCorporate = (await getUserCorporate(user.id)) || null;
|
const [linkedCorporate, groups, referralAgent] = await Promise.all([
|
||||||
const groups = (
|
getUserCorporate(user.id) || null,
|
||||||
await getParticipantGroups(user.id, { _id: 0, admin: 1 })
|
getParticipantGroups(user.id, { _id: 0, group: 1 }),
|
||||||
).map((group) => group.admin);
|
|
||||||
const referralAgent =
|
|
||||||
user.type === "corporate" && user.corporateInformation.referralAgent
|
user.type === "corporate" && user.corporateInformation.referralAgent
|
||||||
? await getUser(user.corporateInformation.referralAgent, {
|
? getUser(user.corporateInformation.referralAgent, {
|
||||||
_id: 0,
|
_id: 0,
|
||||||
name: 1,
|
name: 1,
|
||||||
email: 1,
|
email: 1,
|
||||||
demographicInformation: 1,
|
demographicInformation: 1,
|
||||||
})
|
})
|
||||||
: null;
|
: null,
|
||||||
|
]);
|
||||||
|
const groupsAdmin = groups.map((group) => group.admin);
|
||||||
|
|
||||||
const hasBenefitsFromUniversity =
|
const hasBenefitsFromUniversity =
|
||||||
(await countUsers({
|
(await countUsers({
|
||||||
id: { $in: groups },
|
id: { $in: groupsAdmin },
|
||||||
type: "corporate",
|
type: "corporate",
|
||||||
})) > 0;
|
})) > 0;
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
|
|||||||
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
import { findBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import { getGradingSystemByEntities, getGradingSystemByEntity } from "@/utils/grading.be";
|
import {
|
||||||
|
getGradingSystemByEntities,
|
||||||
|
} from "@/utils/grading.be";
|
||||||
import { Grading } from "@/interfaces";
|
import { Grading } from "@/interfaces";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import CardList from "@/components/High/CardList";
|
import CardList from "@/components/High/CardList";
|
||||||
@@ -33,208 +35,272 @@ import getPendingEvals from "@/utils/disabled.be";
|
|||||||
import useEvaluationPolling from "@/hooks/useEvaluationPolling";
|
import useEvaluationPolling from "@/hooks/useEvaluationPolling";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
|
|
||||||
const entityIDs = mapBy(user.entities, 'id')
|
const entityIDs = mapBy(user.entities, "id");
|
||||||
const isAdmin = checkAccess(user, ["admin", "developer"])
|
const isAdmin = checkAccess(user, ["admin", "developer"]);
|
||||||
|
|
||||||
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs)
|
const entities = await getEntitiesWithRoles(isAdmin ? undefined : entityIDs);
|
||||||
const entitiesIds = mapBy(entities, 'id')
|
const entitiesIds = mapBy(entities, "id");
|
||||||
const users = await (isAdmin ? getUsers() : getEntitiesUsers(entitiesIds))
|
const [users, assignments, gradingSystems, pendingSessionIds] =
|
||||||
const assignments = await (isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds))
|
await Promise.all([
|
||||||
const gradingSystems = await getGradingSystemByEntities(entitiesIds)
|
isAdmin ? getUsers() : getEntitiesUsers(entitiesIds),
|
||||||
const pendingSessionIds = await getPendingEvals(user.id);
|
isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds),
|
||||||
|
getGradingSystemByEntities(entitiesIds),
|
||||||
|
getPendingEvals(user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({ user, users, assignments, entities, gradingSystems, isAdmin, pendingSessionIds }),
|
props: serialize({
|
||||||
};
|
user,
|
||||||
|
users,
|
||||||
|
assignments,
|
||||||
|
entities,
|
||||||
|
gradingSystems,
|
||||||
|
isAdmin,
|
||||||
|
pendingSessionIds,
|
||||||
|
}),
|
||||||
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
type Filter = "months" | "weeks" | "days" | "assignments" | undefined;
|
type Filter = "months" | "weeks" | "days" | "assignments" | undefined;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
users: User[];
|
||||||
assignments: Assignment[];
|
assignments: Assignment[];
|
||||||
entities: EntityWithRoles[]
|
entities: EntityWithRoles[];
|
||||||
gradingSystems: Grading[]
|
gradingSystems: Grading[];
|
||||||
pendingSessionIds: string[];
|
pendingSessionIds: string[];
|
||||||
isAdmin:boolean
|
isAdmin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_TRAINING_EXAMS = 10;
|
const MAX_TRAINING_EXAMS = 10;
|
||||||
|
|
||||||
export default function History({ user, users, assignments, entities, gradingSystems, isAdmin, pendingSessionIds }: Props) {
|
export default function History({
|
||||||
const router = useRouter();
|
user,
|
||||||
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [
|
users,
|
||||||
state.selectedUser,
|
assignments,
|
||||||
state.setSelectedUser,
|
entities,
|
||||||
state.training,
|
gradingSystems,
|
||||||
state.setTraining,
|
isAdmin,
|
||||||
]);
|
pendingSessionIds,
|
||||||
|
}: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore(
|
||||||
|
(state) => [
|
||||||
|
state.selectedUser,
|
||||||
|
state.setSelectedUser,
|
||||||
|
state.training,
|
||||||
|
state.setTraining,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const [filter, setFilter] = useState<Filter>();
|
const [filter, setFilter] = useState<Filter>();
|
||||||
|
|
||||||
const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id);
|
const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<
|
||||||
const allowedDownloadEntities = useAllowedEntities(user, entities, 'download_student_record')
|
Stat[]
|
||||||
|
>(statsUserId || user?.id);
|
||||||
|
const allowedDownloadEntities = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"download_student_record"
|
||||||
|
);
|
||||||
|
|
||||||
const renderPdfIcon = usePDFDownload("stats");
|
const renderPdfIcon = usePDFDownload("stats");
|
||||||
|
|
||||||
const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>([]);
|
const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>(
|
||||||
const setTrainingStats = useTrainingContentStore((state) => state.setStats);
|
[]
|
||||||
|
);
|
||||||
|
const setTrainingStats = useTrainingContentStore((state) => state.setStats);
|
||||||
|
|
||||||
const groupedStats = useMemo(() => groupByDate(
|
const groupedStats = useMemo(
|
||||||
stats.filter((x) => {
|
() =>
|
||||||
if (
|
groupByDate(
|
||||||
(
|
stats.filter((x) => {
|
||||||
x.module === "writing" || x.module === "speaking") &&
|
if (
|
||||||
!x.isDisabled && Array.isArray(x.solutions) &&
|
(x.module === "writing" || x.module === "speaking") &&
|
||||||
!x.solutions.every((y) => Object.keys(y).includes("evaluation")
|
!x.isDisabled &&
|
||||||
)
|
Array.isArray(x.solutions) &&
|
||||||
)
|
!x.solutions.every((y) => Object.keys(y).includes("evaluation"))
|
||||||
return false;
|
)
|
||||||
return true;
|
return false;
|
||||||
}),
|
return true;
|
||||||
), [stats])
|
})
|
||||||
|
),
|
||||||
|
[stats]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]);
|
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRouteChange = (url: string) => {
|
const handleRouteChange = (url: string) => {
|
||||||
setTraining(false);
|
setTraining(false);
|
||||||
};
|
};
|
||||||
router.events.on("routeChangeStart", handleRouteChange);
|
router.events.on("routeChangeStart", handleRouteChange);
|
||||||
return () => {
|
return () => {
|
||||||
router.events.off("routeChangeStart", handleRouteChange);
|
router.events.off("routeChangeStart", handleRouteChange);
|
||||||
};
|
};
|
||||||
}, [router.events, setTraining]);
|
}, [router.events, setTraining]);
|
||||||
|
|
||||||
const filterStatsByDate = (stats: { [key: string]: Stat[] }) => {
|
const filterStatsByDate = (stats: { [key: string]: Stat[] }) => {
|
||||||
if (filter && filter !== "assignments") {
|
if (filter && filter !== "assignments") {
|
||||||
const filterDate = moment()
|
const filterDate = moment()
|
||||||
.subtract({ [filter as string]: 1 })
|
.subtract({ [filter as string]: 1 })
|
||||||
.format("x");
|
.format("x");
|
||||||
const filteredStats: { [key: string]: Stat[] } = {};
|
const filteredStats: { [key: string]: Stat[] } = {};
|
||||||
|
|
||||||
Object.keys(stats).forEach((timestamp) => {
|
Object.keys(stats).forEach((timestamp) => {
|
||||||
if (timestamp >= filterDate) filteredStats[timestamp] = stats[timestamp];
|
if (timestamp >= filterDate)
|
||||||
});
|
filteredStats[timestamp] = stats[timestamp];
|
||||||
return filteredStats;
|
});
|
||||||
}
|
return filteredStats;
|
||||||
|
}
|
||||||
|
|
||||||
if (filter && filter === "assignments") {
|
if (filter && filter === "assignments") {
|
||||||
const filteredStats: { [key: string]: Stat[] } = {};
|
const filteredStats: { [key: string]: Stat[] } = {};
|
||||||
|
|
||||||
Object.keys(stats).forEach((timestamp) => {
|
Object.keys(stats).forEach((timestamp) => {
|
||||||
if (stats[timestamp].map((s) => s.assignment === undefined).includes(false))
|
if (
|
||||||
filteredStats[timestamp] = [...stats[timestamp].filter((s) => !!s.assignment)];
|
stats[timestamp]
|
||||||
});
|
.map((s) => s.assignment === undefined)
|
||||||
|
.includes(false)
|
||||||
|
)
|
||||||
|
filteredStats[timestamp] = [
|
||||||
|
...stats[timestamp].filter((s) => !!s.assignment),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
return filteredStats;
|
return filteredStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTrainingContentSubmission = () => {
|
const handleTrainingContentSubmission = () => {
|
||||||
if (groupedStats) {
|
if (groupedStats) {
|
||||||
const groupedStatsByDate = filterStatsByDate(groupedStats);
|
const groupedStatsByDate = filterStatsByDate(groupedStats);
|
||||||
const allStats = Object.keys(groupedStatsByDate);
|
const allStats = Object.keys(groupedStatsByDate);
|
||||||
const selectedStats = selectedTrainingExams.reduce<Record<string, Stat[]>>((accumulator, moduleAndTimestamp) => {
|
const selectedStats = selectedTrainingExams.reduce<
|
||||||
const timestamp = moduleAndTimestamp.split("-")[1];
|
Record<string, Stat[]>
|
||||||
if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) {
|
>((accumulator, moduleAndTimestamp) => {
|
||||||
accumulator[timestamp] = groupedStatsByDate[timestamp];
|
const timestamp = moduleAndTimestamp.split("-")[1];
|
||||||
}
|
if (
|
||||||
return accumulator;
|
allStats.includes(timestamp) &&
|
||||||
}, {});
|
!accumulator.hasOwnProperty(timestamp)
|
||||||
setTrainingStats(Object.values(selectedStats).flat());
|
) {
|
||||||
router.push("/training");
|
accumulator[timestamp] = groupedStatsByDate[timestamp];
|
||||||
}
|
}
|
||||||
};
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
setTrainingStats(Object.values(selectedStats).flat());
|
||||||
|
router.push("/training");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredStats = useMemo(() =>
|
const filteredStats = useMemo(
|
||||||
Object.keys(filterStatsByDate(groupedStats))
|
() =>
|
||||||
.sort((a, b) => parseInt(b) - parseInt(a)),
|
Object.keys(filterStatsByDate(groupedStats)).sort(
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
(a, b) => parseInt(b) - parseInt(a)
|
||||||
[groupedStats, filter])
|
),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[groupedStats, filter]
|
||||||
|
);
|
||||||
|
|
||||||
const customContent = (timestamp: string) => {
|
const customContent = (timestamp: string) => {
|
||||||
const dateStats = groupedStats[timestamp];
|
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 (
|
return (
|
||||||
<StatsGridItem
|
<StatsGridItem
|
||||||
key={uuidv4()}
|
key={uuidv4()}
|
||||||
stats={dateStats}
|
stats={dateStats}
|
||||||
gradingSystems={gradingSystems}
|
gradingSystems={gradingSystems}
|
||||||
timestamp={timestamp}
|
timestamp={timestamp}
|
||||||
user={user}
|
user={user}
|
||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
users={users}
|
users={users}
|
||||||
training={training}
|
training={training}
|
||||||
selectedTrainingExams={selectedTrainingExams}
|
selectedTrainingExams={selectedTrainingExams}
|
||||||
setSelectedTrainingExams={setSelectedTrainingExams}
|
setSelectedTrainingExams={setSelectedTrainingExams}
|
||||||
maxTrainingExams={MAX_TRAINING_EXAMS}
|
maxTrainingExams={MAX_TRAINING_EXAMS}
|
||||||
renderPdfIcon={canDownload ? renderPdfIcon : undefined}
|
renderPdfIcon={canDownload ? renderPdfIcon : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEvaluationPolling(pendingSessionIds ? pendingSessionIds : [], "records", user.id);
|
useEvaluationPolling(
|
||||||
|
pendingSessionIds ? pendingSessionIds : [],
|
||||||
|
"records",
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Record | EnCoach</title>
|
<title>Record | EnCoach</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
<RecordFilter user={user} isAdmin={isAdmin} entities={entities} filterState={{ filter: filter, setFilter: setFilter }}>
|
<RecordFilter
|
||||||
{training && (
|
user={user}
|
||||||
<div className="flex flex-row">
|
isAdmin={isAdmin}
|
||||||
<div className="font-semibold text-2xl mr-4">
|
entities={entities}
|
||||||
Select up to 10 exercises
|
filterState={{ filter: filter, setFilter: setFilter }}
|
||||||
{`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}
|
>
|
||||||
</div>
|
{training && (
|
||||||
<button
|
<div className="flex flex-row">
|
||||||
className={clsx(
|
<div className="font-semibold text-2xl mr-4">
|
||||||
"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",
|
Select up to 10 exercises
|
||||||
"transition duration-300 ease-in-out",
|
{`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}
|
||||||
)}
|
</div>
|
||||||
disabled={selectedTrainingExams.length == 0}
|
<button
|
||||||
onClick={handleTrainingContentSubmission}>
|
className={clsx(
|
||||||
Submit
|
"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",
|
||||||
</button>
|
"transition duration-300 ease-in-out"
|
||||||
</div>
|
)}
|
||||||
)}
|
disabled={selectedTrainingExams.length == 0}
|
||||||
</RecordFilter>
|
onClick={handleTrainingContentSubmission}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</RecordFilter>
|
||||||
|
|
||||||
|
{filteredStats.length > 0 && !isStatsLoading && (
|
||||||
{filteredStats.length > 0 && !isStatsLoading && (
|
<CardList
|
||||||
<CardList list={filteredStats} renderCard={customContent} searchFields={[]} pageSize={30} className="lg:!grid-cols-3" />
|
list={filteredStats}
|
||||||
)}
|
renderCard={customContent}
|
||||||
{filteredStats.length === 0 && !isStatsLoading && (
|
searchFields={[]}
|
||||||
<span className="font-semibold ml-1">No record to display...</span>
|
pageSize={30}
|
||||||
)}
|
className="lg:!grid-cols-3"
|
||||||
{isStatsLoading && (
|
/>
|
||||||
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
|
)}
|
||||||
<span className="loading loading-infinity w-32 bg-mti-green-light" />
|
{filteredStats.length === 0 && !isStatsLoading && (
|
||||||
</div>
|
<span className="font-semibold ml-1">No record to display...</span>
|
||||||
)}
|
)}
|
||||||
</>
|
{isStatsLoading && (
|
||||||
)}
|
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
|
||||||
</>
|
<span className="loading loading-infinity w-32 bg-mti-green-light" />
|
||||||
);
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,169 +13,256 @@ import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import IconCard from "@/components/IconCard";
|
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 UserCreator from "./(admin)/UserCreator";
|
||||||
import CorporateGradingSystem from "./(admin)/CorporateGradingSystem";
|
import CorporateGradingSystem from "./(admin)/CorporateGradingSystem";
|
||||||
import { CEFR_STEPS } from "@/resources/grading";
|
import { CEFR_STEPS } from "@/resources/grading";
|
||||||
import { User } from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import { getUserPermissions } from "@/utils/permissions.be";
|
import { getUserPermissions } from "@/utils/permissions.be";
|
||||||
import { PermissionType } from "@/interfaces/permissions";
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
import { getUsers } from "@/utils/users.be";
|
import { getUsers } from "@/utils/users.be";
|
||||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
import { mapBy, serialize, redirect } from "@/utils";
|
import { mapBy, serialize, redirect } from "@/utils";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { isAdmin } from "@/utils/users";
|
import { isAdmin } from "@/utils/users";
|
||||||
import { getGradingSystemByEntities, getGradingSystemByEntity } from "@/utils/grading.be";
|
import {
|
||||||
|
getGradingSystemByEntities,
|
||||||
|
getGradingSystemByEntity,
|
||||||
|
} from "@/utils/grading.be";
|
||||||
import { Grading } from "@/interfaces";
|
import { Grading } from "@/interfaces";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]))
|
if (
|
||||||
return redirect("/")
|
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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const permissions = await getUserPermissions(user.id);
|
return {
|
||||||
const entities = isAdmin(user) ? await getEntitiesWithRoles() : await getEntitiesWithRoles(mapBy(user.entities, 'id'))
|
props: serialize({
|
||||||
const allUsers = await getUsers()
|
user,
|
||||||
const gradingSystems = await getGradingSystemByEntities(mapBy(entities, 'id'))
|
permissions,
|
||||||
const entitiesGrading = entities.map(e => gradingSystems.find(g => g.entity === e.id) || { entity: e.id, steps: CEFR_STEPS })
|
entities,
|
||||||
|
allUsers,
|
||||||
return {
|
entitiesGrading,
|
||||||
props: serialize({ user, permissions, entities, allUsers, entitiesGrading }),
|
}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
entities: EntityWithRoles[];
|
entities: EntityWithRoles[];
|
||||||
allUsers: User[]
|
allUsers: User[];
|
||||||
entitiesGrading: Grading[]
|
entitiesGrading: Grading[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Admin({ user, entities, permissions, allUsers, entitiesGrading }: Props) {
|
export default function Admin({
|
||||||
const [modalOpen, setModalOpen] = useState<string>();
|
user,
|
||||||
const router = useRouter()
|
entities,
|
||||||
|
permissions,
|
||||||
|
allUsers,
|
||||||
|
entitiesGrading,
|
||||||
|
}: Props) {
|
||||||
|
const [modalOpen, setModalOpen] = useState<string>();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const entitiesAllowCreateUser = useAllowedEntities(user, entities, 'create_user')
|
const entitiesAllowCreateUser = useAllowedEntities(
|
||||||
const entitiesAllowCreateUsers = useAllowedEntities(user, entities, 'create_user_batch')
|
user,
|
||||||
const entitiesAllowCreateCode = useAllowedEntities(user, entities, 'create_code')
|
entities,
|
||||||
const entitiesAllowCreateCodes = useAllowedEntities(user, entities, 'create_code_batch')
|
"create_user"
|
||||||
const entitiesAllowEditGrading = useAllowedEntities(user, entities, 'edit_grading_system')
|
);
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Settings Panel | EnCoach</title>
|
<title>Settings Panel | EnCoach</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<>
|
<>
|
||||||
<Modal isOpen={modalOpen === "batchCreateUser"} onClose={() => setModalOpen(undefined)} maxWidth="max-w-[85%]">
|
<Modal
|
||||||
<BatchCreateUser
|
isOpen={modalOpen === "batchCreateUser"}
|
||||||
user={user}
|
onClose={() => setModalOpen(undefined)}
|
||||||
entities={entitiesAllowCreateUser}
|
maxWidth="max-w-[85%]"
|
||||||
permissions={permissions}
|
>
|
||||||
onFinish={() => setModalOpen(undefined)}
|
<BatchCreateUser
|
||||||
/>
|
user={user}
|
||||||
</Modal>
|
entities={entitiesAllowCreateUser}
|
||||||
<Modal isOpen={modalOpen === "batchCreateCode"} onClose={() => setModalOpen(undefined)}>
|
permissions={permissions}
|
||||||
<BatchCodeGenerator
|
onFinish={() => setModalOpen(undefined)}
|
||||||
entities={entitiesAllowCreateCodes}
|
/>
|
||||||
user={user}
|
</Modal>
|
||||||
users={allUsers}
|
<Modal
|
||||||
permissions={permissions}
|
isOpen={modalOpen === "batchCreateCode"}
|
||||||
onFinish={() => setModalOpen(undefined)}
|
onClose={() => setModalOpen(undefined)}
|
||||||
/>
|
>
|
||||||
</Modal>
|
<BatchCodeGenerator
|
||||||
<Modal isOpen={modalOpen === "createCode"} onClose={() => setModalOpen(undefined)}>
|
entities={entitiesAllowCreateCodes}
|
||||||
<CodeGenerator
|
user={user}
|
||||||
entities={entitiesAllowCreateCode}
|
users={allUsers}
|
||||||
user={user}
|
permissions={permissions}
|
||||||
permissions={permissions}
|
onFinish={() => setModalOpen(undefined)}
|
||||||
onFinish={() => setModalOpen(undefined)}
|
/>
|
||||||
/>
|
</Modal>
|
||||||
</Modal>
|
<Modal
|
||||||
<Modal isOpen={modalOpen === "createUser"} onClose={() => setModalOpen(undefined)}>
|
isOpen={modalOpen === "createCode"}
|
||||||
<UserCreator
|
onClose={() => setModalOpen(undefined)}
|
||||||
user={user}
|
>
|
||||||
entities={entitiesAllowCreateUsers}
|
<CodeGenerator
|
||||||
users={allUsers}
|
entities={entitiesAllowCreateCode}
|
||||||
permissions={permissions}
|
user={user}
|
||||||
onFinish={() => setModalOpen(undefined)}
|
permissions={permissions}
|
||||||
/>
|
onFinish={() => setModalOpen(undefined)}
|
||||||
</Modal>
|
/>
|
||||||
<Modal isOpen={modalOpen === "gradingSystem"} onClose={() => setModalOpen(undefined)}>
|
</Modal>
|
||||||
<CorporateGradingSystem
|
<Modal
|
||||||
user={user}
|
isOpen={modalOpen === "createUser"}
|
||||||
entitiesGrading={entitiesGrading}
|
onClose={() => setModalOpen(undefined)}
|
||||||
entities={entitiesAllowEditGrading}
|
>
|
||||||
mutate={() => router.replace(router.asPath)}
|
<UserCreator
|
||||||
/>
|
user={user}
|
||||||
</Modal>
|
entities={entitiesAllowCreateUsers}
|
||||||
|
users={allUsers}
|
||||||
|
permissions={permissions}
|
||||||
|
onFinish={() => setModalOpen(undefined)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
isOpen={modalOpen === "gradingSystem"}
|
||||||
|
onClose={() => setModalOpen(undefined)}
|
||||||
|
>
|
||||||
|
<CorporateGradingSystem
|
||||||
|
user={user}
|
||||||
|
entitiesGrading={entitiesGrading}
|
||||||
|
entities={entitiesAllowEditGrading}
|
||||||
|
mutate={() => router.replace(router.asPath)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8">
|
<section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8">
|
||||||
<ExamLoader />
|
<ExamLoader />
|
||||||
{checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && (
|
{checkAccess(
|
||||||
<div className="w-full grid grid-cols-2 gap-4">
|
user,
|
||||||
<IconCard
|
getTypesOfUser(["teacher"]),
|
||||||
Icon={BsCode}
|
permissions,
|
||||||
label="Generate Single Code"
|
"viewCodes"
|
||||||
color="purple"
|
) && (
|
||||||
className="w-full h-full"
|
<div className="w-full grid grid-cols-2 gap-4">
|
||||||
onClick={() => setModalOpen("createCode")}
|
<IconCard
|
||||||
disabled={entitiesAllowCreateCode.length === 0}
|
Icon={BsCode}
|
||||||
/>
|
label="Generate Single Code"
|
||||||
<IconCard
|
color="purple"
|
||||||
Icon={BsCodeSquare}
|
className="w-full h-full"
|
||||||
label="Generate Codes in Batch"
|
onClick={() => setModalOpen("createCode")}
|
||||||
color="purple"
|
disabled={entitiesAllowCreateCode.length === 0}
|
||||||
className="w-full h-full"
|
/>
|
||||||
onClick={() => setModalOpen("batchCreateCode")}
|
<IconCard
|
||||||
disabled={entitiesAllowCreateCodes.length === 0}
|
Icon={BsCodeSquare}
|
||||||
/>
|
label="Generate Codes in Batch"
|
||||||
<IconCard
|
color="purple"
|
||||||
Icon={BsPersonFill}
|
className="w-full h-full"
|
||||||
label="Create Single User"
|
onClick={() => setModalOpen("batchCreateCode")}
|
||||||
color="purple"
|
disabled={entitiesAllowCreateCodes.length === 0}
|
||||||
className="w-full h-full"
|
/>
|
||||||
onClick={() => setModalOpen("createUser")}
|
<IconCard
|
||||||
disabled={entitiesAllowCreateUser.length === 0}
|
Icon={BsPersonFill}
|
||||||
/>
|
label="Create Single User"
|
||||||
<IconCard
|
color="purple"
|
||||||
Icon={BsPeopleFill}
|
className="w-full h-full"
|
||||||
label="Create Users in Batch"
|
onClick={() => setModalOpen("createUser")}
|
||||||
color="purple"
|
disabled={entitiesAllowCreateUser.length === 0}
|
||||||
className="w-full h-full"
|
/>
|
||||||
onClick={() => setModalOpen("batchCreateUser")}
|
<IconCard
|
||||||
disabled={entitiesAllowCreateUsers.length === 0}
|
Icon={BsPeopleFill}
|
||||||
/>
|
label="Create Users in Batch"
|
||||||
{checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && (
|
color="purple"
|
||||||
<IconCard
|
className="w-full h-full"
|
||||||
Icon={BsGearFill}
|
onClick={() => setModalOpen("batchCreateUser")}
|
||||||
label="Grading System"
|
disabled={entitiesAllowCreateUsers.length === 0}
|
||||||
color="purple"
|
/>
|
||||||
className="w-full h-full col-span-2"
|
{checkAccess(user, [
|
||||||
onClick={() => setModalOpen("gradingSystem")}
|
"admin",
|
||||||
disabled={entitiesAllowEditGrading.length === 0}
|
"corporate",
|
||||||
/>
|
"developer",
|
||||||
)}
|
"mastercorporate",
|
||||||
</div>
|
]) && (
|
||||||
)}
|
<IconCard
|
||||||
</section>
|
Icon={BsGearFill}
|
||||||
<section className="w-full">
|
label="Grading System"
|
||||||
<Lists user={user} entities={entities} permissions={permissions} />
|
color="purple"
|
||||||
</section>
|
className="w-full h-full col-span-2"
|
||||||
</>
|
onClick={() => setModalOpen("gradingSystem")}
|
||||||
</>
|
disabled={entitiesAllowEditGrading.length === 0}
|
||||||
);
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className="w-full">
|
||||||
|
<Lists user={user} entities={entities} permissions={permissions} />
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import { Session } from "@/hooks/useSessions";
|
|||||||
import { Entity, EntityWithRoles } from "@/interfaces/entity";
|
import { Entity, EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { Exam } from "@/interfaces/exam";
|
import { Exam } from "@/interfaces/exam";
|
||||||
import { Assignment, AssignmentResult } from "@/interfaces/results";
|
import { Assignment, AssignmentResult } from "@/interfaces/results";
|
||||||
import { StudentUser, User } from "@/interfaces/user";
|
import { StudentUser, User } from "@/interfaces/user";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils";
|
import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { getEntitiesAssignments } from "@/utils/assignments.be";
|
import { getEntitiesAssignments } from "@/utils/assignments.be";
|
||||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
import { getExamsByIds } from "@/utils/exams.be";
|
import { getExamsByIds } from "@/utils/exams.be";
|
||||||
import { findAllowedEntities } from "@/utils/permissions";
|
import { findAllowedEntities } from "@/utils/permissions";
|
||||||
import { getSessionsByAssignments } from "@/utils/sessions.be";
|
import { getSessionsByAssignments } from "@/utils/sessions.be";
|
||||||
import { isAdmin } from "@/utils/users";
|
import { isAdmin } from "@/utils/users";
|
||||||
import { getEntitiesUsers } from "@/utils/users.be";
|
import { getEntitiesUsers } from "@/utils/users.be";
|
||||||
@@ -28,278 +28,399 @@ import Head from "next/head";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import {
|
import { BsBank, BsChevronLeft, BsX } from "react-icons/bs";
|
||||||
BsBank,
|
|
||||||
BsChevronLeft,
|
|
||||||
BsX,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
students: StudentUser[];
|
students: StudentUser[];
|
||||||
entities: EntityWithRoles[];
|
entities: EntityWithRoles[];
|
||||||
assignments: Assignment[];
|
assignments: Assignment[];
|
||||||
sessions: Session[]
|
sessions: Session[];
|
||||||
exams: Exam[]
|
exams: Exam[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
const entityIDS = mapBy(user.entities, "id") || [];
|
const entityIDS = mapBy(user.entities, "id") || [];
|
||||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
|
const entities = await getEntitiesWithRoles(
|
||||||
const allowedEntities = findAllowedEntities(user, entities, 'view_entity_statistics')
|
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 studentsAllowedEntities = findAllowedEntities(
|
||||||
const students = await getEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" })
|
user,
|
||||||
|
entities,
|
||||||
|
"view_students"
|
||||||
|
);
|
||||||
|
|
||||||
|
const [students, assignments] = await Promise.all([
|
||||||
|
getEntitiesUsers(mapBy(studentsAllowedEntities, "id"), { type: "student" }),
|
||||||
|
getEntitiesAssignments(mapBy(entities, "id")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [sessions, exams] = await Promise.all([
|
||||||
|
getSessionsByAssignments(mapBy(assignments, "id")),
|
||||||
|
getExamsByIds(assignments.flatMap((a) => a.exams)),
|
||||||
|
]);
|
||||||
|
|
||||||
const assignments = await getEntitiesAssignments(mapBy(entities, "id"));
|
return {
|
||||||
const sessions = await getSessionsByAssignments(mapBy(assignments, 'id'))
|
props: serialize({
|
||||||
const exams = await getExamsByIds(assignments.flatMap(a => a.exams))
|
user,
|
||||||
|
students,
|
||||||
return { props: serialize({ user, students, entities: allowedEntities, assignments, sessions, exams }) };
|
entities: allowedEntities,
|
||||||
|
assignments,
|
||||||
|
sessions,
|
||||||
|
exams,
|
||||||
|
}),
|
||||||
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
student: StudentUser
|
student: StudentUser;
|
||||||
result?: AssignmentResult
|
result?: AssignmentResult;
|
||||||
assignment: Assignment
|
assignment: Assignment;
|
||||||
exams: Exam[]
|
exams: Exam[];
|
||||||
entity: Entity
|
entity: Entity;
|
||||||
session?: Session
|
session?: Session;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Item>();
|
const columnHelper = createColumnHelper<Item>();
|
||||||
|
|
||||||
export default function Statistical({ user, students, entities, assignments, sessions, exams }: Props) {
|
export default function Statistical({
|
||||||
const [startDate, setStartDate] = useState<Date>(new Date());
|
user,
|
||||||
const [endDate, setEndDate] = useState<Date | null>(moment().add(1, 'month').toDate());
|
students,
|
||||||
const [selectedEntities, setSelectedEntities] = useState<string[]>([])
|
entities,
|
||||||
const [isDownloading, setIsDownloading] = useState(false)
|
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 entitiesAllowDownload = useAllowedEntities(user, entities, 'download_statistics_report')
|
const entitiesAllowDownload = useAllowedEntities(
|
||||||
|
user,
|
||||||
|
entities,
|
||||||
|
"download_statistics_report"
|
||||||
|
);
|
||||||
|
|
||||||
const resetDateRange = () => {
|
const resetDateRange = () => {
|
||||||
const orderedAssignments = orderBy(assignments, ['startDate'], ['asc'])
|
const orderedAssignments = orderBy(assignments, ["startDate"], ["asc"]);
|
||||||
setStartDate(moment(orderedAssignments.shift()?.startDate || "2024-01-01T00:00:01.986Z").toDate())
|
setStartDate(
|
||||||
setEndDate(moment().add(1, 'month').toDate())
|
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 updateDateRange = (dates: [Date, Date | null]) => {
|
||||||
const [start, end] = dates;
|
const [start, end] = dates;
|
||||||
setStartDate(start!);
|
setStartDate(start!);
|
||||||
setEndDate(end);
|
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 renderAssignmentResolution = (entityID: string) => {
|
||||||
const entityAssignments = filterBy(assignments, 'entity', entityID)
|
const entityAssignments = filterBy(assignments, "entity", entityID);
|
||||||
const total = entityAssignments.reduce((acc, curr) => acc + curr.assignees.length, 0)
|
const total = entityAssignments.reduce(
|
||||||
const results = entityAssignments.reduce((acc, curr) => acc + curr.results.length, 0)
|
(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 totalAssignmentResolution = useMemo(() => {
|
||||||
const total = assignments.reduce((acc, curr) => acc + curr.assignees.length, 0)
|
const total = assignments.reduce(
|
||||||
const results = assignments.reduce((acc, curr) => acc + curr.results.length, 0)
|
(acc, curr) => acc + curr.assignees.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const results = assignments.reduce(
|
||||||
|
(acc, curr) => acc + curr.results.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
return { results, total }
|
return { results, total };
|
||||||
}, [assignments])
|
}, [assignments]);
|
||||||
|
|
||||||
const filteredAssignments = useMemo(() => {
|
const filteredAssignments = useMemo(() => {
|
||||||
if (!startDate && !endDate) return assignments
|
if (!startDate && !endDate) return assignments;
|
||||||
const startDateFiltered = startDate ? assignments.filter(a => moment(a.startDate).isSameOrAfter(moment(startDate))) : assignments
|
const startDateFiltered = startDate
|
||||||
return endDate ? startDateFiltered.filter(a => moment(a.endDate).isSameOrBefore(moment(endDate))) : startDateFiltered
|
? assignments.filter((a) =>
|
||||||
}, [startDate, endDate, assignments])
|
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(() =>
|
const data: Item[] = useMemo(
|
||||||
filteredAssignments.filter(a => selectedEntities.includes(a.entity || "")).flatMap(a => a.assignees.map(x => {
|
() =>
|
||||||
const result = findBy(a.results, 'user', x)
|
filteredAssignments
|
||||||
const student = findBy(students, 'id', x)
|
.filter((a) => selectedEntities.includes(a.entity || ""))
|
||||||
const entity = findBy(entities, 'id', a.entity)
|
.flatMap((a) =>
|
||||||
const assignmentExams = exams.filter(e => a.exams.map(x => `${x.id}_${x.module}`).includes(`${e.id}_${e.module}`))
|
a.assignees.map((x) => {
|
||||||
const session = sessions.find(s => s.assignment?.id === a.id && s.user === 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
|
if (!student) return undefined;
|
||||||
return { student, result, assignment: a, exams: assignmentExams, session, entity }
|
return {
|
||||||
})).filter(x => !!x) as Item[],
|
student,
|
||||||
[students, selectedEntities, filteredAssignments, exams, sessions, entities]
|
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 sortedData: Item[] = useMemo(
|
||||||
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
|
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
|
return bTotalScore - aTotalScore;
|
||||||
}), [data])
|
}),
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
const downloadExcel = async () => {
|
const downloadExcel = async () => {
|
||||||
setIsDownloading(true)
|
setIsDownloading(true);
|
||||||
|
|
||||||
const request = await axios.post("/api/statistical", {
|
const request = await axios.post(
|
||||||
entities: entities.filter(e => selectedEntities.includes(e.id)),
|
"/api/statistical",
|
||||||
items: data,
|
{
|
||||||
assignments: filteredAssignments,
|
entities: entities.filter((e) => selectedEntities.includes(e.id)),
|
||||||
startDate,
|
items: data,
|
||||||
endDate
|
assignments: filteredAssignments,
|
||||||
}, {
|
startDate,
|
||||||
responseType: 'blob'
|
endDate,
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
responseType: "blob",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const href = URL.createObjectURL(request.data)
|
const href = URL.createObjectURL(request.data);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement("a");
|
||||||
link.href = href;
|
link.href = href;
|
||||||
link.setAttribute('download', `statistical_${new Date().toISOString()}.xlsx`);
|
link.setAttribute(
|
||||||
document.body.appendChild(link);
|
"download",
|
||||||
link.click();
|
`statistical_${new Date().toISOString()}.xlsx`
|
||||||
|
);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
URL.revokeObjectURL(href);
|
URL.revokeObjectURL(href);
|
||||||
|
|
||||||
setIsDownloading(false)
|
setIsDownloading(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
columnHelper.accessor("student.name", {
|
columnHelper.accessor("student.name", {
|
||||||
header: "Student",
|
header: "Student",
|
||||||
cell: (info) => info.getValue(),
|
cell: (info) => info.getValue(),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("student.studentID", {
|
columnHelper.accessor("student.studentID", {
|
||||||
header: "Student ID",
|
header: "Student ID",
|
||||||
cell: (info) => info.getValue(),
|
cell: (info) => info.getValue(),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("student.email", {
|
columnHelper.accessor("student.email", {
|
||||||
header: "E-mail",
|
header: "E-mail",
|
||||||
cell: (info) => info.getValue(),
|
cell: (info) => info.getValue(),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("entity.label", {
|
columnHelper.accessor("entity.label", {
|
||||||
header: "Entity",
|
header: "Entity",
|
||||||
cell: (info) => info.getValue(),
|
cell: (info) => info.getValue(),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("assignment.name", {
|
columnHelper.accessor("assignment.name", {
|
||||||
header: "Assignment",
|
header: "Assignment",
|
||||||
cell: (info) => info.getValue(),
|
cell: (info) => info.getValue(),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("assignment.startDate", {
|
columnHelper.accessor("assignment.startDate", {
|
||||||
header: "Date",
|
header: "Date",
|
||||||
cell: (info) => moment(info.getValue()).format("DD/MM/YYYY"),
|
cell: (info) => moment(info.getValue()).format("DD/MM/YYYY"),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("result", {
|
columnHelper.accessor("result", {
|
||||||
header: "Progress",
|
header: "Progress",
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
const student = info.row.original.student
|
const student = info.row.original.student;
|
||||||
const session = info.row.original.session
|
const session = info.row.original.session;
|
||||||
|
|
||||||
if (!student.lastLogin) return <span className="text-mti-red-dark">Never logged in</span>
|
if (!student.lastLogin)
|
||||||
if (info.getValue()) return <span className="text-mti-green font-semibold">Submitted</span>
|
return <span className="text-mti-red-dark">Never logged in</span>;
|
||||||
if (!session) return <span className="text-mti-rose">Not started</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">
|
return (
|
||||||
{capitalize(session.exam?.module || "")} Module, Part {session.partIndex + 1}, Exercise {session.exerciseIndex + 1}
|
<span className="font-semibold">
|
||||||
</span>
|
{capitalize(session.exam?.module || "")} Module, Part{" "}
|
||||||
},
|
{session.partIndex + 1}, Exercise {session.exerciseIndex + 1}
|
||||||
})
|
</span>
|
||||||
]
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Statistical | EnCoach</title>
|
<title>Statistical | EnCoach</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link href="/dashboard" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
<Link
|
||||||
<BsChevronLeft />
|
href="/dashboard"
|
||||||
</Link>
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
|
||||||
<h2 className="font-bold text-2xl">Statistical</h2>
|
>
|
||||||
</div>
|
<BsChevronLeft />
|
||||||
<Checkbox
|
</Link>
|
||||||
onChange={value => setSelectedEntities(value ? mapBy(entities, 'id') : [])}
|
<h2 className="font-bold text-2xl">Statistical</h2>
|
||||||
isChecked={selectedEntities.length === entities.length}
|
</div>
|
||||||
>
|
<Checkbox
|
||||||
Select All
|
onChange={(value) =>
|
||||||
</Checkbox>
|
setSelectedEntities(value ? mapBy(entities, "id") : [])
|
||||||
</div>
|
}
|
||||||
<Separator />
|
isChecked={selectedEntities.length === entities.length}
|
||||||
</div>
|
>
|
||||||
|
Select All
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
<div className="w-full flex items-center justify-between gap-4 flex-wrap">
|
<div className="w-full flex items-center justify-between gap-4 flex-wrap">
|
||||||
{entities.map(entity => (
|
{entities.map((entity) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleEntity(entity.id)}
|
onClick={() => toggleEntity(entity.id)}
|
||||||
className={clsx(
|
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",
|
"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",
|
"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}
|
)}
|
||||||
>
|
key={entity.id}
|
||||||
<BsBank size={48} />
|
>
|
||||||
<div className="flex flex-col gap-1">
|
<BsBank size={48} />
|
||||||
<span>{entity.label}</span>
|
<div className="flex flex-col gap-1">
|
||||||
<span className={clsx("font-semibold")}>
|
<span>{entity.label}</span>
|
||||||
{renderAssignmentResolution(entity.id)}
|
<span className={clsx("font-semibold")}>
|
||||||
</span>
|
{renderAssignmentResolution(entity.id)}
|
||||||
</div>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
))}
|
</button>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ReactDatePicker
|
<ReactDatePicker
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-6 px-12 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"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",
|
"hover:border-mti-purple tooltip",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out"
|
||||||
)}
|
)}
|
||||||
dateFormat="dd/MM/yyyy"
|
dateFormat="dd/MM/yyyy"
|
||||||
selectsRange
|
selectsRange
|
||||||
selected={startDate}
|
selected={startDate}
|
||||||
onChange={updateDateRange}
|
onChange={updateDateRange}
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
endDate={endDate}
|
endDate={endDate}
|
||||||
/>
|
/>
|
||||||
{startDate !== null && endDate !== null && (
|
{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
|
||||||
<BsX size={24} />
|
onClick={resetDateRange}
|
||||||
</button>
|
className="transition ease-in-out duration-300 rounded-full p-2 hover:bg-mti-gray-cool/10"
|
||||||
)}
|
>
|
||||||
</div>
|
<BsX size={24} />
|
||||||
<span className="font-semibold text-lg pr-1">
|
</button>
|
||||||
Total: {totalAssignmentResolution.results} / {totalAssignmentResolution.total}
|
)}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<span className="font-semibold text-lg pr-1">
|
||||||
</section>
|
Total: {totalAssignmentResolution.results} /{" "}
|
||||||
|
{totalAssignmentResolution.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{selectedEntities.length > 0 && (
|
{selectedEntities.length > 0 && (
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={sortedData}
|
data={sortedData}
|
||||||
searchFields={[["student", "name"], ["student", "email"], ["student", "studentID"], ["exams", "id"], ["assignment", "name"]]}
|
searchFields={[
|
||||||
searchPlaceholder="Search by student, assignment or exam..."
|
["student", "name"],
|
||||||
onDownload={entitiesAllowDownload.length > 0 ? downloadExcel : undefined}
|
["student", "email"],
|
||||||
isDownloadLoading={isDownloading}
|
["student", "studentID"],
|
||||||
/>
|
["exams", "id"],
|
||||||
)}
|
["assignment", "name"],
|
||||||
</>
|
]}
|
||||||
</>
|
searchPlaceholder="Search by student, assignment or exam..."
|
||||||
)
|
onDownload={
|
||||||
|
entitiesAllowDownload.length > 0 ? downloadExcel : undefined
|
||||||
|
}
|
||||||
|
isDownloadLoading={isDownloading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,10 +32,7 @@ import { capitalize } from "lodash";
|
|||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import { calculateBandScore } from "@/utils/score";
|
import { calculateBandScore } from "@/utils/score";
|
||||||
import {
|
import { MODULE_ARRAY, sortByModule } from "@/utils/moduleUtils";
|
||||||
MODULE_ARRAY,
|
|
||||||
sortByModule,
|
|
||||||
} from "@/utils/moduleUtils";
|
|
||||||
import { Chart } from "react-chartjs-2";
|
import { Chart } from "react-chartjs-2";
|
||||||
import DatePicker from "react-datepicker";
|
import DatePicker from "react-datepicker";
|
||||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
@@ -45,7 +42,6 @@ import { Stat, User } from "@/interfaces/user";
|
|||||||
import { Divider } from "primereact/divider";
|
import { Divider } from "primereact/divider";
|
||||||
import Badge from "@/components/Low/Badge";
|
import Badge from "@/components/Low/Badge";
|
||||||
import { mapBy, redirect, serialize } from "@/utils";
|
import { mapBy, redirect, serialize } from "@/utils";
|
||||||
import { getEntities } from "@/utils/entities.be";
|
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import { checkAccess } from "@/utils/permissions";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
@@ -69,19 +65,10 @@ const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"];
|
|||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res);
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login");
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/");
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
|
|
||||||
const entityIDs = mapBy(user.entities, "id");
|
|
||||||
const isAdmin = checkAccess(user, ["admin", "developer"]);
|
const isAdmin = checkAccess(user, ["admin", "developer"]);
|
||||||
|
|
||||||
const entities = await getEntities(isAdmin ? undefined : entityIDs, {
|
|
||||||
id: 1,
|
|
||||||
label: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({ user, entities, isAdmin }),
|
props: serialize({ user, isAdmin }),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -1,225 +1,269 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import {ToastContainer} from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {FaPlus} from "react-icons/fa";
|
import { FaPlus } from "react-icons/fa";
|
||||||
import useRecordStore from "@/stores/recordStore";
|
import useRecordStore from "@/stores/recordStore";
|
||||||
import router from "next/router";
|
import router from "next/router";
|
||||||
import useTrainingContentStore from "@/stores/trainingContentStore";
|
import useTrainingContentStore from "@/stores/trainingContentStore";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {ITrainingContent} from "@/training/TrainingInterfaces";
|
import { ITrainingContent } from "@/training/TrainingInterfaces";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {uuidv4} from "@firebase/util";
|
import { uuidv4 } from "@firebase/util";
|
||||||
import TrainingScore from "@/training/TrainingScore";
|
import TrainingScore from "@/training/TrainingScore";
|
||||||
import ModuleBadge from "@/components/ModuleBadge";
|
import ModuleBadge from "@/components/ModuleBadge";
|
||||||
import RecordFilter from "@/components/Medium/RecordFilter";
|
import RecordFilter from "@/components/Medium/RecordFilter";
|
||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import { mapBy, redirect, serialize } from "@/utils";
|
import { mapBy, redirect, serialize } from "@/utils";
|
||||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
|
||||||
import { getEntitiesUsers } from "@/utils/users.be";
|
import { getEntitiesUsers } from "@/utils/users.be";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { checkAccess } from "../../utils/permissions";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) return redirect("/")
|
if (shouldRedirectHome(user)) return redirect("/");
|
||||||
|
const isAdmin = checkAccess(user, ["admin", "developer"]);
|
||||||
|
const entityIDs = mapBy(user.entities, "id");
|
||||||
|
const users = await getEntitiesUsers(entityIDs);
|
||||||
|
|
||||||
const entityIDs = mapBy(user.entities, 'id')
|
return {
|
||||||
const entities = await getEntitiesWithRoles(entityIDs)
|
props: serialize({ user, users, isAdmin }),
|
||||||
const users = await getEntitiesUsers(entityIDs)
|
};
|
||||||
|
|
||||||
return {
|
|
||||||
props: serialize({user, users, entities}),
|
|
||||||
};
|
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[] }> = ({user, entities, users}) => {
|
const Training: React.FC<{
|
||||||
const [recordUserId, setRecordTraining] = useRecordStore((state) => [state.selectedUser, state.setTraining]);
|
user: User;
|
||||||
const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">();
|
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 [stats, setTrainingStats] = useTrainingContentStore((state) => [
|
||||||
const [isNewContentLoading, setIsNewContentLoading] = useState(stats.length != 0);
|
state.stats,
|
||||||
const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{[key: string]: ITrainingContent}>();
|
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 } =
|
||||||
recordUserId || user?.id,
|
useFilterRecordsByUser<ITrainingContent[]>(
|
||||||
undefined,
|
recordUserId || user?.id,
|
||||||
"training",
|
undefined,
|
||||||
);
|
"training"
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRouteChange = (url: string) => {
|
const handleRouteChange = (url: string) => {
|
||||||
setTrainingStats([]);
|
setTrainingStats([]);
|
||||||
};
|
};
|
||||||
router.events.on("routeChangeStart", handleRouteChange);
|
router.events.on("routeChangeStart", handleRouteChange);
|
||||||
return () => {
|
return () => {
|
||||||
router.events.off("routeChangeStart", handleRouteChange);
|
router.events.off("routeChangeStart", handleRouteChange);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [router.events, setTrainingStats]);
|
}, [router.events, setTrainingStats]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const postStats = async () => {
|
const postStats = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<{id: string}>(`/api/training`, {userID: user.id, stats: stats});
|
const response = await axios.post<{ id: string }>(`/api/training`, {
|
||||||
return response.data.id;
|
userID: user.id,
|
||||||
} catch (error) {
|
stats: stats,
|
||||||
setIsNewContentLoading(false);
|
});
|
||||||
}
|
return response.data.id;
|
||||||
};
|
} catch (error) {
|
||||||
|
setIsNewContentLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isNewContentLoading) {
|
if (isNewContentLoading) {
|
||||||
postStats().then((id) => {
|
postStats().then((id) => {
|
||||||
setTrainingStats([]);
|
setTrainingStats([]);
|
||||||
if (id) {
|
if (id) {
|
||||||
router.push(`/training/${id}`);
|
router.push(`/training/${id}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isNewContentLoading]);
|
}, [isNewContentLoading]);
|
||||||
|
|
||||||
const handleNewTrainingContent = () => {
|
const handleNewTrainingContent = () => {
|
||||||
setRecordTraining(true);
|
setRecordTraining(true);
|
||||||
router.push("/record");
|
router.push("/record");
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterTrainingContentByDate = (trainingContent: {[key: string]: ITrainingContent}) => {
|
const filterTrainingContentByDate = (trainingContent: {
|
||||||
if (filter) {
|
[key: string]: ITrainingContent;
|
||||||
const filterDate = moment()
|
}) => {
|
||||||
.subtract({[filter as string]: 1})
|
if (filter) {
|
||||||
.format("x");
|
const filterDate = moment()
|
||||||
const filteredTrainingContent: {[key: string]: ITrainingContent} = {};
|
.subtract({ [filter as string]: 1 })
|
||||||
|
.format("x");
|
||||||
|
const filteredTrainingContent: { [key: string]: ITrainingContent } = {};
|
||||||
|
|
||||||
Object.keys(trainingContent).forEach((timestamp) => {
|
Object.keys(trainingContent).forEach((timestamp) => {
|
||||||
if (timestamp >= filterDate) filteredTrainingContent[timestamp] = trainingContent[timestamp];
|
if (timestamp >= filterDate)
|
||||||
});
|
filteredTrainingContent[timestamp] = trainingContent[timestamp];
|
||||||
return filteredTrainingContent;
|
});
|
||||||
}
|
return filteredTrainingContent;
|
||||||
return trainingContent;
|
}
|
||||||
};
|
return trainingContent;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (trainingContent.length > 0) {
|
if (trainingContent.length > 0) {
|
||||||
const grouped = trainingContent.reduce((acc, content) => {
|
const grouped = trainingContent.reduce((acc, content) => {
|
||||||
acc[content.created_at] = content;
|
acc[content.created_at] = content;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as {[key: number]: ITrainingContent});
|
}, {} as { [key: number]: ITrainingContent });
|
||||||
|
|
||||||
setGroupedByTrainingContent(grouped);
|
setGroupedByTrainingContent(grouped);
|
||||||
} else {
|
} else {
|
||||||
setGroupedByTrainingContent(undefined);
|
setGroupedByTrainingContent(undefined);
|
||||||
}
|
}
|
||||||
}, [trainingContent]);
|
}, [trainingContent]);
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) => {
|
const formatTimestamp = (timestamp: string) => {
|
||||||
const date = moment(parseInt(timestamp));
|
const date = moment(parseInt(timestamp));
|
||||||
const formatter = "YYYY/MM/DD - HH:mm";
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
|
|
||||||
return date.format(formatter);
|
return date.format(formatter);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectTrainingContent = (trainingContent: ITrainingContent) => {
|
const selectTrainingContent = (trainingContent: ITrainingContent) => {
|
||||||
router.push(`/training/${trainingContent.id}`);
|
router.push(`/training/${trainingContent.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const trainingContentContainer = (timestamp: string) => {
|
const trainingContentContainer = (timestamp: string) => {
|
||||||
if (!groupedByTrainingContent) return <></>;
|
if (!groupedByTrainingContent) return <></>;
|
||||||
|
|
||||||
const trainingContent: ITrainingContent = groupedByTrainingContent[timestamp];
|
const trainingContent: ITrainingContent =
|
||||||
const uniqueModules = [...new Set(trainingContent.exams.map((exam) => exam.module))];
|
groupedByTrainingContent[timestamp];
|
||||||
|
const uniqueModules = [
|
||||||
|
...new Set(trainingContent.exams.map((exam) => exam.module)),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
key={uuidv4()}
|
key={uuidv4()}
|
||||||
className={clsx(
|
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)}
|
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">
|
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
||||||
<span className="font-medium">{formatTimestamp(timestamp)}</span>
|
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
|
||||||
</div>
|
<span className="font-medium">{formatTimestamp(timestamp)}</span>
|
||||||
<div className="flex flex-col gap-2">
|
</div>
|
||||||
<div className="w-full flex flex-row gap-1">
|
<div className="flex flex-col gap-2">
|
||||||
{uniqueModules.map((module) => (
|
<div className="w-full flex flex-row gap-1">
|
||||||
<ModuleBadge key={module} module={module} />
|
{uniqueModules.map((module) => (
|
||||||
))}
|
<ModuleBadge key={module} module={module} />
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TrainingScore trainingContent={trainingContent} gridView={true} />
|
</div>
|
||||||
</div>
|
<TrainingScore trainingContent={trainingContent} gridView={true} />
|
||||||
</>
|
</div>
|
||||||
);
|
</>
|
||||||
};
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Training | EnCoach</title>
|
<title>Training | EnCoach</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
|
||||||
<>
|
<>
|
||||||
{isNewContentLoading || areRecordsLoading ? (
|
{isNewContentLoading || areRecordsLoading ? (
|
||||||
<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">
|
<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" />
|
<span className="loading loading-infinity w-32 bg-mti-green-light" />
|
||||||
{isNewContentLoading && (
|
{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...
|
||||||
</div>
|
</span>
|
||||||
) : (
|
)}
|
||||||
<>
|
</div>
|
||||||
<RecordFilter entities={entities} user={user} filterState={{filter: filter, setFilter: setFilter}} assignments={false}>
|
) : (
|
||||||
{user.type === "student" && (
|
<>
|
||||||
<>
|
<RecordFilter
|
||||||
<div className="flex items-center">
|
entities={entities}
|
||||||
<div className="font-semibold text-2xl">Generate New Training Material</div>
|
user={user}
|
||||||
<button
|
isAdmin={isAdmin}
|
||||||
className={clsx(
|
filterState={{ filter: filter, setFilter: setFilter }}
|
||||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
|
assignments={false}
|
||||||
"transition duration-300 ease-in-out",
|
>
|
||||||
)}
|
{user.type === "student" && (
|
||||||
onClick={handleNewTrainingContent}>
|
<>
|
||||||
<FaPlus />
|
<div className="flex items-center">
|
||||||
</button>
|
<div className="font-semibold text-2xl">
|
||||||
</div>
|
Generate New Training Material
|
||||||
</>
|
</div>
|
||||||
)}
|
<button
|
||||||
</RecordFilter>
|
className={clsx(
|
||||||
{trainingContent.length == 0 && (
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
|
||||||
<div className="flex flex-grow justify-center items-center">
|
"transition duration-300 ease-in-out"
|
||||||
<span className="font-semibold ml-1">No training content to display...</span>
|
)}
|
||||||
</div>
|
onClick={handleNewTrainingContent}
|
||||||
)}
|
>
|
||||||
{!areRecordsLoading && groupedByTrainingContent && Object.keys(groupedByTrainingContent).length > 0 && (
|
<FaPlus />
|
||||||
<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">
|
</button>
|
||||||
{Object.keys(filterTrainingContentByDate(groupedByTrainingContent))
|
</div>
|
||||||
.sort((a, b) => parseInt(b) - parseInt(a))
|
</>
|
||||||
.map(trainingContentContainer)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
);
|
</div>
|
||||||
|
)}
|
||||||
|
{!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)
|
||||||
|
)
|
||||||
|
.sort((a, b) => parseInt(b) - parseInt(a))
|
||||||
|
.map(trainingContentContainer)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Training;
|
export default Training;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import { Group, Stat, StudentUser, User } from "@/interfaces/user";
|
import { Group, Stat, StudentUser, User } from "@/interfaces/user";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { BsChevronLeft } from "react-icons/bs";
|
import { BsChevronLeft } from "react-icons/bs";
|
||||||
import { mapBy, serialize } from "@/utils";
|
import { mapBy, serialize } from "@/utils";
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
|
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
||||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
import { Entity } from "@/interfaces/entity";
|
import { Entity } from "@/interfaces/entity";
|
||||||
import { getParticipantsGroups } from "@/utils/groups.be";
|
import { getParticipantsGroups } from "@/utils/groups.be";
|
||||||
import StudentPerformanceList from "../(admin)/Lists/StudentPerformanceList";
|
import StudentPerformanceList from "../(admin)/Lists/StudentPerformanceList";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
@@ -17,73 +17,84 @@ import { requestUser } from "@/utils/api";
|
|||||||
import { redirect } from "@/utils";
|
import { redirect } from "@/utils";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res);
|
||||||
if (!user) return redirect("/login")
|
if (!user) return redirect("/login");
|
||||||
|
|
||||||
const entityIDs = mapBy(user.entities, 'id')
|
const entityIDs = mapBy(user.entities, "id");
|
||||||
|
|
||||||
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", 'developer']) ? undefined : entityIDs)
|
const entities = await getEntitiesWithRoles(
|
||||||
const allowedEntities = findAllowedEntities(user, entities, "view_student_performance")
|
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'])
|
const students = await (checkAccess(user, ["admin", "developer"])
|
||||||
? getUsers({ type: 'student' })
|
? getUsers({ type: "student" })
|
||||||
: getEntitiesUsers(mapBy(allowedEntities, 'id'), { type: 'student' })
|
: getEntitiesUsers(mapBy(allowedEntities, "id"), { type: "student" }));
|
||||||
)
|
const groups = await getParticipantsGroups(mapBy(students, "id"));
|
||||||
const groups = await getParticipantsGroups(mapBy(students, 'id'))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: serialize({ user, students, entities, groups }),
|
props: serialize({ user, students, entities, groups }),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
students: StudentUser[]
|
students: StudentUser[];
|
||||||
entities: Entity[]
|
entities: Entity[];
|
||||||
groups: Group[]
|
groups: Group[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const StudentPerformance = ({ user, students, entities, groups }: Props) => {
|
const StudentPerformance = ({ user, students, entities, groups }: Props) => {
|
||||||
const { data: stats } = useFilterRecordsByUser<Stat[]>();
|
const { data: stats } = useFilterRecordsByUser<Stat[]>();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const performanceStudents = students.map((u) => ({
|
const performanceStudents = students.map((u) => ({
|
||||||
...u,
|
...u,
|
||||||
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>EnCoach</title>
|
<title>EnCoach</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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>
|
<BsChevronLeft />
|
||||||
<h2 className="font-bold text-2xl">Student Performance ({students.length})</h2>
|
</button>
|
||||||
</div>
|
<h2 className="font-bold text-2xl">
|
||||||
<StudentPerformanceList items={performanceStudents} stats={stats} />
|
Student Performance ({students.length})
|
||||||
</>
|
</h2>
|
||||||
</>
|
</div>
|
||||||
);
|
<StudentPerformanceList items={performanceStudents} stats={stats} />
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default StudentPerformance;
|
export default StudentPerformance;
|
||||||
|
|||||||
@@ -1,71 +1,77 @@
|
|||||||
export type RolePermission =
|
export type RolePermission =
|
||||||
"view_students" |
|
| "view_students"
|
||||||
"view_teachers" |
|
| "view_teachers"
|
||||||
"view_corporates" |
|
| "view_corporates"
|
||||||
"view_mastercorporates" |
|
| "view_mastercorporates"
|
||||||
"edit_students" |
|
| "edit_students"
|
||||||
"edit_teachers" |
|
| "edit_teachers"
|
||||||
"edit_corporates" |
|
| "edit_corporates"
|
||||||
"edit_mastercorporates" |
|
| "edit_mastercorporates"
|
||||||
"delete_students" |
|
| "delete_students"
|
||||||
"delete_teachers" |
|
| "delete_teachers"
|
||||||
"delete_corporates" |
|
| "delete_corporates"
|
||||||
"delete_mastercorporates" |
|
| "delete_mastercorporates"
|
||||||
"generate_reading" |
|
| "generate_reading"
|
||||||
"view_reading" |
|
| "view_reading"
|
||||||
"delete_reading" |
|
| "delete_reading"
|
||||||
"generate_listening" |
|
| "generate_listening"
|
||||||
"view_listening" |
|
| "view_listening"
|
||||||
"delete_listening" |
|
| "delete_listening"
|
||||||
"generate_writing" |
|
| "generate_writing"
|
||||||
"view_writing" |
|
| "view_writing"
|
||||||
"delete_writing" |
|
| "delete_writing"
|
||||||
"generate_speaking" |
|
| "generate_speaking"
|
||||||
"view_speaking" |
|
| "view_speaking"
|
||||||
"delete_speaking" |
|
| "delete_speaking"
|
||||||
"generate_level" |
|
| "generate_level"
|
||||||
"view_level" |
|
| "view_level"
|
||||||
"delete_level" |
|
| "delete_level"
|
||||||
"view_classrooms" |
|
| "view_classrooms"
|
||||||
"create_classroom" |
|
| "create_classroom"
|
||||||
"rename_classrooms" |
|
| "rename_classrooms"
|
||||||
"add_to_classroom" |
|
| "add_to_classroom"
|
||||||
"remove_from_classroom" |
|
| "remove_from_classroom"
|
||||||
"delete_classroom" |
|
| "delete_classroom"
|
||||||
"view_entities" |
|
| "view_entities"
|
||||||
"rename_entity" |
|
| "rename_entity"
|
||||||
"add_to_entity" |
|
| "add_to_entity"
|
||||||
"remove_from_entity" |
|
| "remove_from_entity"
|
||||||
"delete_entity" |
|
| "delete_entity"
|
||||||
"view_entity_roles" |
|
| "view_entity_roles"
|
||||||
"create_entity_role" |
|
| "create_entity_role"
|
||||||
"rename_entity_role" |
|
| "rename_entity_role"
|
||||||
"edit_role_permissions" |
|
| "edit_role_permissions"
|
||||||
"assign_to_role" |
|
| "assign_to_role"
|
||||||
"delete_entity_role" |
|
| "delete_entity_role"
|
||||||
"view_assignments" |
|
| "view_assignments"
|
||||||
"create_assignment" |
|
| "create_assignment"
|
||||||
"edit_assignment" |
|
| "edit_assignment"
|
||||||
"delete_assignment" |
|
| "delete_assignment"
|
||||||
"start_assignment" |
|
| "start_assignment"
|
||||||
"archive_assignment" |
|
| "archive_assignment"
|
||||||
"view_entity_statistics" |
|
| "view_entity_statistics"
|
||||||
"create_user" |
|
| "create_user"
|
||||||
"create_user_batch" |
|
| "create_user_batch"
|
||||||
"create_code" |
|
| "create_code"
|
||||||
"create_code_batch" |
|
| "create_code_batch"
|
||||||
"view_code_list" |
|
| "view_code_list"
|
||||||
"delete_code" |
|
| "delete_code"
|
||||||
"view_statistics" |
|
| "view_statistics"
|
||||||
"download_statistics_report" |
|
| "download_statistics_report"
|
||||||
"edit_grading_system" |
|
| "edit_grading_system"
|
||||||
"view_student_performance" |
|
| "view_student_performance"
|
||||||
"upload_classroom" |
|
| "upload_classroom"
|
||||||
"download_user_list" |
|
| "download_user_list"
|
||||||
"view_student_record" |
|
| "view_student_record"
|
||||||
"download_student_record" |
|
| "download_student_record"
|
||||||
"pay_entity" |
|
| "pay_entity"
|
||||||
"view_payment_record"
|
| "view_payment_record"
|
||||||
|
| "view_approval_workflows"
|
||||||
|
| "update_exam_privacy"
|
||||||
|
| "view_workflows"
|
||||||
|
| "configure_workflows"
|
||||||
|
| "edit_workflow"
|
||||||
|
| "delete_workflow";
|
||||||
|
|
||||||
export const DEFAULT_PERMISSIONS: RolePermission[] = [
|
export const DEFAULT_PERMISSIONS: RolePermission[] = [
|
||||||
"view_students",
|
"view_students",
|
||||||
@@ -74,8 +80,8 @@ export const DEFAULT_PERMISSIONS: RolePermission[] = [
|
|||||||
"view_classrooms",
|
"view_classrooms",
|
||||||
"view_entity_roles",
|
"view_entity_roles",
|
||||||
"view_statistics",
|
"view_statistics",
|
||||||
"download_statistics_report"
|
"download_statistics_report",
|
||||||
]
|
];
|
||||||
|
|
||||||
export const ADMIN_PERMISSIONS: RolePermission[] = [
|
export const ADMIN_PERMISSIONS: RolePermission[] = [
|
||||||
"view_students",
|
"view_students",
|
||||||
@@ -144,5 +150,10 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [
|
|||||||
"view_student_record",
|
"view_student_record",
|
||||||
"download_student_record",
|
"download_student_record",
|
||||||
"pay_entity",
|
"pay_entity",
|
||||||
"view_payment_record"
|
"view_payment_record",
|
||||||
]
|
"update_exam_privacy",
|
||||||
|
"configure_workflows",
|
||||||
|
"view_workflows",
|
||||||
|
"edit_workflow",
|
||||||
|
"delete_workflow",
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import defaultModuleSettings from "./defaults";
|
import defaultModuleSettings from "./defaults";
|
||||||
import { Action, rootReducer } from "./reducers";
|
import {Action, rootReducer} from "./reducers";
|
||||||
import ExamEditorStore from "./types";
|
import ExamEditorStore from "./types";
|
||||||
import { create } from "zustand";
|
import {create} from "zustand";
|
||||||
|
|
||||||
|
|
||||||
const useExamEditorStore = create<
|
const useExamEditorStore = create<
|
||||||
ExamEditorStore & {
|
ExamEditorStore & {
|
||||||
dispatch: (action: Action) => void;
|
dispatch: (action: Action) => void;
|
||||||
}>((set) => ({
|
}
|
||||||
title: "",
|
>((set) => ({
|
||||||
globalEdit: [],
|
title: "",
|
||||||
currentModule: "reading",
|
globalEdit: [],
|
||||||
speakingAvatars: [],
|
currentModule: "reading",
|
||||||
modules: {
|
speakingAvatars: [],
|
||||||
reading: defaultModuleSettings("reading", 60),
|
modules: {
|
||||||
writing: defaultModuleSettings("writing", 60),
|
reading: defaultModuleSettings("reading", 60),
|
||||||
speaking: defaultModuleSettings("speaking", 14),
|
writing: defaultModuleSettings("writing", 60),
|
||||||
listening: defaultModuleSettings("listening", 30),
|
speaking: defaultModuleSettings("speaking", 14),
|
||||||
level: defaultModuleSettings("level", 60)
|
listening: defaultModuleSettings("listening", 30),
|
||||||
},
|
level: defaultModuleSettings("level", 60),
|
||||||
dispatch: (action) => set((state) => rootReducer(state, action))
|
},
|
||||||
}));
|
dispatch: (action) => set((state) => rootReducer(state, action)),
|
||||||
|
}));
|
||||||
|
|
||||||
export default useExamEditorStore;
|
export default useExamEditorStore;
|
||||||
|
|||||||
@@ -3,88 +3,126 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.scrollbar-hide {
|
.scrollbar-hide {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
/* IE and Edge */
|
/* IE and Edge */
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
/* Firefox */
|
/* Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
/* Chrome, Safari and Opera */
|
/* Chrome, Safari and Opera */
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.training-scrollbar::-webkit-scrollbar {
|
||||||
@apply w-1.5;
|
@apply w-1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.training-scrollbar::-webkit-scrollbar-track {
|
.training-scrollbar::-webkit-scrollbar-track {
|
||||||
@apply bg-transparent;
|
@apply bg-transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.training-scrollbar::-webkit-scrollbar-thumb {
|
.training-scrollbar::-webkit-scrollbar-thumb {
|
||||||
@apply bg-gray-400 hover:bg-gray-500 rounded-full transition-colors opacity-50 hover:opacity-75;
|
@apply bg-gray-400 hover:bg-gray-500 rounded-full transition-colors opacity-50 hover:opacity-75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.training-scrollbar {
|
.training-scrollbar {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--max-width: 1100px;
|
--max-width: 1100px;
|
||||||
--border-radius: 12px;
|
--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",
|
||||||
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
|
"Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
|
||||||
|
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
|
||||||
|
|
||||||
--foreground-rgb: 53, 51, 56;
|
--foreground-rgb: 53, 51, 56;
|
||||||
--background-start-rgb: 245, 245, 245;
|
--background-start-rgb: 245, 245, 245;
|
||||||
--background-end-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);
|
--primary-glow: conic-gradient(
|
||||||
--secondary-glow: radial-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));
|
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-start-rgb: 239, 245, 249;
|
||||||
--tile-end-rgb: 228, 232, 233;
|
--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-rgb: 238, 240, 241;
|
||||||
--callout-border-rgb: 172, 175, 176;
|
--callout-border-rgb: 172, 175, 176;
|
||||||
--card-rgb: 180, 185, 188;
|
--card-rgb: 180, 185, 188;
|
||||||
--card-border-rgb: 131, 134, 135;
|
--card-border-rgb: 131, 134, 135;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
min-height: 100vh !important;
|
min-height: 100vh !important;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
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 {
|
body {
|
||||||
min-height: 100vh !important;
|
min-height: 100vh !important;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
overflow-x: hidden;
|
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 {
|
body {
|
||||||
color: rgb(var(--foreground-rgb));
|
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 {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|||||||
126
src/utils/approval.workflows.be.ts
Normal file
126
src/utils/approval.workflows.be.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -19,8 +19,8 @@ export const getAssignmentsByAssigner = async (id: string, startDate?: Date, end
|
|||||||
return await db.collection("assignments").find<Assignment>(query).toArray();
|
return await db.collection("assignments").find<Assignment>(query).toArray();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAssignments = async () => {
|
export const getAssignments = async (projection = {}) => {
|
||||||
return await db.collection("assignments").find<Assignment>({}).toArray();
|
return await db.collection("assignments").find<Assignment>({}, { projection }).toArray();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAssignment = async (id: string) => {
|
export const getAssignment = async (id: string) => {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const db = client.db(process.env.MONGODB_DB);
|
|||||||
export const getEntityWithRoles = async (id: string): Promise<EntityWithRoles | undefined> => {
|
export const getEntityWithRoles = async (id: string): Promise<EntityWithRoles | undefined> => {
|
||||||
const entity = await getEntity(id);
|
const entity = await getEntity(id);
|
||||||
if (!entity) return undefined;
|
if (!entity) return undefined;
|
||||||
|
|
||||||
const roles = await getRolesByEntity(id);
|
const roles = await getRolesByEntity(id);
|
||||||
return { ...entity, roles };
|
return { ...entity, roles };
|
||||||
};
|
};
|
||||||
|
|||||||
84
src/utils/exam.differences.ts
Normal file
84
src/utils/exam.differences.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
import { collection, getDocs, query, where, setDoc, doc, Firestore, getDoc, and } from "firebase/firestore";
|
|
||||||
import { groupBy, shuffle } from "lodash";
|
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 { DeveloperUser, Stat, StudentUser, User } from "@/interfaces/user";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { getCorporateUser } from "@/resources/user";
|
import { Db } from "mongodb";
|
||||||
import { getUserCorporate } from "./groups.be";
|
|
||||||
import { Db, ObjectId } from "mongodb";
|
|
||||||
import client from "@/lib/mongodb";
|
import client from "@/lib/mongodb";
|
||||||
import { MODULE_ARRAY } from "./moduleUtils";
|
import { MODULE_ARRAY } from "./moduleUtils";
|
||||||
import { mapBy } from ".";
|
import { mapBy } from ".";
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import client from "@/lib/mongodb";
|
|||||||
|
|
||||||
const db = client.db(process.env.MONGODB_DB);
|
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
|
await db
|
||||||
.collection("sessions")
|
.collection("sessions")
|
||||||
.find<Session>({ user: id, ...filter })
|
.find<Session>({ user: id, ...filter }, { projection })
|
||||||
.limit(limit || 0)
|
.limit(limit || 0)
|
||||||
.toArray();
|
.toArray();
|
||||||
|
|
||||||
|
|||||||
@@ -129,19 +129,19 @@ export async function getUser(id: string, projection = {}): Promise<User | undef
|
|||||||
return !!user ? user : undefined;
|
return !!user ? user : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSpecificUsers(ids: string[]) {
|
export async function getSpecificUsers(ids: string[], projection = {}) {
|
||||||
if (ids.length === 0) return [];
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
return await db
|
return await db
|
||||||
.collection("users")
|
.collection("users")
|
||||||
.find<User>({ id: { $in: ids } }, { projection: { _id: 0 } })
|
.find<User>({ id: { $in: ids } }, { projection: { _id: 0, ...projection } })
|
||||||
.toArray();
|
.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
|
return await db
|
||||||
.collection("users")
|
.collection("users")
|
||||||
.find<User>({ "entities.id": id, ...(filter || {}) })
|
.find<User>({ "entities.id": id, ...(filter || {}) }, { projection: { _id: 0, ...projection } })
|
||||||
.limit(limit || 0)
|
.limit(limit || 0)
|
||||||
.toArray();
|
.toArray();
|
||||||
}
|
}
|
||||||
@@ -231,8 +231,8 @@ export async function getUserBalance(user: User) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[]) => {
|
export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[], projection = {}) => {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
["view_students"]: allowedStudentEntities,
|
["view_students"]: allowedStudentEntities,
|
||||||
["view_teachers"]: allowedTeacherEntities,
|
["view_teachers"]: allowedTeacherEntities,
|
||||||
@@ -244,12 +244,12 @@ export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[]
|
|||||||
'view_corporates',
|
'view_corporates',
|
||||||
'view_mastercorporates',
|
'view_mastercorporates',
|
||||||
]);
|
]);
|
||||||
|
const [students, teachers, corporates, masterCorporates] = await Promise.all([
|
||||||
|
getEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }, 0, projection),
|
||||||
const students = await getEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" })
|
getEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }, 0, projection),
|
||||||
const teachers = await getEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" })
|
getEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }, 0, projection),
|
||||||
const corporates = await getEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" })
|
getEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }, 0, projection),
|
||||||
const masterCorporates = await getEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" })
|
])
|
||||||
|
|
||||||
return [...students, ...teachers, ...corporates, ...masterCorporates]
|
return [...students, ...teachers, ...corporates, ...masterCorporates]
|
||||||
}
|
}
|
||||||
@@ -266,11 +266,12 @@ export const countAllowedUsers = async (user: User, entities: EntityWithRoles[])
|
|||||||
'view_corporates',
|
'view_corporates',
|
||||||
'view_mastercorporates',
|
'view_mastercorporates',
|
||||||
]);
|
]);
|
||||||
|
const [student, teacher, corporate, mastercorporate] = await Promise.all([
|
||||||
const student = await countEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" })
|
countEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }),
|
||||||
const teacher = await countEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" })
|
countEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }),
|
||||||
const corporate = await countEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" })
|
countEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }),
|
||||||
const mastercorporate = await countEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" })
|
countEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }),
|
||||||
|
])
|
||||||
|
|
||||||
return { student, teacher, corporate, mastercorporate }
|
return { student, teacher, corporate, mastercorporate }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { EntityWithRoles, WithLabeledEntities } from "@/interfaces/entity";
|
import {WithLabeledEntities} from "@/interfaces/entity";
|
||||||
import { Group, User } from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import { getUserCompanyName, USER_TYPE_LABELS } from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import { capitalize } from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { mapBy } from ".";
|
|
||||||
import { findAllowedEntities } from "./permissions";
|
|
||||||
import { getEntitiesUsers } from "./users.be";
|
|
||||||
|
|
||||||
export interface UserListRow {
|
export interface UserListRow {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -25,7 +22,7 @@ export const exportListToExcel = (rowUsers: WithLabeledEntities<User>[]) => {
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
type: USER_TYPE_LABELS[user.type],
|
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",
|
expiryDate: user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited",
|
||||||
country: user.demographicInformation?.country || "N/A",
|
country: user.demographicInformation?.country || "N/A",
|
||||||
phone: user.demographicInformation?.phone || "N/A",
|
phone: user.demographicInformation?.phone || "N/A",
|
||||||
@@ -44,8 +41,7 @@ export const exportListToExcel = (rowUsers: WithLabeledEntities<User>[]) => {
|
|||||||
|
|
||||||
export const getUserName = (user?: User) => {
|
export const getUserName = (user?: User) => {
|
||||||
if (!user) return "N/A";
|
if (!user) return "N/A";
|
||||||
if (user.type === "corporate" || user.type === "mastercorporate") return user.name;
|
|
||||||
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);
|
||||||
|
|||||||
41
yarn.lock
41
yarn.lock
@@ -1683,6 +1683,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.4.tgz#cb7e85d9ad5ababfac2f27183e8ac8b576b2abb3"
|
resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.4.tgz#cb7e85d9ad5ababfac2f27183e8ac8b576b2abb3"
|
||||||
integrity sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==
|
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":
|
"@types/express-handlebars@^5":
|
||||||
version "5.3.1"
|
version "5.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/express-handlebars/-/express-handlebars-5.3.1.tgz#30447330fa4b7d19bb953834c7c26077a906e25e"
|
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"
|
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
|
||||||
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
|
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:
|
deep-is@^0.1.3, deep-is@~0.1.3:
|
||||||
version "0.1.4"
|
version "0.1.4"
|
||||||
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
|
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"
|
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
|
||||||
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
|
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"
|
version "4.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||||
@@ -6658,7 +6677,14 @@ string_decoder@~1.1.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer "~5.1.0"
|
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"
|
version "6.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
@@ -7295,7 +7321,7 @@ wordwrap@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
|
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
|
||||||
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
|
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"
|
version "7.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||||
@@ -7313,6 +7339,15 @@ wrap-ansi@^6.2.0:
|
|||||||
string-width "^4.1.0"
|
string-width "^4.1.0"
|
||||||
strip-ansi "^6.0.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:
|
wrap-ansi@^8.1.0:
|
||||||
version "8.1.0"
|
version "8.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||||
|
|||||||
Reference in New Issue
Block a user