Compare commits

...

94 Commits

Author SHA1 Message Date
Tiago Ribeiro
720597e916 Merged develop into ENCOA-83_MasterStatistical 2024-08-21 16:18:37 +00:00
carlos.mesquita
e74ded676e Merged in feature/level-file-upload (pull request #77)
Forgot to check if exam had shuffled enabled

Approved-by: Tiago Ribeiro
2024-08-21 16:18:23 +00:00
carlos.mesquita
ee60eedd0d Merged develop into feature/level-file-upload 2024-08-21 12:03:57 +00:00
Carlos Mesquita
c37a1becbf Forgot to check if exam had shuffled enabled 2024-08-21 13:02:30 +01:00
João Ramos
b9cca483ec Merged develop into ENCOA-83_MasterStatistical 2024-08-20 23:39:57 +00:00
Joao Ramos
c758bdaf9e Merge branch 'ENCOA-83_MasterStatistical' of https://bitbucket.org/ecropdev/ielts-ui into ENCOA-83_MasterStatistical 2024-08-21 00:39:22 +01:00
Joao Ramos
5ada588b16 Reverted minor change 2024-08-21 00:38:26 +01:00
Joao Ramos
eec1bb0c30 Added final touches 2024-08-21 00:37:33 +01:00
Joao Ramos
65f8368708 Initial draft of level test report 2024-08-20 23:40:54 +01:00
Tiago Ribeiro
806e621c5b Updated the record for students 2024-08-20 22:43:34 +01:00
carlos.mesquita
ce35b23714 Merged in feature/level-file-upload (pull request #75)
Part intro's, modals between parts and some fixes

Approved-by: Tiago Ribeiro
2024-08-20 21:18:22 +00:00
Carlos Mesquita
2cd025b118 Patched part divider, forgot to check if the exam had field intro 2024-08-20 20:14:42 +01:00
Carlos Mesquita
2e699d7e25 Forgot to save TextComponent, and cleanup some warnings 2024-08-20 20:02:59 +01:00
Carlos Mesquita
30da295c60 Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-20 19:37:06 +01:00
Carlos Mesquita
a82a399d52 Merge branch 'feature/level-file-upload' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-20 18:53:25 +01:00
Carlos Mesquita
505df31d6b Part intro's, modals between parts and some fixes 2024-08-20 18:52:38 +01:00
Tiago Ribeiro
a4d8ba72af Disallowed the name to be equal to Corporate 2024-08-20 17:41:06 +01:00
Tiago Ribeiro
2bfd0cb502 More stuff related to the groups 2024-08-20 17:38:48 +01:00
Tiago Ribeiro
5ee071028c Removed a WIP page 2024-08-20 17:14:54 +01:00
Tiago Ribeiro
23b9452a3a Another bug solved 2024-08-20 17:12:10 +01:00
Tiago Ribeiro
0ce3a16d3a Resolved another bug related to master corporate groups 2024-08-20 16:29:55 +01:00
Tiago Ribeiro
4315a7b17c Corrected a bug related to groups of a master corporate 2024-08-20 14:37:33 +01:00
Tiago Ribeiro
247f192a0a Added a scroll to the selection 2024-08-20 12:43:34 +01:00
João Ramos
9c944ae3d2 Merged in ENCOA-83_MasterStatistical (pull request #74)
ENCOA-83 MasterStatistical

Approved-by: Tiago Ribeiro
2024-08-20 10:14:19 +00:00
Tiago Ribeiro
a390aa429d Merged develop into ENCOA-83_MasterStatistical 2024-08-20 10:13:42 +00:00
Joao Ramos
3367384791 Merge branch 'ENCOA-83_MasterStatistical' of https://bitbucket.org/ecropdev/ielts-ui into ENCOA-83_MasterStatistical 2024-08-20 11:12:05 +01:00
Joao Ramos
158324a705 Added second excel for master corporate export 2024-08-20 11:10:51 +01:00
Tiago Ribeiro
f9286d1793 Updated the Dockerfile after having upgraded NextJS 2024-08-20 10:47:56 +01:00
João Ramos
2e376c37dd Merged in ENCOA-83_MasterStatistical (pull request #73)
ENCOA-83 MasterStatistical

Approved-by: Tiago Ribeiro
2024-08-20 09:33:42 +00:00
Tiago Ribeiro
5bda9ed227 Merge branch 'develop' into ENCOA-83_MasterStatistical 2024-08-20 10:32:19 +01:00
Tiago Ribeiro
97b533bd3a Took care of some warnings 2024-08-20 10:07:18 +01:00
Tiago Ribeiro
75a45108a2 Upgraded @types/react and @types/react-dom 2024-08-20 09:52:22 +01:00
Tiago Ribeiro
bfc0def20f Updated the exam list to be visible 2024-08-20 09:35:13 +01:00
Joao Ramos
9db33e6a51 Fixed date usage 2024-08-20 09:17:48 +01:00
Joao Ramos
ba5d926659 Merge branch 'develop' into ENCOA-83_MasterStatistical 2024-08-20 01:16:46 +01:00
Joao Ramos
1cd4dfc397 Added download option to master Corporate and teacher 2024-08-20 01:15:43 +01:00
Joao Ramos
bf5dd62b35 Updated Excel document 2024-08-20 01:01:50 +01:00
Tiago Ribeiro
4e583d11b6 Improved the bug on the teachers 2024-08-20 00:10:26 +01:00
Tiago Ribeiro
688505b4eb Updated the permissions access 2024-08-20 00:01:59 +01:00
carlos.mesquita
81b8ceb2b3 Merged in feature/level-file-upload (pull request #72)
Fixed question numbers for fillBlanks exercises, reverted multipleChoice underline prompt, added part label to module title, and changed some styles
2024-08-19 22:48:26 +00:00
carlos.mesquita
d93d36c392 Merged develop into feature/level-file-upload 2024-08-19 22:47:48 +00:00
Carlos Mesquita
3299acee36 Forgot to add a key in Level 2024-08-19 23:46:51 +01:00
Carlos Mesquita
abddead402 Fixed question numbers for fillBlanks exercises, reverted multipleChoice underline prompt, added part label to module title, and changed some styles 2024-08-19 23:43:08 +01:00
Joao Ramos
2d69fdac3c Added missing updated lockfile 2024-08-19 23:39:38 +01:00
Joao Ramos
506ff2503e Merge branch 'develop' into ENCOA-83_MasterStatistical 2024-08-19 23:38:46 +01:00
Tiago Ribeiro
5d191730d2 Added the total of assignments to the Master Corporate 2024-08-19 23:04:13 +01:00
Tiago Ribeiro
346b131388 Improved the sorting and filtering for the Student Performance page 2024-08-19 19:46:43 +01:00
carlos.mesquita
aba49e385f Merged in feature/level-file-upload (pull request #71)
Previously commented a required line
2024-08-19 16:25:52 +00:00
Carlos Mesquita
5789688eab Previously commented a required line 2024-08-19 17:24:10 +01:00
Tiago Ribeiro
f7da11bc69 Updated the way groups work there 2024-08-19 17:03:33 +01:00
carlos.mesquita
10802f6bb5 Merged in feature/level-file-upload (pull request #70)
Feature/level file upload
2024-08-19 15:55:10 +00:00
Carlos Mesquita
37e356572b Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-19 16:53:39 +01:00
Carlos Mesquita
8669ef462d Commented all related to shuffle 2024-08-19 16:42:14 +01:00
Tiago Ribeiro
df1c0bad4d Created a student performance page 2024-08-19 16:41:35 +01:00
Carlos Mesquita
bcb1a0f914 If someone else wants to join in on the fun be my guest 2024-08-19 01:24:55 +01:00
Joao Ramos
bf1bdd935c Improvements on excel rendering 2024-08-18 21:25:18 +01:00
Carlos Mesquita
edc9d4de2a Fill Blanks changes 2024-08-18 08:07:16 +01:00
Tiago Ribeiro
229275aaee Created a groups page for students and teachers 2024-08-17 20:18:28 +01:00
Tiago Ribeiro
f0ff6ac691 ENCOA-87: Allow MasterCorporate & Corporate to change the type of students and teachers 2024-08-17 19:15:20 +01:00
Tiago Ribeiro
878c7c2ef0 Updated the Groups List to allow teachers to view their corporate's students 2024-08-16 11:50:27 +01:00
Tiago Ribeiro
0a28c2bd41 Added a "last login" to the users 2024-08-15 23:55:08 +01:00
Tiago Ribeiro
38e48c90bb Updated the label for Admin 2024-08-15 23:37:34 +01:00
Tiago Ribeiro
c6f35d7750 Enable the option to not have only full exams 2024-08-15 19:25:39 +01:00
Tiago Ribeiro
85f684dff5 Updated the user balance to be based not only on the amount of codes 2024-08-15 19:24:08 +01:00
Tiago Ribeiro
d94a9bb88a Quick little fix 2024-08-15 18:39:17 +01:00
Joao Ramos
1950d5f15d Added initial Excel changes 2024-08-15 14:56:14 +01:00
Joao Ramos
e84cc8ddd8 Cleaned up some code 2024-08-15 13:39:42 +01:00
Joao Ramos
cf2fd06d39 Added Icon ofr consolidate highest student 2024-08-15 10:38:56 +01:00
Joao Ramos
b6015b6433 Added any to error to prevent an error 2024-08-15 10:35:08 +01:00
Joao Ramos
fea58a7b40 Added initial implementation for master statistical 2024-08-15 10:34:31 +01:00
João Ramos
13284eab75 Merged in ENCOA-82_Permissions (pull request #69)
Updated permissions to have a key to group them
2024-08-14 19:38:30 +00:00
Tiago Ribeiro
dd4e3a4694 Merged develop into ENCOA-82_Permissions 2024-08-14 19:38:16 +00:00
Tiago Ribeiro
eb55e65d91 Solved some issues related to the BatchCreateUser 2024-08-14 20:36:47 +01:00
Joao Ramos
cb75ba6056 Updated permissions to have a key to group them 2024-08-14 18:44:35 +01:00
João Ramos
859d9283a7 Merged in ENCOA-77_GenerationTitle (pull request #64)
Added title to the exam generate

Approved-by: Tiago Ribeiro
2024-08-13 21:40:04 +00:00
Tiago Ribeiro
1a3437b333 Merged develop into ENCOA-77_GenerationTitle 2024-08-13 21:38:56 +00:00
João Ramos
bbbf17daa0 Merged in ENCOA-79_PaymentRecordsFilters (pull request #61)
ENCOA-79 PaymentRecordsFilters

Approved-by: Tiago Ribeiro
2024-08-13 21:38:38 +00:00
Joao Ramos
ae79aef132 Merge branch 'develop' into ENCOA-79_PaymentRecordsFilters 2024-08-13 21:32:33 +01:00
Joao Ramos
c3e71b4389 Merge branch 'develop' into ENCOA-77_GenerationTitle 2024-08-13 21:30:50 +01:00
Tiago Ribeiro
2784117862 Updated the MasterCorporate and Corporate pages to allow to have Assignments 2024-08-13 10:02:40 +01:00
Tiago Ribeiro
8162567e12 Updated the Batch Create User to also have an expiry date 2024-08-12 19:49:18 +01:00
Tiago Ribeiro
58300e32ff Solved an issue where, for developers, because of the amount of permissions, the cookie was too big, so I separated the permissions logic into a hook 2024-08-12 19:35:11 +01:00
carlos.mesquita
cb489bf0ca Merged in feature/training-content (pull request #67)
Tooltips for assessment criteria on Writing and Speaking

Approved-by: Tiago Ribeiro
2024-08-12 14:13:28 +00:00
Tiago Ribeiro
91bc91e725 Merged develop into feature/training-content 2024-08-12 14:12:55 +00:00
João Ramos
ce086a8b22 Merged in ENCOA-81_CodeListPermissions (pull request #68)
ENCOA-81: Fixed issue with the users that each role could create

Approved-by: Tiago Ribeiro
2024-08-12 14:12:25 +00:00
Joao Ramos
6e71ee7cb0 ENCOA-81: Fixed issue with the users that each role could create 2024-08-08 09:17:33 +01:00
Carlos Mesquita
21e58e3b9c Tooltips for assessment criteria on Writing and Speaking 2024-08-07 13:04:42 +01:00
João Ramos
b885dd46b5 Merged in ENCOA-80_Permissions (pull request #66)
ENCOA-80 Permissions

Approved-by: Tiago Ribeiro
2024-08-07 09:30:04 +00:00
Joao Ramos
0fc2df1070 Added permission to codes 2024-08-07 10:03:17 +01:00
Joao Ramos
cf91f1812d Added group permissions 2024-08-07 09:36:08 +01:00
Tiago Ribeiro
3289f27cd5 Merged in settings-import-users (pull request #65)
Settings import users
2024-08-07 06:48:37 +00:00
Joao Ramos
95c3f89911 Added title to the exam generate 2024-08-06 19:24:43 +01:00
Joao Ramos
48faee07f6 Added missing fussy search fileds and support for number 2024-08-05 19:32:53 +01:00
Joao Ramos
f0d7d7644b Added date filter 2024-08-05 19:26:50 +01:00
109 changed files with 15111 additions and 10096 deletions

View File

@@ -54,4 +54,4 @@ EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME localhost
CMD ["node", "server.js"]
CMD HOSTNAME="0.0.0.0" node server.js

17
components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

2966
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,30 +12,34 @@
"dependencies": {
"@beam-australia/react-env": "^3.1.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@firebase/util": "^1.9.7",
"@headlessui/react": "^1.7.13",
"@headlessui/react": "^2.1.2",
"@mdi/js": "^7.1.96",
"@mdi/react": "^1.6.1",
"@next/font": "13.1.6",
"@paypal/paypal-js": "^7.1.0",
"@paypal/react-paypal-js": "^8.1.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.1.1",
"@react-pdf/renderer": "^3.1.14",
"@react-spring/web": "^9.7.4",
"@tanstack/react-table": "^8.10.1",
"@types/node": "18.13.0",
"@types/react": "18.0.27",
"@types/react-dom": "18.0.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@use-gesture/react": "^10.3.1",
"axios": "^1.3.5",
"bcrypt": "^5.1.1",
"chart.js": "^4.2.1",
"clsx": "^1.2.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"countries-list": "^3.0.1",
"country-codes-list": "^1.6.11",
"currency-symbol-map": "^5.1.0",
"daisyui": "^3.1.5",
"eslint": "8.33.0",
"eslint-config-next": "13.1.6",
"exceljs": "^4.4.0",
"express-handlebars": "^7.1.2",
"firebase": "9.19.1",
"firebase-admin": "^11.10.1",
@@ -47,7 +51,7 @@
"lodash": "^4.17.21",
"moment": "^2.29.4",
"moment-timezone": "^0.5.44",
"next": "13.1.6",
"next": "^14.2.5",
"nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0",
"primeicons": "^6.0.1",
@@ -76,7 +80,9 @@
"short-unique-id": "5.0.2",
"stripe": "^13.10.0",
"swr": "^2.1.3",
"tailwind-merge": "^2.5.2",
"tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss-animate": "^1.0.7",
"typescript": "4.9.5",
"use-file-picker": "^2.1.0",
"uuid": "^9.0.0",

View File

@@ -1,56 +0,0 @@
import {Dialog, Transition} from "@headlessui/react";
import {Fragment} from "react";
import Button from "./Low/Button";
interface Props {
isOpen: boolean;
onClose: (next?: boolean) => void;
}
export default function BlankQuestionsModal({isOpen, onClose}: Props) {
return (
<Transition show={isOpen} as={Fragment}>
<Dialog onClose={() => onClose(false)} className="relative z-50">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black/30" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
<span>
Please note that you are finishing the current module and once you proceed to the next module, you will no longer be
able to change the answers in the current one, including your unanswered questions. <br />
<br />
Are you sure you want to continue without completing those questions?
</span>
<div className="w-full flex justify-between mt-8">
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
Go Back
</Button>
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
Continue
</Button>
</div>
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
);
}

View File

@@ -1,187 +0,0 @@
import {FillBlanksExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import {Fragment, useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import Button from "../Low/Button";
interface WordsDrawerProps {
words: {word: string; isDisabled: boolean}[];
isOpen: boolean;
blankId?: string;
previouslySelectedWord?: string;
onCancel: () => void;
onAnswer: (answer: string) => void;
}
function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}: WordsDrawerProps) {
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
return (
<>
<div
className={clsx(
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
isOpen ? "visible opacity-10" : "invisible opacity-0",
)}
/>
<div
className={clsx(
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
isOpen ? "visible opacity-100" : "invisible opacity-0",
)}>
<div className="w-full flex gap-2">
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
<span> Choose the correct word:</span>
</div>
<div className="grid grid-cols-6 gap-6" key="word-array">
{words.map(({word, isDisabled}) => (
<button
key={`${word}_${blankId}`}
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
className={clsx(
"rounded-full py-3 text-center transition duration-300 ease-in-out",
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
!isDisabled && "hover:text-white hover:bg-mti-purple",
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
)}
disabled={isDisabled}>
{word}
</button>
))}
</div>
<div className="flex justify-between w-full">
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
Cancel
</Button>
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
Confirm
</Button>
</div>
</div>
</>
);
}
export default function FillBlanks({
id,
allowRepetition,
type,
prompt,
solutions,
text,
words,
userSolutions,
onNext,
onBack,
}: FillBlanksExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
if (!solution) return false;
const option = words.find((w) =>
typeof w === "string" ? w.toLowerCase() === x.solution.toLowerCase() : w.letter.toLowerCase() === x.solution.toLowerCase(),
);
if (!option) return false;
return solution === (typeof option === "string" ? option.toLowerCase() : option.word.toLowerCase());
}).length;
const missing = total - answers.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing};
};
const renderLines = (line: string) => {
return (
<div className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id);
return (
<input
className={clsx(
"rounded-full hover:text-white focus:ring-0 focus:outline-none focus:!text-white focus:bg-mti-purple transition duration-300 ease-in-out my-1 px-5 py-2 text-center",
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
userSolution && "px-5 py-2 text-center text-mti-purple-dark bg-mti-purple-ultralight",
)}
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution: e.target.value}])}
value={userSolution?.solution}></input>
);
})}
</div>
);
};
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<p key={index}>
{renderLines(line)}
<br />
</p>
))}
</span>
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
<span className="font-medium text-mti-purple-dark">Options</span>
<div className="flex gap-4 flex-wrap">
{words.map((v) => {
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`;
return (
<span
className={clsx(
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
!!answers.find((x) => x.solution.toLowerCase() === (typeof v === "string" ? v : v.letter).toLowerCase()) &&
"bg-mti-purple-dark text-white",
)}
key={text}>
{text}
</span>
);
})}
</div>
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,64 @@
import Button from "@/components/Low/Button";
import clsx from "clsx";
import { useState } from "react";
interface WordsDrawerProps {
words: {word: string; isDisabled: boolean}[];
isOpen: boolean;
blankId?: string;
previouslySelectedWord?: string;
onCancel: () => void;
onAnswer: (answer: string) => void;
}
const WordsDrawer: React.FC<WordsDrawerProps> = ({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}) => {
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
return (
<>
<div
className={clsx(
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
isOpen ? "visible opacity-10" : "invisible opacity-0",
)}
/>
<div
className={clsx(
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
isOpen ? "visible opacity-100" : "invisible opacity-0",
)}>
<div className="w-full flex gap-2">
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
<span> Choose the correct word:</span>
</div>
<div className="grid grid-cols-6 gap-6" key="word-array">
{words.map(({word, isDisabled}) => (
<button
key={`${word}_${blankId}`}
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
className={clsx(
"rounded-full py-3 text-center transition duration-300 ease-in-out",
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
!isDisabled && "hover:text-white hover:bg-mti-purple",
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
)}
disabled={isDisabled}>
{word}
</button>
))}
</div>
<div className="flex justify-between w-full">
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
Cancel
</Button>
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
Confirm
</Button>
</div>
</div>
</>
);
}
export default WordsDrawer;

View File

@@ -0,0 +1,243 @@
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import { Fragment, useEffect, useState } from "react";
import reactStringReplace from "react-string-replace";
import { CommonProps } from "..";
import Button from "../../Low/Button";
import { v4 } from "uuid";
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
id,
type,
prompt,
solutions,
text,
words,
userSolutions,
variant,
onNext,
onBack,
}) => {
const { shuffleMaps, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const [currentMCSelection, setCurrentMCSelection] = useState<{ id: string, selection: FillBlanksMCOption }>();
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every(
word => word && typeof word === 'object' && 'id' in word && 'options' in word
);
}
const excludeWordMCType = (x: any) => {
return typeof x === "string" ? x : x as { letter: string; word: string };
}
useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
let correctWords: any;
if (exam && exam.module === "level" && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
}
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers!.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
if (!solution) return false;
const option = correctWords!.find((w: any) => {
if (typeof w === "string") {
return w.toLowerCase() === x.solution.toLowerCase();
} else if ('letter' in w) {
return w.word.toLowerCase() === x.solution.toLowerCase();
} else {
return w.id.toString() === x.id.toString();
}
});
if (!option) return false;
if (typeof option === "string") {
return solution.toLowerCase() === option.toLowerCase();
} else if ('letter' in option) {
return solution.toLowerCase() === option.word.toLowerCase();
} else if ('options' in option) {
return option.options[solution as keyof typeof option.options] == x.solution;
}
return false;
}).length;
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing };
};
const renderLines = (line: string) => {
return (
<div className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id);
const styles = clsx(
"rounded-full hover:text-white focus:ring-0 focus:outline-none focus:!text-white focus:bg-mti-purple transition duration-300 ease-in-out my-1 px-5 py-2 text-center",
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
)
return (
variant === "mc" ? (
<>
{/*<span className="mr-2">{`(${id})`}</span>*/}
<button
className={styles}
onClick={() => {
setCurrentMCSelection(
{
id: id,
selection: words.find((x) => {
if (typeof x !== "string" && 'id' in x) {
return (x as FillBlanksMCOption).id.toString() == id.toString();
}
return false;
}) as FillBlanksMCOption
}
);
}}
>
{userSolution?.solution === undefined ? <span className="text-transparent select-none">placeholder</span> : <span> {userSolution.solution} </span>}
</button>
</>
) : (
<input
className={styles}
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
value={userSolution?.solution} />
)
);
})}
</div>
);
};
const onSelection = (id: string, value: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id: id, solution: value }]);
}
const getShuffles = () => {
let shuffle = {};
if (shuffleMaps.length !== 0) {
shuffle = {
shuffleMaps: shuffleMaps.filter((map) =>
answers.some(answer => answer.id === map.id)
)
}
}
return shuffle;
}
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
{false && <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>}
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
{renderLines(line)}
<br />
</p>
))}
</span>
{variant === "mc" && typeCheckWordsMC(words) ? (
<>
{currentMCSelection && (
<div className="bg-mti-gray-smoke rounded-xl flex flex-col gap-4 px-16 py-8">
<span className="font-medium text-lg text-mti-purple-dark mb-4 px-2">{`${currentMCSelection.id} - Select the appropriate word.`}</span>
<div className="flex gap-4 flex-wrap justify-between">
{currentMCSelection.selection?.options && Object.entries(currentMCSelection.selection.options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => {
return <div
key={v4()}
onClick={() => onSelection(currentMCSelection.id, value)}
className={clsx(
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base",
!!answers.find((x) => x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id) &&
"border-mti-purple-light",
)}>
<span className="font-semibold">{key}.</span>
<span>{value}</span>
</div>
/*<button
className={clsx(
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
!!answers.find((x) => x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id) &&
"bg-mti-purple-dark text-white",
)}
key={v4()}
onClick={() => onSelection(currentMCSelection.id, value)}
>
{value}
</button>;*/
})}
</div>
</div>
)}
</>
) : (
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
<span className="font-medium text-mti-purple-dark">Options</span>
<div className="flex gap-4 flex-wrap">
{words.map((v) => {
v = excludeWordMCType(v);
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`;
return (
<span
className={clsx(
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
!!answers.find((x) => x.solution.toLowerCase() === (typeof v === "string" ? v : ("letter" in v ? v.letter : "")).toLowerCase()) &&
"bg-mti-purple-dark text-white",
)}
key={v4()}
>
{text}
</span>
)
})}
</div>
</div >
)}
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })}
className="max-w-[200px] w-full"
disabled={
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
typeof exam.parts[0].intro === "string" && questionIndex === 0}
>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}
export default FillBlanks;

View File

@@ -1,11 +1,12 @@
/* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import { MultipleChoiceExercise, MultipleChoiceQuestion } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import {useEffect, useState} from "react";
import { useEffect, useState } from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import { CommonProps } from ".";
import Button from "../Low/Button";
import { v4 } from "uuid";
function Question({
id,
@@ -14,22 +15,27 @@ function Question({
options,
userSolution,
onSelectOption,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
}: MultipleChoiceQuestion & {
userSolution: string | undefined;
onSelectOption?: (option: string) => void;
showSolution?: boolean,
}) {
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => {
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u>{word}</u> : null;
});
};
return (
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-8">
{isNaN(Number(id)) ? (
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
<span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
) : (
<span className="">
<span className="text-lg">
<>
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
</>
</span>
)}
@@ -37,10 +43,10 @@ function Question({
{variant === "image" &&
options.map((option) => (
<div
key={option.id.toString()}
key={v4()}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
className={clsx(
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
userSolution === option.id.toString() && "border-mti-purple-light",
)}>
<span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span>
@@ -50,10 +56,10 @@ function Question({
{variant === "text" &&
options.map((option) => (
<div
key={option.id.toString()}
key={v4()}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
className={clsx(
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
userSolution === option.id.toString() && "border-mti-purple-light",
)}>
<span className="font-semibold">{option.id.toString()}.</span>
@@ -65,53 +71,84 @@ function Question({
);
}
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
export default function MultipleChoice({ id, prompt, type, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const {
questionIndex,
exam,
shuffleMaps,
hasExamEnded,
userSolutions: storeUserSolutions,
setQuestionIndex,
setUserSolutions
} = useExamStore((state) => state);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]);
setUserSolutions(
[...storeUserSolutions.filter((x) => x.exercise !== id), {
exercise: id, solutions: answers, score: calculateScore(), type
}]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers]);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const onSelectOption = (option: string) => {
const question = questions[questionIndex];
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), { option, question: question.id }]);
};
const calculateScore = () => {
const total = questions.length;
const correct = answers.filter(
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
).length;
const missing = total - answers.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
const correct = answers.filter((x) => {
const matchingQuestion = questions.find((y) => {
return y.id.toString() === x.question.toString();
});
return {total, correct, missing};
let isSolutionCorrect;
if (shuffleMaps.length == 0) {
isSolutionCorrect = matchingQuestion?.solution === x.option;
} else {
const shuffleMap = shuffleMaps.find((map) => map.id == x.question)
isSolutionCorrect = shuffleMap?.map[x.option] == matchingQuestion?.solution;
}
return isSolutionCorrect || false;
}).length;
const missing = total - correct;
return { total, correct, missing };
};
const getShuffles = () => {
let shuffle = {};
if (shuffleMaps.length !== 0) {
shuffle = {
shuffleMaps: shuffleMaps.filter((map) =>
answers.some(answer => answer.question === map.id)
)
}
}
return shuffle;
}
const next = () => {
if (questionIndex === questions.length - 1) {
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() });
} else {
setQuestionIndex(questionIndex + 1);
}
scrollToTop();
};
const back = () => {
if (questionIndex === 0) {
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() });
} else {
setQuestionIndex(questionIndex - 1);
}
@@ -122,7 +159,7 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
return (
<>
<div className="flex flex-col gap-2 mt-4 h-fit w-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
<span className="text-xl font-semibold">{prompt}</span>
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
{questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
@@ -133,7 +170,10 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full"
disabled={
exam && exam.module === "level" && typeof exam.parts[0].intro === "string" && questionIndex === 0}
>
Back
</Button>

View File

@@ -63,7 +63,7 @@ const FillBlanksEdit = (props: Props) => {
label={`Word ${index + 1}`}
name="word"
required
value={typeof word === "string" ? word : word.word}
value={typeof word === "string" ? word : ("word" in word ? word.word : "")}
onChange={(value) =>
updateExercise({
words: exercise.words.map((sol, idx) =>

View File

@@ -11,14 +11,15 @@ interface Props {
className?: string;
navDisabled?: boolean;
focusMode?: boolean;
bgColor?: string;
onFocusLayerMouseEnter?: () => void;
}
export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
export default function Layout({user, children, className, bgColor="bg-white", navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
const router = useRouter();
return (
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative">
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
<Navbar
path={router.pathname}
user={user}
@@ -37,7 +38,8 @@ export default function Layout({user, children, className, navDisabled = false,
/>
<div
className={clsx(
"w-full min-h-full h-fit md:mr-8 bg-white shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2",
`w-full min-h-full md:mr-8 ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`,
bgColor !== "bg-white" ? "justify-center" : "h-fit",
className,
)}>
{children}

View File

@@ -1,255 +1,179 @@
import useUsers from "@/hooks/useUsers";
import {
Ticket,
TicketStatus,
TicketStatusLabel,
TicketType,
TicketTypeLabel,
} from "@/interfaces/ticket";
import { User } from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user";
import {Ticket, TicketStatus, TicketStatusLabel, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
import {User} from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user";
import axios from "axios";
import moment from "moment";
import { useState } from "react";
import { toast } from "react-toastify";
import {useState} from "react";
import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id";
import Button from "../Low/Button";
import Input from "../Low/Input";
import Select from "../Low/Select";
import { checkAccess } from "@/utils/permissions";
import {checkAccess} from "@/utils/permissions";
interface Props {
user: User;
ticket: Ticket;
onClose: () => void;
user: User;
ticket: Ticket;
onClose: () => void;
}
export default function TicketDisplay({ user, ticket, onClose }: Props) {
const [subject] = useState(ticket.subject);
const [type, setType] = useState<TicketType>(ticket.type);
const [description] = useState(ticket.description);
const [reporter] = useState(ticket.reporter);
const [reportedFrom] = useState(ticket.reportedFrom);
const [status, setStatus] = useState(ticket.status);
const [assignedTo, setAssignedTo] = useState<string | null>(
ticket.assignedTo || null,
);
const [isLoading, setIsLoading] = useState(false);
export default function TicketDisplay({user, ticket, onClose}: Props) {
const [subject] = useState(ticket.subject);
const [type, setType] = useState<TicketType>(ticket.type);
const [description] = useState(ticket.description);
const [reporter] = useState(ticket.reporter);
const [reportedFrom] = useState(ticket.reportedFrom);
const [status, setStatus] = useState(ticket.status);
const [assignedTo, setAssignedTo] = useState<string | null>(ticket.assignedTo || null);
const [isLoading, setIsLoading] = useState(false);
const { users } = useUsers();
const {users} = useUsers();
const submit = () => {
if (!type)
return toast.error("Please choose a type!", { toastId: "missing-type" });
const submit = () => {
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
setIsLoading(true);
axios
.patch(`/api/tickets/${ticket.id}`, {
subject,
type,
description,
reporter,
reportedFrom,
status,
assignedTo,
})
.then(() => {
toast.success(`The ticket has been updated!`, { toastId: "submitted" });
onClose();
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong, please try again later!", {
toastId: "error",
});
})
.finally(() => setIsLoading(false));
};
setIsLoading(true);
axios
.patch(`/api/tickets/${ticket.id}`, {
subject,
type,
description,
reporter,
reportedFrom,
status,
assignedTo,
})
.then(() => {
toast.success(`The ticket has been updated!`, {toastId: "submitted"});
onClose();
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong, please try again later!", {
toastId: "error",
});
})
.finally(() => setIsLoading(false));
};
const del = () => {
if (!confirm("Are you sure you want to delete this ticket?")) return;
const del = () => {
if (!confirm("Are you sure you want to delete this ticket?")) return;
setIsLoading(true);
axios
.delete(`/api/tickets/${ticket.id}`)
.then(() => {
toast.success(`The ticket has been deleted!`, { toastId: "submitted" });
onClose();
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong, please try again later!", {
toastId: "error",
});
})
.finally(() => setIsLoading(false));
};
setIsLoading(true);
axios
.delete(`/api/tickets/${ticket.id}`)
.then(() => {
toast.success(`The ticket has been deleted!`, {toastId: "submitted"});
onClose();
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong, please try again later!", {
toastId: "error",
});
})
.finally(() => setIsLoading(false));
};
return (
<form className="flex flex-col gap-4 pt-8">
<Input
label="Subject"
type="text"
name="subject"
placeholder="Subject..."
value={subject}
onChange={(e) => null}
disabled
/>
return (
<form className="flex flex-col gap-4 pt-8">
<Input label="Subject" type="text" name="subject" placeholder="Subject..." value={subject} onChange={(e) => null} disabled />
<div className="-md:flex-col 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">
Status
</label>
<Select
options={Object.keys(TicketStatusLabel).map((x) => ({
value: x,
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
}))}
value={{ value: status, label: TicketStatusLabel[status] }}
onChange={(value) =>
setStatus((value?.value as TicketStatus) ?? undefined)
}
placeholder="Status..."
/>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Type
</label>
<Select
options={Object.keys(TicketTypeLabel).map((x) => ({
value: x,
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
}))}
value={{ value: type, label: TicketTypeLabel[type] }}
onChange={(value) => setType(value!.value as TicketType)}
placeholder="Type..."
/>
</div>
</div>
<div className="-md:flex-col 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">Status</label>
<Select
options={Object.keys(TicketStatusLabel).map((x) => ({
value: x,
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
}))}
value={{value: status, label: TicketStatusLabel[status]}}
onChange={(value) => setStatus((value?.value as TicketStatus) ?? undefined)}
placeholder="Status..."
/>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Type</label>
<Select
options={Object.keys(TicketTypeLabel).map((x) => ({
value: x,
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
}))}
value={{value: type, label: TicketTypeLabel[type]}}
onChange={(value) => setType(value!.value as TicketType)}
placeholder="Type..."
/>
</div>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">
Assignee
</label>
<Select
options={[
{ value: "me", label: "Assign to me" },
...users
.filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
.map((u) => ({
value: u.id,
label: `${u.name} - ${u.email}`,
})),
]}
disabled={checkAccess(user, ["agent"])}
value={
assignedTo
? {
value: assignedTo,
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
}
: null
}
onChange={(value) =>
value
? setAssignedTo(value.value === "me" ? user.id : value.value)
: setAssignedTo(null)
}
placeholder="Assignee..."
isClearable
/>
</div>
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Assignee</label>
<Select
options={[
{value: "me", label: "Assign to me"},
...users
.filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
.map((u) => ({
value: u.id,
label: `${u.name} - ${u.email}`,
})),
]}
disabled={checkAccess(user, ["agent"])}
value={
assignedTo
? {
value: assignedTo,
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
}
: null
}
onChange={(value) => (value ? setAssignedTo(value.value === "me" ? user.id : value.value) : setAssignedTo(null))}
placeholder="Assignee..."
isClearable
/>
</div>
<div className="-md:flex-col flex w-full items-center gap-4">
<Input
label="Reported From"
type="text"
name="reportedFrom"
onChange={() => null}
value={reportedFrom}
disabled
/>
<Input
label="Date"
type="text"
name="date"
onChange={() => null}
value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")}
disabled
/>
</div>
<div className="-md:flex-col flex w-full items-center gap-4">
<Input label="Reported From" type="text" name="reportedFrom" onChange={() => null} value={reportedFrom} disabled />
<Input label="Date" type="text" name="date" onChange={() => null} value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")} disabled />
</div>
<div className="-md:flex-col flex w-full items-center gap-4">
<Input
label="Reporter's Name"
type="text"
name="reporter"
onChange={() => null}
value={reporter.name}
disabled
/>
<Input
label="Reporter's E-mail"
type="text"
name="reporter"
onChange={() => null}
value={reporter.email}
disabled
/>
<Input
label="Reporter's Type"
type="text"
name="reporterType"
onChange={() => null}
value={USER_TYPE_LABELS[reporter.type]}
disabled
/>
</div>
<div className="-md:flex-col flex w-full items-center gap-4">
<Input label="Reporter's Name" type="text" name="reporter" onChange={() => null} value={reporter.name} disabled />
<Input label="Reporter's E-mail" type="text" name="reporter" onChange={() => null} value={reporter.email} disabled />
<Input
label="Reporter's Type"
type="text"
name="reporterType"
onChange={() => null}
value={USER_TYPE_LABELS[reporter.type]}
disabled
/>
</div>
<textarea
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
placeholder="Write your ticket's description here..."
contentEditable={false}
value={description}
spellCheck
/>
<textarea
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
placeholder="Write your ticket's description here..."
contentEditable={false}
value={description}
spellCheck
/>
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
<Button
type="button"
color="red"
className="w-full md:max-w-[200px]"
variant="outline"
onClick={del}
isLoading={isLoading}
>
Delete
</Button>
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
<Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={del} isLoading={isLoading}>
Delete
</Button>
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
<Button
type="button"
color="red"
className="w-full md:max-w-[200px]"
variant="outline"
onClick={onClose}
isLoading={isLoading}
>
Cancel
</Button>
<Button
type="button"
className="w-full md:max-w-[200px]"
isLoading={isLoading}
onClick={submit}
>
Update
</Button>
</div>
</div>
</form>
);
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
<Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
Cancel
</Button>
<Button type="button" className="w-full md:max-w-[200px]" isLoading={isLoading} onClick={submit}>
Update
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,39 @@
import { useCallback } from "react";
const HighlightContent: React.FC<{
html: string;
highlightPhrases: string[],
firstOccurence?: boolean
}> = ({
html,
highlightPhrases,
firstOccurence = false
}) => {
const createHighlightedContent = useCallback(() => {
if (highlightPhrases.length === 0) {
return { __html: html };
}
const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'i');
const globalRegex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi');
let highlightedHtml = html;
if (firstOccurence) {
highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
} else {
highlightedHtml = html.replace(globalRegex, (match) => `<span style="background-color: yellow;">${match}</span>`);
}
return { __html: highlightedHtml };
}, [html, highlightPhrases, firstOccurence]);
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
};
export default HighlightContent;

51
src/components/List.tsx Normal file
View File

@@ -0,0 +1,51 @@
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
export default function List<T>({data, columns}: {data: T[]; columns: any[]}) {
const table = useReactTable({
data,
columns: columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder ? null : (
<>
<div
{...{
className: header.column.getCanSort() ? "cursor-pointer select-none py-4 text-left first:pl-4" : "",
onClick: header.column.getToggleSortingHandler(),
}}>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: " 🔼",
desc: " 🔽",
}[header.column.getIsSorted() as string] ?? null}
</div>
</>
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -7,6 +7,7 @@ import {ReactNode, useEffect, useState} from "react";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
import ProgressBar from "../Low/ProgressBar";
import TimerEndedModal from "../TimerEndedModal";
import Timer from "./Timer";
interface Props {
minTimer: number;
@@ -15,35 +16,13 @@ interface Props {
exerciseIndex: number;
totalExercises: number;
disableTimer?: boolean;
partLabel?: string;
showTimer?: boolean;
}
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) {
const [timer, setTimer] = useState(minTimer * 60);
const [showModal, setShowModal] = useState(false);
const [warningMode, setWarningMode] = useState(false);
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
const {timeSpent} = useExamStore((state) => state);
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
useEffect(() => {
if (!disableTimer) {
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
return () => {
clearInterval(timerInterval);
};
}
}, [disableTimer, minTimer]);
useEffect(() => {
if (timer <= 0) setShowModal(true);
}, [timer]);
useEffect(() => {
if (timer < 300 && !warningMode) setWarningMode(true);
}, [timer, warningMode]);
export default function ModuleTitle({
minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel, showTimer = true
}: Props) {
const moduleIcon: {[key in Module]: ReactNode} = {
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
@@ -55,49 +34,39 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
return (
<>
<TimerEndedModal
isOpen={showModal}
onClose={() => {
setHasExamEnded(true);
setShowModal(false);
}}
/>
<motion.div
className={clsx(
"absolute top-4 right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
)}
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
<BsStopwatch className="w-6 h-6" />
<span className="text-base font-semibold w-12">
{timer > 0 && (
<>
{Math.floor(timer / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(timer % 60)
.toString(10)
.padStart(2, "0")}
</>
)}
{timer <= 0 && <>00:00</>}
</span>
</motion.div>
<div className="flex gap-6 w-full h-fit items-center mt-5">
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
<div className="flex flex-col gap-3 w-full">
<div className="w-full flex justify-between">
<span className="text-base font-semibold">
{moduleLabels[module]} exam {label && `- ${label}`}
</span>
<span className="text-sm font-semibold self-end">
Exercise {exerciseIndex}/{totalExercises}
</span>
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
<div className="w-full">
{partLabel && (
<div className="text-3xl space-y-4">
{partLabel.split("\n\n").map((line, index) => {
if (index == 0)
return (
<p key={index} className="font-bold">
{line}
</p>
);
else
return (
<p key={index} className="text-2xl font-semibold">
{line}
</p>
);
})}
</div>
)}
<div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5")}>
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
<div className="flex flex-col gap-3 w-full">
<div className="w-full flex justify-between">
<span className="text-base font-semibold">
{moduleLabels[module]} exam {label && `- ${label}`}
</span>
<span className="text-sm font-semibold self-end">
Question {exerciseIndex}/{totalExercises}
</span>
</div>
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
</div>
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
</div>
</div>
</>

View File

@@ -0,0 +1,80 @@
import useExamStore from "@/stores/examStore";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import TimerEndedModal from "../TimerEndedModal";
import clsx from "clsx";
import { BsStopwatch } from "react-icons/bs";
interface Props {
minTimer: number;
disableTimer?: boolean;
standalone?: boolean;
}
const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) => {
const [timer, setTimer] = useState(minTimer * 60);
const [showModal, setShowModal] = useState(false);
const [warningMode, setWarningMode] = useState(false);
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
const { timeSpent } = useExamStore((state) => state);
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
useEffect(() => {
if (!disableTimer) {
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
return () => {
clearInterval(timerInterval);
};
}
}, [disableTimer, minTimer]);
useEffect(() => {
if (timer <= 0) setShowModal(true);
}, [timer]);
useEffect(() => {
if (timer < 300 && !warningMode) setWarningMode(true);
}, [timer, warningMode]);
return (
<>
<TimerEndedModal
isOpen={showModal}
onClose={() => {
setHasExamEnded(true);
setShowModal(false);
}}
/>
<motion.div
className={clsx(
"absolute right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
standalone ? "top-6" : "top-4",
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
)}
initial={{ scale: warningMode && !disableTimer ? 0.8 : 1 }}
animate={{ scale: warningMode && !disableTimer ? 1.1 : 1 }}
transition={{ repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut" }}>
<BsStopwatch className="w-6 h-6" />
<span className="text-base font-semibold w-12">
{timer > 0 && (
<>
{Math.floor(timer / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(timer % 60)
.toString(10)
.padStart(2, "0")}
</>
)}
{timer <= 0 && <>00:00</>}
</span>
</motion.div>
</>
);
}
export default Timer;

View File

@@ -1,5 +1,6 @@
import React from "react";
import {Permission} from "@/interfaces/permissions";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable, Row} from "@tanstack/react-table";
import Link from "next/link";
import {convertCamelCaseToReadable} from "@/utils/string";
@@ -29,8 +30,18 @@ export default function PermissionList({permissions}: Props) {
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const groupedData: {[key: string]: Row<Permission>[]} = table.getRowModel().rows.reduce((groups: {[key: string]: Row<Permission>[]}, row) => {
const parent = row.original.topic;
if (!groups[parent]) {
groups[parent] = [];
}
groups[parent].push(row);
return groups;
}, {});
return (
<div className="w-full">
<div className="w-full h-full">
<div className="w-full flex flex-col gap-2">
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
@@ -45,14 +56,23 @@ export default function PermissionList({permissions}: Props) {
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{Object.keys(groupedData).map((parent) => (
<React.Fragment key={parent}>
<tr>
<td className="px-2 py-2 items-center w-fit">
<strong>{parent}</strong>
</td>
</tr>
{groupedData[parent].map((row, i) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tr>
</React.Fragment>
))}
</tbody>
</table>

View File

@@ -0,0 +1,80 @@
import { Dialog, Transition } from "@headlessui/react";
import { Fragment } from "react";
import Button from "./Low/Button";
interface Props {
isOpen: boolean;
blankQuestions?: boolean;
finishingWhat? : string;
onClose: (next?: boolean) => void;
}
export default function QuestionsModal({ isOpen, onClose, blankQuestions = true, finishingWhat = "module" }: Props) {
return (
<Transition show={isOpen} as={Fragment}>
<Dialog onClose={() => onClose(false)} className="relative z-50">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black/30" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
{blankQuestions ? (
<>
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
<span>
Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be
able to change the answers of the current one, including your unanswered questions. <br />
<br />
Are you sure you want to continue without completing those questions?
</span>
<div className="w-full flex justify-between mt-8">
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
Go Back
</Button>
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
Continue
</Button>
</div>
</>
): (
<>
<Dialog.Title className="font-bold text-xl">Confirm Submission</Dialog.Title>
<span>
Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be
able to review the answers of the current one. <br />
<br />
Are you sure you want to continue?
</span>
<div className="w-full flex justify-between mt-8">
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
Go Back
</Button>
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
Continue
</Button>
</div>
</>
)}
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
);
}

View File

@@ -13,8 +13,9 @@ import {
BsCurrencyDollar,
BsClipboardData,
BsFileLock,
BsPeople,
} from "react-icons/bs";
import { CiDumbbell } from "react-icons/ci";
import {CiDumbbell} from "react-icons/ci";
import {RiLogoutBoxFill} from "react-icons/ri";
import {SlPencil} from "react-icons/sl";
import {FaAward} from "react-icons/fa";
@@ -28,6 +29,7 @@ import usePreferencesStore from "@/stores/preferencesStore";
import {User} from "@/interfaces/user";
import useTicketsListener from "@/hooks/useTicketsListener";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
interface Props {
path: string;
navDisabled?: boolean;
@@ -80,6 +82,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
const {totalAssignedTickets} = useTicketsListener(user.id);
const {permissions} = usePermissions(user.id);
const logout = async () => {
axios.post("/api/logout").finally(() => {
@@ -98,22 +101,25 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
)}>
<div className="-xl:hidden flex-col gap-3 xl:flex">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
{checkAccess(user, ["student", "teacher", "developer"], "viewExams") && (
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && (
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
)}
{checkAccess(user, ["student", "teacher", "developer"], "viewExercises") && (
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExercises") && (
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
{checkAccess(user, ["developer", "admin", "teacher", "student"], permissions) && (
<Nav disabled={disableNavigation} Icon={BsPeople} label="Groups" path={path} keyPath="/groups" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
)}
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], "viewPaymentRecords") && (
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], permissions, "viewPaymentRecords") && (
<Nav
disabled={disableNavigation}
Icon={BsCurrencyDollar}
@@ -133,7 +139,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
isMinimized={isMinimized}
/>
)}
{checkAccess(user, ["admin", "developer", "agent"], "viewTickets") && (
{checkAccess(user, ["admin", "developer", "agent"], permissions, "viewTickets") && (
<Nav
disabled={disableNavigation}
Icon={BsClipboardData}
@@ -144,38 +150,38 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
badge={totalAssignedTickets}
/>
)}
{checkAccess(user, ["developer", "admin"]) && (
<>
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={isMinimized}
/>
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={isMinimized}
/>
</>
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={isMinimized}
/>
)}
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "agent"]) && (
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={isMinimized}
/>
)}
</div>
<div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["student"])) && (

View File

@@ -1,9 +1,10 @@
import {FillBlanksExercise} from "@/interfaces/exam";
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import clsx from "clsx";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {Fragment} from "react";
import { CommonProps } from ".";
import { Fragment } from "react";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
export default function FillBlanksSolutions({
id,
@@ -12,43 +13,81 @@ export default function FillBlanksSolutions({
solutions,
words,
text,
userSolutions,
onNext,
onBack,
}: FillBlanksExercise & CommonProps) {
// next and back was all messed up and still don't know why, anyways
const storeUserSolutions = useExamStore((state) => state.userSolutions);
const correctUserSolutions = storeUserSolutions.find(
(solution) => solution.exercise === id
)?.solutions;
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
const correct = correctUserSolutions!.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
console.log(solution);
if (!solution) return false;
const option = words.find((w) =>
typeof w === "string" ? w.toLowerCase() === x.solution.toLowerCase() : w.letter.toLowerCase() === x.solution.toLowerCase(),
);
const option = words.find((w) => {
if (typeof w === "string") {
return w.toLowerCase() === x.solution.toLowerCase();
} else if ('letter' in w) {
return w.word.toLowerCase() === x.solution.toLowerCase();
} else {
return w.id.toString() === x.id.toString();
}
});
if (!option) return false;
return solution === (typeof option === "string" ? option.toLowerCase() : option.word.toLowerCase());
if (typeof option === "string") {
return solution.toLowerCase() === option.toLowerCase();
} else if ('letter' in option) {
return solution.toLowerCase() === option.word.toLowerCase();
} else if ('options' in option) {
return option.options[solution as keyof typeof option.options] == x.solution;
}
return false;
}).length;
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing};
const missing = total - correctUserSolutions!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing };
};
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every(
word => word && typeof word === 'object' && 'id' in word && 'options' in word
);
}
const renderLines = (line: string) => {
return (
<span>
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id);
const solution = solutions.find((x) => x.id === id)!;
const userSolution = correctUserSolutions!.find((x) => x.id.toString() === id.toString());
const answerSolution = solutions.find(sol => sol.id.toString() === id.toString())!.solution;
if (!userSolution) {
let answerText;
if (typeCheckWordsMC(words)) {
const options = words.find((x) => x.id.toString() === id.toString());
const correctKey = Object.keys(options!.options).find(key =>
key.toLowerCase() === answerSolution.toLowerCase()
);
answerText = options!.options[correctKey as keyof typeof options];
} else {
answerText = answerSolution;
}
return (
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-gray-davy transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-gray-davy",
)}>
{solution?.solution}
{answerText}
</button>
);
}
@@ -56,23 +95,53 @@ export default function FillBlanksSolutions({
const userSolutionWord = words.find((w) =>
typeof w === "string"
? w.toLowerCase() === userSolution.solution.toLowerCase()
: w.letter.toLowerCase() === userSolution.solution.toLowerCase(),
: 'letter' in w
? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
: 'options' in w
? w.id === userSolution.id
: false
);
const userSolutionText = typeof userSolutionWord === "string" ? userSolutionWord : userSolutionWord?.word;
if (userSolutionText === solution.solution) {
const userSolutionText =
typeof userSolutionWord === "string"
? userSolutionWord
: userSolutionWord && 'letter' in userSolutionWord
? userSolutionWord.word
: userSolutionWord && 'options' in userSolutionWord
? userSolution.solution
: userSolution.solution;
let correct;
let solutionText;
if (typeCheckWordsMC(words)) {
const options = words.find((x) => x.id.toString() === id.toString());
if (options) {
const correctKey = Object.keys(options.options).find(key =>
key.toLowerCase() === answerSolution.toLowerCase()
);
correct = userSolution.solution == options.options[correctKey as keyof typeof options.options];
solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution;
} else {
correct = false;
solutionText = answerSolution;
}
} else {
correct = userSolutionText === answerSolution;
solutionText = answerSolution;
}
if (correct) {
return (
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)}>
{solution.solution}
{solutionText}
</button>
);
}
if (userSolutionText !== solution.solution) {
} else {
return (
<>
<button
@@ -88,7 +157,7 @@ export default function FillBlanksSolutions({
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)}>
{solution.solution}
{solutionText}
</button>
</>
);
@@ -101,16 +170,8 @@ export default function FillBlanksSolutions({
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{userSolutions &&
{correctUserSolutions &&
text.split("\\n").map((line, index) => (
<p key={index}>
{renderLines(line)}
@@ -138,14 +199,14 @@ export default function FillBlanksSolutions({
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
onClick={() => onBack({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
onClick={() => onNext({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })}
className="max-w-[200px] self-end w-full">
Next
</Button>

View File

@@ -1,17 +1,17 @@
/* eslint-disable @next/next/no-img-element */
import {InteractiveSpeakingExercise} from "@/interfaces/exam";
import {CommonProps} from ".";
import {useEffect, useState} from "react";
import { InteractiveSpeakingExercise } from "@/interfaces/exam";
import { CommonProps } from ".";
import { useEffect, useState } from "react";
import Button from "../Low/Button";
import dynamic from "next/dynamic";
import axios from "axios";
import {speakingReverseMarking} from "@/utils/score";
import {Tab} from "@headlessui/react";
import { speakingReverseMarking } from "@/utils/score";
import { Tab } from "@headlessui/react";
import clsx from "clsx";
import Modal from "../Modal";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
export default function InteractiveSpeaking({
id,
@@ -26,13 +26,20 @@ export default function InteractiveSpeaking({
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
const [diffNumber, setDiffNumber] = useState(0);
const tooltips: { [key: string]: string } = {
"Grammatical Range and Accuracy": "Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
"Fluency and Coherence": "Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
"Pronunciation": "Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
"Lexical Resource": "Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
};
useEffect(() => {
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then(
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, { path: x.answer }, { responseType: "arraybuffer" }))).then(
(values) => {
setSolutionsURL(
values.map(({data}) => {
const blob = new Blob([data], {type: "audio/wav"});
values.map(({ data }) => {
const blob = new Blob([data], { type: "audio/wav" });
const url = URL.createObjectURL(blob);
return url;
@@ -64,13 +71,13 @@ export default function InteractiveSpeaking({
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px",
},
marker: {display: "none"},
diffRemoved: {padding: "32px 28px"},
diffAdded: {padding: "32px 28px"},
marker: { display: "none" },
diffRemoved: { padding: "32px 28px" },
diffAdded: { padding: "32px 28px" },
wordRemoved: {padding: "0px", display: "initial"},
wordAdded: {padding: "0px", display: "initial"},
wordDiff: {padding: "0px", display: "initial"},
wordRemoved: { padding: "0px", display: "initial" },
wordAdded: { padding: "0px", display: "initial" },
wordDiff: { padding: "0px", display: "initial" },
}}
oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")}
@@ -132,12 +139,14 @@ export default function InteractiveSpeaking({
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}>
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
index === 0 && "tooltip-right"
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade}
</div>
);
@@ -148,7 +157,7 @@ export default function InteractiveSpeaking({
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
<Tab
className={({selected}) =>
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
@@ -159,7 +168,7 @@ export default function InteractiveSpeaking({
General Feedback
</Tab>
<Tab
className={({selected}) =>
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
@@ -232,7 +241,7 @@ export default function InteractiveSpeaking({
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
score: { total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
type,
})
}

View File

@@ -1,10 +1,10 @@
/* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import {useEffect, useState} from "react";
import { useEffect, useState } from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import { CommonProps } from ".";
import Button from "../Low/Button";
function Question({
@@ -14,20 +14,51 @@ function Question({
solution,
options,
userSolution,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
}: MultipleChoiceQuestion & { userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean }) {
const { userSolutions } = useExamStore((state) => state);
const getShuffledOptions = (options: { id: string, text: string }[], questionShuffleMap: ShuffleMap) => {
const shuffledOptions = ['A', 'B', 'C', 'D'].map(newId => {
const originalId = questionShuffleMap.map[newId];
const originalOption = options.find(option => option.id === originalId);
return {
id: newId,
text: originalOption!.text
};
});
return shuffledOptions;
}
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
if (originalPosition === originalSolution) {
return newPosition;
}
}
return originalSolution;
}
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
if (foundMap) return foundMap;
return userSolution.shuffleMaps?.find(map => map.id === id) || null;
}, null as ShuffleMap | null);
const questionOptions = questionShuffleMap ? getShuffledOptions(options as { id: string, text: string }[], questionShuffleMap) : options;
const newSolution = questionShuffleMap ? getShuffledSolution(solution, questionShuffleMap) : solution;
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => {
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u>{word}</u> : null;
});
};
const optionColor = (option: string) => {
if (option === solution && !userSolution) {
if (option === newSolution && !userSolution) {
return "!border-mti-gray-davy !text-mti-gray-davy";
}
if (option === solution) {
if (option === newSolution) {
return "!border-mti-purple-light !text-mti-purple-light";
}
@@ -35,36 +66,36 @@ function Question({
};
return (
<div className="flex flex-col items-center gap-4">
<div className="flex flex-col gap-4">
{isNaN(Number(id)) ? (
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
) : (
<span className="">
<span className="text-lg">
<>
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
{id} - <span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
</>
</span>
)}
<div className="grid grid-cols-4 gap-4 place-items-center">
<div className="flex flex-wrap gap-4 justify-between">
{variant === "image" &&
options.map((option) => (
questionOptions.map((option) => (
<div
key={option.id}
key={option?.id}
className={clsx(
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
optionColor(option.id),
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
optionColor(option!.id),
)}>
<span className={clsx("text-sm", solution !== option.id && userSolution !== option.id && "opacity-50")}>{option.id}</span>
<img src={option.src!} alt={`Option ${option.id}`} />
<span className={clsx("text-sm", newSolution !== option?.id && userSolution !== option?.id && "opacity-50")}>{option?.id}</span>
{"src" in option && <img src={option?.src!} alt={`Option ${option?.id}`} />}
</div>
))}
{variant === "text" &&
options.map((option) => (
questionOptions.map((option) => (
<div
key={option.id}
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", optionColor(option.id))}>
<span className="font-semibold">{option.id}.</span>
<span>{option.text}</span>
key={option?.id}
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none", optionColor(option!.id))}>
<span className="font-semibold">{option?.id}.</span>
<span>{option?.text}</span>
</div>
))}
</div>
@@ -72,8 +103,9 @@ function Question({
);
}
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) {
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
const calculateScore = () => {
const total = questions.length;
@@ -82,12 +114,12 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
).length;
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct, missing};
return { total, correct, missing };
};
const next = () => {
if (questionIndex === questions.length - 1) {
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type });
} else {
setQuestionIndex(questionIndex + 1);
}
@@ -95,7 +127,7 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
const back = () => {
if (questionIndex === 0) {
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type });
} else {
setQuestionIndex(questionIndex - 1);
}
@@ -105,7 +137,7 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
<>
<div className="flex flex-col gap-4 w-full h-full mb-20">
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<span className="text-xl font-semibold">{prompt}</span>
{/*<span className="text-xl font-semibold">{prompt}</span>*/}
{userSolutions && questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
@@ -127,10 +159,14 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
Wrong
</div>
</div>
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full"
disabled={
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
typeof exam.parts[0].intro === "string" && questionIndex === 0 && partIndex === 0}
>
Back
</Button>

View File

@@ -1,20 +1,20 @@
/* eslint-disable @next/next/no-img-element */
import {SpeakingExercise} from "@/interfaces/exam";
import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react";
import { SpeakingExercise } from "@/interfaces/exam";
import { CommonProps } from ".";
import { Fragment, useEffect, useState } from "react";
import Button from "../Low/Button";
import dynamic from "next/dynamic";
import axios from "axios";
import {speakingReverseMarking} from "@/utils/score";
import {Tab} from "@headlessui/react";
import { speakingReverseMarking } from "@/utils/score";
import { Tab } from "@headlessui/react";
import clsx from "clsx";
import Modal from "../Modal";
import {BsQuestionCircleFill} from "react-icons/bs";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
import { BsQuestionCircleFill } from "react-icons/bs";
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
export default function Speaking({ id, type, title, video_url, text, prompts, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) {
const [solutionURL, setSolutionURL] = useState<string>();
const [showDiff, setShowDiff] = useState(false);
@@ -23,8 +23,8 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
const solution = userSolutions[0].solution;
if (solution.startsWith("https://")) return setSolutionURL(solution);
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
const blob = new Blob([data], {type: "audio/wav"});
axios.post(`/api/speaking`, { path: userSolutions[0].solution }, { responseType: "arraybuffer" }).then(({ data }) => {
const blob = new Blob([data], { type: "audio/wav" });
const url = URL.createObjectURL(blob);
setSolutionURL(url);
@@ -32,6 +32,13 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
}
}, [userSolutions]);
const tooltips: { [key: string]: string } = {
"Grammatical Range and Accuracy": "Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
"Fluency and Coherence": "Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
"Pronunciation": "Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
"Lexical Resource": "Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
};
return (
<>
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
@@ -51,13 +58,13 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px",
},
marker: {display: "none"},
diffRemoved: {padding: "32px 28px"},
diffAdded: {padding: "32px 28px"},
marker: { display: "none" },
diffRemoved: { padding: "32px 28px" },
diffAdded: { padding: "32px 28px" },
wordRemoved: {padding: "0px", display: "initial"},
wordAdded: {padding: "0px", display: "initial"},
wordDiff: {padding: "0px", display: "initial"},
wordRemoved: { padding: "0px", display: "initial" },
wordAdded: { padding: "0px", display: "initial" },
wordDiff: { padding: "0px", display: "initial" },
}}
oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")}
@@ -126,12 +133,14 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}>
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
index === 0 && "tooltip-right"
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade}
</div>
);
@@ -142,7 +151,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
<Tab
className={({selected}) =>
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",

View File

@@ -49,15 +49,11 @@ function Blank({
{userSolution && !isUserSolutionCorrect() && (
<div
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
placeholder={id}
contentEditable={disabled}>
{userSolution}
</div>
)}
<div
className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())}
placeholder={id}
contentEditable={disabled}>
<div className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())} contentEditable={disabled}>
{!solutions ? userInput : solutions.join(" / ")}
</div>
</span>

View File

@@ -1,23 +1,30 @@
/* eslint-disable @next/next/no-img-element */
import {WritingExercise} from "@/interfaces/exam";
import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react";
import { WritingExercise } from "@/interfaces/exam";
import { CommonProps } from ".";
import { Fragment, useEffect, useState } from "react";
import Button from "../Low/Button";
import {Dialog, Tab, Transition} from "@headlessui/react";
import {writingReverseMarking} from "@/utils/score";
import { Dialog, Tab, Transition } from "@headlessui/react";
import { writingReverseMarking } from "@/utils/score";
import clsx from "clsx";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer";
import useUser from "@/hooks/useUser";
import AIDetection from "../AIDetection";
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
export default function Writing({ id, type, prompt, attachment, userSolutions, onNext, onBack }: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [showDiff, setShowDiff] = useState(false);
const {user} = useUser();
const { user } = useUser();
const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined;
const tooltips: { [key: string]: string } = {
"Lexical Resource": "Assesses the diversity and accuracy of vocabulary used. A higher score indicates varied and precise word choice; a lower score points to limited vocabulary and inaccuracies.",
"Task Achievement": "Evaluates how well the task requirements are fulfilled. A higher score means all parts of the task are addressed thoroughly; a lower score shows incomplete or inadequate task response.",
"Coherence and Cohesion": "Measures logical organization and flow of writing. A higher score reflects well-structured and connected ideas; a lower score indicates disorganized writing and poor linkage between ideas.",
"Grammatical Range and Accuracy": "Looks at the range and precision of grammatical structures. A higher score shows varied and accurate grammar use; a lower score suggests frequent errors and limited range.",
};
return (
<>
{attachment && (
@@ -92,13 +99,13 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px",
},
marker: {display: "none"},
diffRemoved: {padding: "32px 28px"},
diffAdded: {padding: "32px 28px"},
marker: { display: "none" },
diffRemoved: { padding: "32px 28px" },
diffAdded: { padding: "32px 28px" },
wordRemoved: {padding: "0px", display: "initial"},
wordAdded: {padding: "0px", display: "initial"},
wordDiff: {padding: "0px", display: "initial"},
wordRemoved: { padding: "0px", display: "initial" },
wordAdded: { padding: "0px", display: "initial" },
wordDiff: { padding: "0px", display: "initial" },
}}
oldValue={userSolutions[0].solution.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation!.fixed_text!.replaceAll("\\n", "\n")}
@@ -123,12 +130,15 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div className={clsx("bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2")} key={key}>
<div className={clsx(
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
index === 0 && "tooltip-right"
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade}
</div>
);
@@ -138,7 +148,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
<Tab
className={({selected}) =>
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
@@ -149,7 +159,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
General Feedback
</Tab>
<Tab
className={({selected}) =>
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
@@ -160,7 +170,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
Evaluation
</Tab>
<Tab
className={({selected}) =>
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
@@ -172,7 +182,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
</Tab>
{aiEval && user?.type !== "student" && (
<Tab
className={({selected}) =>
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
@@ -238,7 +248,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
score: { total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
type,
})
}

View File

@@ -1,23 +0,0 @@
import { useCallback } from "react";
const HighlightedContent: React.FC<{ html: string; highlightPhrases: string[] }> = ({ html, highlightPhrases }) => {
const createHighlightedContent = useCallback(() => {
if (highlightPhrases.length === 0) {
return { __html: html };
}
const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi');
const highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
return { __html: highlightedHtml };
}, [html, highlightPhrases]);
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
};
export default HighlightedContent;

View File

@@ -1,287 +1,280 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { animated } from '@react-spring/web';
import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6";
import HighlightedContent from './AnimatedHighlight';
import { ITrainingTip, SegmentRef, TimelineEvent } from './TrainingInterfaces';
import React, {useState, useEffect, useRef, useCallback} from "react";
import {animated} from "@react-spring/web";
import {FaRegCirclePlay, FaRegCircleStop} from "react-icons/fa6";
import HighlightContent from "../HighlightContent";
import {ITrainingTip, SegmentRef, TimelineEvent} from "./TrainingInterfaces";
const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(0);
const [walkthroughHtml, setWalkthroughHtml] = useState<string>('');
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const timelineRef = useRef<TimelineEvent[]>([]);
const animationRef = useRef<number | null>(null);
const segmentsRef = useRef<SegmentRef[]>([]);
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(0);
const [walkthroughHtml, setWalkthroughHtml] = useState<string>("");
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const timelineRef = useRef<TimelineEvent[]>([]);
const animationRef = useRef<number | null>(null);
const segmentsRef = useRef<SegmentRef[]>([]);
const toggleAutoPlay = useCallback(() => {
setIsAutoPlaying((prev) => {
if (!prev && currentTime === getMaxTime()) {
setCurrentTime(0);
}
return !prev;
});
}, [currentTime]);
const toggleAutoPlay = useCallback(() => {
setIsAutoPlaying((prev) => {
if (!prev && currentTime === getMaxTime()) {
setCurrentTime(0);
}
return !prev;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTime]);
const handleAnimationComplete = useCallback(() => {
setIsAutoPlaying(false);
}, []);
const handleAnimationComplete = useCallback(() => {
setIsAutoPlaying(false);
}, []);
const handleResetAnimation = useCallback((newTime: number) => {
setCurrentTime(newTime);
}, []);
const handleResetAnimation = useCallback((newTime: number) => {
setCurrentTime(newTime);
}, []);
const getMaxTime = (): number => {
return tip.exercise?.segments.reduce((sum, segment) =>
sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0
) ?? 0;
};
const getMaxTime = (): number => {
return (
tip.exercise?.segments.reduce((sum, segment) => sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0) ?? 0
);
};
useEffect(() => {
const timeline: TimelineEvent[] = [];
let currentTimePosition = 0;
segmentsRef.current = [];
useEffect(() => {
const timeline: TimelineEvent[] = [];
let currentTimePosition = 0;
segmentsRef.current = [];
tip.exercise?.segments.forEach((segment, index) => {
const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, 'text/html');
const words: string[] = [];
const walkTree = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
words.push(...(node.textContent?.split(/\s+/).filter(word => word.length > 0) || []));
} else if (node.nodeType === Node.ELEMENT_NODE) {
Array.from(node.childNodes).forEach(walkTree);
}
};
walkTree(doc.body);
tip.exercise?.segments.forEach((segment, index) => {
const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, "text/html");
const words: string[] = [];
const walkTree = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
words.push(...(node.textContent?.split(/\s+/).filter((word) => word.length > 0) || []));
} else if (node.nodeType === Node.ELEMENT_NODE) {
Array.from(node.childNodes).forEach(walkTree);
}
};
walkTree(doc.body);
const textDuration = words.length * segment.wordDelay;
const textDuration = words.length * segment.wordDelay;
segmentsRef.current.push({
...segment,
words: words,
startTime: currentTimePosition,
endTime: currentTimePosition + textDuration
});
segmentsRef.current.push({
...segment,
words: words,
startTime: currentTimePosition,
endTime: currentTimePosition + textDuration,
});
timeline.push({
type: 'text',
start: currentTimePosition,
end: currentTimePosition + textDuration,
segmentIndex: index
});
timeline.push({
type: "text",
start: currentTimePosition,
end: currentTimePosition + textDuration,
segmentIndex: index,
});
currentTimePosition += textDuration;
currentTimePosition += textDuration;
timeline.push({
type: 'highlight',
start: currentTimePosition,
end: currentTimePosition + segment.holdDelay,
content: segment.highlight,
segmentIndex: index
});
timeline.push({
type: "highlight",
start: currentTimePosition,
end: currentTimePosition + segment.holdDelay,
content: segment.highlight,
segmentIndex: index,
});
currentTimePosition += segment.holdDelay;
});
currentTimePosition += segment.holdDelay;
});
timelineRef.current = timeline;
}, [tip.exercise?.segments]);
timelineRef.current = timeline;
}, [tip.exercise?.segments]);
const updateText = useCallback(() => {
const currentEvent = timelineRef.current.find(
event => currentTime >= event.start && currentTime < event.end
);
const updateText = useCallback(() => {
const currentEvent = timelineRef.current.find((event) => currentTime >= event.start && currentTime < event.end);
if (currentEvent) {
if (currentEvent.type === 'text') {
const segment = segmentsRef.current[currentEvent.segmentIndex];
const elapsedTime = currentTime - currentEvent.start;
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
if (currentEvent) {
if (currentEvent.type === "text") {
const segment = segmentsRef.current[currentEvent.segmentIndex];
const elapsedTime = currentTime - currentEvent.start;
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
const previousSegmentsHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex)
.map(seg => seg.html)
.join('');
const previousSegmentsHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex)
.map((seg) => seg.html)
.join("");
const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, 'text/html');
let wordCount = 0;
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
const words = node.textContent.split(/(\s+)/).filter(word => word.length > 0);
if (wordCount + words.filter(w => !/\s+/.test(w)).length <= wordsToShow) {
action(node.cloneNode(true));
wordCount += words.filter(w => !/\s+/.test(w)).length;
} else {
const remainingWords = wordsToShow - wordCount;
const newTextContent = words.reduce((acc, word) => {
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
acc.text += word;
acc.nonSpaceWords++;
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
acc.text += word;
}
return acc;
}, { text: '', nonSpaceWords: 0 }).text;
const newNode = node.cloneNode(false);
newNode.textContent = newTextContent;
action(newNode);
wordCount = wordsToShow;
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const clone = node.cloneNode(false);
action(clone);
Array.from(node.childNodes).some(child => {
return walkTree(child, childNode => (clone as Node).appendChild(childNode));
});
}
return wordCount >= wordsToShow;
};
const fragment = document.createDocumentFragment();
walkTree(doc.body, node => fragment.appendChild(node));
const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, "text/html");
let wordCount = 0;
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
const words = node.textContent.split(/(\s+)/).filter((word) => word.length > 0);
if (wordCount + words.filter((w) => !/\s+/.test(w)).length <= wordsToShow) {
action(node.cloneNode(true));
wordCount += words.filter((w) => !/\s+/.test(w)).length;
} else {
const remainingWords = wordsToShow - wordCount;
const newTextContent = words.reduce(
(acc, word) => {
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
acc.text += word;
acc.nonSpaceWords++;
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
acc.text += word;
}
return acc;
},
{text: "", nonSpaceWords: 0},
).text;
const newNode = node.cloneNode(false);
newNode.textContent = newTextContent;
action(newNode);
wordCount = wordsToShow;
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const clone = node.cloneNode(false);
action(clone);
Array.from(node.childNodes).some((child) => {
return walkTree(child, (childNode) => (clone as Node).appendChild(childNode));
});
}
return wordCount >= wordsToShow;
};
const fragment = document.createDocumentFragment();
walkTree(doc.body, (node) => fragment.appendChild(node));
const serializer = new XMLSerializer();
const currentSegmentHtml = Array.from(fragment.childNodes)
.map(node => serializer.serializeToString(node))
.join('');
const newHtml = previousSegmentsHtml + currentSegmentHtml;
const serializer = new XMLSerializer();
const currentSegmentHtml = Array.from(fragment.childNodes)
.map((node) => serializer.serializeToString(node))
.join("");
const newHtml = previousSegmentsHtml + currentSegmentHtml;
setWalkthroughHtml(newHtml);
setHighlightedPhrases([]);
} else if (currentEvent.type === 'highlight') {
const newHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex + 1)
.map(seg => seg.html)
.join('');
setWalkthroughHtml(newHtml);
setHighlightedPhrases(currentEvent.content || []);
}
}
}, [currentTime]);
setWalkthroughHtml(newHtml);
setHighlightedPhrases([]);
} else if (currentEvent.type === "highlight") {
const newHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex + 1)
.map((seg) => seg.html)
.join("");
setWalkthroughHtml(newHtml);
setHighlightedPhrases(currentEvent.content || []);
}
}
}, [currentTime]);
useEffect(() => {
updateText();
}, [currentTime, updateText]);
useEffect(() => {
updateText();
}, [currentTime, updateText]);
useEffect(() => {
if (isAutoPlaying) {
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
if (lastEvent && currentTime >= lastEvent.end) {
setCurrentTime(0);
}
setIsPlaying(true);
} else {
setIsPlaying(false);
}
}, [isAutoPlaying, currentTime]);
useEffect(() => {
if (isAutoPlaying) {
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
if (lastEvent && currentTime >= lastEvent.end) {
setCurrentTime(0);
}
setIsPlaying(true);
} else {
setIsPlaying(false);
}
}, [isAutoPlaying, currentTime]);
useEffect(() => {
const animate = () => {
if (isPlaying) {
setCurrentTime((prevTime) => {
const newTime = prevTime + 50;
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
if (lastEvent && newTime >= lastEvent.end) {
setIsPlaying(false);
handleAnimationComplete();
return lastEvent.end;
}
return newTime;
});
}
animationRef.current = requestAnimationFrame(animate);
};
useEffect(() => {
const animate = () => {
if (isPlaying) {
setCurrentTime((prevTime) => {
const newTime = prevTime + 50;
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
if (lastEvent && newTime >= lastEvent.end) {
setIsPlaying(false);
handleAnimationComplete();
return lastEvent.end;
}
return newTime;
});
}
animationRef.current = requestAnimationFrame(animate);
};
animationRef.current = requestAnimationFrame(animate);
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [isPlaying, handleAnimationComplete]);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [isPlaying, handleAnimationComplete]);
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = parseInt(e.target.value, 10);
setCurrentTime(newTime);
handleResetAnimation(newTime);
};
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = parseInt(e.target.value, 10);
setCurrentTime(newTime);
handleResetAnimation(newTime);
};
const handleSliderMouseDown = () => {
setIsPlaying(false);
};
const handleSliderMouseDown = () => {
setIsPlaying(false);
};
const handleSliderMouseUp = () => {
if (isAutoPlaying) {
setIsPlaying(true);
}
};
const handleSliderMouseUp = () => {
if (isAutoPlaying) {
setIsPlaying(true);
}
};
if (tip.standalone || !tip.exercise) {
return (
<div className="container mx-auto">
<h1 className='text-xl font-bold text-red-600'>The exercise for this tip is not available yet!</h1>
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} />
</div>
</div>
);
}
if (tip.standalone || !tip.exercise) {
return (
<div className="container mx-auto">
<h1 className="text-xl font-bold text-red-600">The exercise for this tip is not available yet!</h1>
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
</div>
</div>
);
}
return (
<div className="container mx-auto">
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} />
</div>
<div className='flex flex-col space-y-4'>
<div className='flex flex-row items-center space-x-4 py-4'>
<button
onClick={toggleAutoPlay}
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
aria-label={isAutoPlaying ? 'Pause' : 'Play'}
>
{isAutoPlaying ? (
<FaRegCircleStop className="w-6 h-6" />
) : (
<FaRegCirclePlay className="w-6 h-6" />
)}
</button>
<input
type="range"
min="0"
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
value={currentTime}
onChange={handleSliderChange}
onMouseDown={handleSliderMouseDown}
onMouseUp={handleSliderMouseUp}
onTouchStart={handleSliderMouseDown}
onTouchEnd={handleSliderMouseUp}
className='flex-grow'
/>
</div>
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4'>
<div className='flex-1 bg-white p-6 rounded-lg shadow'>
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
<div className="mb-4" dangerouslySetInnerHTML={{ __html: tip.exercise.question }} />
<HighlightedContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
</div>
<div className='flex-1'>
<div className='bg-gray-50 rounded-lg shadow'>
<div className='p-6 space-y-4'>
<animated.div
dangerouslySetInnerHTML={{ __html: walkthroughHtml }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
return (
<div className="container mx-auto">
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
</div>
<div className="flex flex-col space-y-4">
<div className="flex flex-row items-center space-x-4 py-4">
<button
onClick={toggleAutoPlay}
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
aria-label={isAutoPlaying ? "Pause" : "Play"}>
{isAutoPlaying ? <FaRegCircleStop className="w-6 h-6" /> : <FaRegCirclePlay className="w-6 h-6" />}
</button>
<input
type="range"
min="0"
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
value={currentTime}
onChange={handleSliderChange}
onMouseDown={handleSliderMouseDown}
onMouseUp={handleSliderMouseUp}
onTouchStart={handleSliderMouseDown}
onTouchEnd={handleSliderMouseUp}
className="flex-grow"
/>
</div>
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
<div className="flex-1 bg-white p-6 rounded-lg shadow">
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
<div className="mb-4" dangerouslySetInnerHTML={{__html: tip.exercise.question}} />
<HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
</div>
<div className="flex-1">
<div className="bg-gray-50 rounded-lg shadow">
<div className="p-6 space-y-4">
<animated.div dangerouslySetInnerHTML={{__html: walkthroughHtml}} />
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default ExerciseWalkthrough;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-neutral-200 bg-white p-4 text-neutral-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

File diff suppressed because it is too large Load Diff

View File

@@ -2,17 +2,12 @@
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import { User } from "@/interfaces/user";
import {User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import { dateSorter } from "@/utils";
import {dateSorter} from "@/utils";
import moment from "moment";
import { useEffect, useState } from "react";
import {
BsArrowLeft,
BsPersonFill,
BsBank,
BsCurrencyDollar,
} from "react-icons/bs";
import {useEffect, useState} from "react";
import {BsArrowLeft, BsPersonFill, BsBank, BsCurrencyDollar} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
@@ -20,276 +15,235 @@ import IconCard from "./IconCard";
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
interface Props {
user: User;
user: User;
}
export default function AgentDashboard({ user }: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
export default function AgentDashboard({user}: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const { stats } = useStats();
const { users, reload } = useUsers();
const { groups } = useGroups(user.id);
const { pending, done } = usePaymentStatusUsers();
const {stats} = useStats();
const {users, reload} = useUsers();
const {pending, done} = usePaymentStatusUsers();
useEffect(() => {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
useEffect(() => {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
const corporateFilter = (user: User) => user.type === "corporate";
const referredCorporateFilter = (x: User) =>
x.type === "corporate" &&
!!x.corporateInformation &&
x.corporateInformation.referralAgent === user.id;
const inactiveReferredCorporateFilter = (x: User) =>
referredCorporateFilter(x) &&
(x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
const corporateFilter = (user: User) => user.type === "corporate";
const referredCorporateFilter = (x: User) =>
x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
const inactiveReferredCorporateFilter = (x: User) =>
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
const UserDisplay = ({
displayUser,
allowClick = true,
}: {
displayUser: User;
allowClick?: boolean;
}) => (
<div
onClick={() => allowClick && setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
>
<img
src={displayUser.profilePicture}
alt={displayUser.name}
className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start">
<span>
{displayUser.type === "corporate"
? displayUser.corporateInformation?.companyInformation?.name ||
displayUser.name
: displayUser.name}
</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const UserDisplay = ({displayUser, allowClick = true}: {displayUser: User; allowClick?: boolean}) => (
<div
onClick={() => allowClick && setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>
{displayUser.type === "corporate"
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
: displayUser.name}
</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const ReferredCorporateList = () => {
return (
<UserList
user={user}
filters={[referredCorporateFilter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">
Referred Corporate ({total})
</h2>
</div>
)}
/>
);
};
const ReferredCorporateList = () => {
return (
<UserList
user={user}
filters={[referredCorporateFilter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Referred Corporate ({total})</h2>
</div>
)}
/>
);
};
const InactiveReferredCorporateList = () => {
return (
<UserList
user={user}
filters={[inactiveReferredCorporateFilter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">
Inactive Referred Corporate ({total})
</h2>
</div>
)}
/>
);
};
const InactiveReferredCorporateList = () => {
return (
<UserList
user={user}
filters={[inactiveReferredCorporateFilter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Inactive Referred Corporate ({total})</h2>
</div>
)}
/>
);
};
const CorporateList = () => {
const filter = (x: User) => x.type === "corporate";
const CorporateList = () => {
const filter = (x: User) => x.type === "corporate";
return (
<UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Corporate ({total})</h2>
</div>
)}
/>
);
};
return (
<UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Corporate ({total})</h2>
</div>
)}
/>
);
};
const CorporatePaidStatusList = ({ paid }: { paid: Boolean }) => {
const list = paid ? done : pending;
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
const CorporatePaidStatusList = ({paid}: {paid: Boolean}) => {
const list = paid ? done : pending;
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
return (
<UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">
{paid ? "Payment Done" : "Pending Payment"} ({total})
</h2>
</div>
)}
/>
);
};
return (
<UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">
{paid ? "Payment Done" : "Pending Payment"} ({total})
</h2>
</div>
)}
/>
);
};
const DefaultDashboard = () => (
<>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
<IconCard
onClick={() => setPage("referredCorporate")}
Icon={BsBank}
label="Referred Corporate"
value={users.filter(referredCorporateFilter).length}
color="purple"
/>
<IconCard
onClick={() => setPage("inactiveReferredCorporate")}
Icon={BsBank}
label="Inactive Referred Corporate"
value={users.filter(inactiveReferredCorporateFilter).length}
color="rose"
/>
<IconCard
onClick={() => setPage("corporate")}
Icon={BsBank}
label="Corporate"
value={users.filter(corporateFilter).length}
color="purple"
/>
<IconCard
onClick={() => setPage("paymentdone")}
Icon={BsCurrencyDollar}
label="Payment Done"
value={done.length}
color="purple"
/>
<IconCard
onClick={() => setPage("paymentpending")}
Icon={BsCurrencyDollar}
label="Pending Payment"
value={pending.length}
color="rose"
/>
</section>
const DefaultDashboard = () => (
<>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
<IconCard
onClick={() => setPage("referredCorporate")}
Icon={BsBank}
label="Referred Corporate"
value={users.filter(referredCorporateFilter).length}
color="purple"
/>
<IconCard
onClick={() => setPage("inactiveReferredCorporate")}
Icon={BsBank}
label="Inactive Referred Corporate"
value={users.filter(inactiveReferredCorporateFilter).length}
color="rose"
/>
<IconCard
onClick={() => setPage("corporate")}
Icon={BsBank}
label="Corporate"
value={users.filter(corporateFilter).length}
color="purple"
/>
<IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
<IconCard
onClick={() => setPage("paymentpending")}
Icon={BsCurrencyDollar}
label="Pending Payment"
value={pending.length}
color="rose"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest Referred Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(referredCorporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} displayUser={x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(corporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} displayUser={x} allowClick={false} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Referenced corporate expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
referredCorporateFilter(x) &&
moment().isAfter(
moment(x.subscriptionExpirationDate).subtract(30, "days")
) &&
moment().isBefore(moment(x.subscriptionExpirationDate))
)
.map((x) => (
<UserDisplay key={x.id} displayUser={x} />
))}
</div>
</div>
</section>
</>
);
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest Referred Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(referredCorporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} displayUser={x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(corporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} displayUser={x} allowClick={false} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Referenced corporate expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
referredCorporateFilter(x) &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} displayUser={x} />
))}
</div>
</div>
</section>
</>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" ||
selectedUser.type === "teacher"
? () => setPage("students")
: undefined
}
onViewTeachers={
selectedUser.type === "corporate"
? () => setPage("teachers")
: undefined
}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{page === "referredCorporate" && <ReferredCorporateList />}
{page === "corporate" && <CorporateList />}
{page === "inactiveReferredCorporate" && (
<InactiveReferredCorporateList />
)}
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
{page === "" && <DefaultDashboard />}
</>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
}
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{page === "referredCorporate" && <ReferredCorporateList />}
{page === "corporate" && <CorporateList />}
{page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
{page === "" && <DefaultDashboard />}
</>
);
}

View File

@@ -10,13 +10,17 @@ import {usePDFDownload} from "@/hooks/usePDFDownload";
import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
import {uniqBy} from "lodash";
import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
import {getUserName} from "@/utils/users";
import {User} from "@/interfaces/user";
interface Props {
users: User[];
onClick?: () => void;
allowDownload?: boolean;
reload?: Function;
allowArchive?: boolean;
allowUnarchive?: boolean;
allowExcelDownload?: boolean;
}
export default function AssignmentCard({
@@ -34,8 +38,11 @@ export default function AssignmentCard({
reload,
allowArchive,
allowUnarchive,
allowExcelDownload,
users,
}: Assignment & Props) {
const renderPdfIcon = usePDFDownload("assignments");
const renderExcelIcon = usePDFDownload("assignments", "excel");
const renderArchiveIcon = useAssignmentArchive(id, reload);
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
@@ -60,6 +67,7 @@ export default function AssignmentCard({
<h3 className="text-xl font-semibold">{name}</h3>
<div className="flex gap-2">
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{allowExcelDownload && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
</div>
@@ -72,11 +80,14 @@ export default function AssignmentCard({
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
/>
</div>
<span className="flex justify-between gap-1">
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span>
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
<div className="flex flex-col gap-1">
<span className="flex justify-between gap-1">
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span>
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
<span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span>
</div>
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{uniqBy(exams, (x) => x.module).map(({module}) => (
<div

View File

@@ -371,7 +371,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
!startDate ||
!endDate ||
assignees.length === 0 ||
(!!examIDs && examIDs.length < selectedModules.length)
(!useRandomExams && examIDs.length < selectedModules.length)
}
className="w-full max-w-[200px]"
onClick={createAssignment}

View File

@@ -10,6 +10,7 @@ import {getExamById} from "@/utils/exams";
import {sortByModule} from "@/utils/moduleUtils";
import {calculateBandScore} from "@/utils/score";
import {convertToUserSolutions} from "@/utils/stats";
import {getUserName} from "@/utils/users";
import axios from "axios";
import clsx from "clsx";
import {capitalize, uniqBy} from "lodash";
@@ -241,13 +242,16 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
</div>
<span>
Assignees:{" "}
{users
.filter((u) => assignment?.assignees.includes(u.id))
.map((u) => `${u.name} (${u.email})`)
.join(", ")}
</span>
<div className="flex flex-col gap-2">
<span>
Assignees:{" "}
{users
.filter((u) => assignment?.assignees.includes(u.id))
.map((u) => `${u.name} (${u.email})`)
.join(", ")}
</span>
<span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">Average Scores</span>

File diff suppressed because it is too large Load Diff

View File

@@ -1,140 +1,108 @@
import React from "react";
import useUsers from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups";
import { User } from "@/interfaces/user";
import {User} from "@/interfaces/user";
import Select from "@/components/Low/Select";
import ProgressBar from "@/components/Low/ProgressBar";
import {
BsBook,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
} from "react-icons/bs";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { capitalize } from "lodash";
import { getLevelLabel } from "@/utils/score";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {capitalize} from "lodash";
import {getLevelLabel} from "@/utils/score";
const Card = ({ user }: { user: User }) => {
return (
<div className="border-mti-gray-platinum flex flex-col h-fit w-full cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
<div className="flex flex-col gap-3">
<h3 className="text-xl font-semibold">{user.name}</h3>
</div>
<div className="flex w-full gap-3 flex-wrap">
{MODULE_ARRAY.map((module) => {
const desiredLevel = user.desiredLevels[module] || 9;
const level = user.levels[module] || 0;
return (
<div
className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 min-w-[250px]"
key={module}
>
<div className="flex items-center gap-2 md:gap-3">
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
{module === "reading" && (
<BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />
)}
{module === "listening" && (
<BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />
)}
{module === "writing" && (
<BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />
)}
{module === "speaking" && (
<BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />
)}
{module === "level" && (
<BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />
)}
</div>
<div className="flex w-full flex-col">
<span className="text-sm font-bold md:font-extrabold w-full">
{capitalize(module)}
</span>
<div className="text-mti-gray-dim text-sm font-normal">
{module === "level" && (
<span>
English Level: {getLevelLabel(level).join(" / ")}
</span>
)}
{module !== "level" && (
<div className="flex flex-col">
<span>Level {level} / Level 9</span>
<span>Desired Level: {desiredLevel}</span>
</div>
)}
</div>
</div>
</div>
<div className="md:pl-14">
<ProgressBar
color={module}
label=""
mark={Math.round((desiredLevel * 100) / 9)}
markLabel={`Desired Level: ${desiredLevel}`}
percentage={Math.round((level * 100) / 9)}
className="h-2 w-full"
/>
</div>
</div>
);
})}
</div>
</div>
);
const Card = ({user}: {user: User}) => {
return (
<div className="border-mti-gray-platinum flex flex-col h-fit w-full cursor-pointer gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
<div className="flex flex-col gap-3">
<h3 className="text-xl font-semibold">{user.name}</h3>
</div>
<div className="flex w-full gap-3 flex-wrap">
{MODULE_ARRAY.map((module) => {
const desiredLevel = user.desiredLevels[module] || 9;
const level = user.levels[module] || 0;
return (
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 min-w-[250px]" key={module}>
<div className="flex items-center gap-2 md:gap-3">
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
</div>
<div className="flex w-full flex-col">
<span className="text-sm font-bold md:font-extrabold w-full">{capitalize(module)}</span>
<div className="text-mti-gray-dim text-sm font-normal">
{module === "level" && <span>English Level: {getLevelLabel(level).join(" / ")}</span>}
{module !== "level" && (
<div className="flex flex-col">
<span>Level {level} / Level 9</span>
<span>Desired Level: {desiredLevel}</span>
</div>
)}
</div>
</div>
</div>
<div className="md:pl-14">
<ProgressBar
color={module}
label=""
mark={Math.round((desiredLevel * 100) / 9)}
markLabel={`Desired Level: ${desiredLevel}`}
percentage={Math.round((level * 100) / 9)}
className="h-2 w-full"
/>
</div>
</div>
);
})}
</div>
</div>
);
};
const CorporateStudentsLevels = () => {
const { users } = useUsers();
const { groups } = useGroups();
const {users} = useUsers();
const {groups} = useGroups({});
const corporateUsers = users.filter((u) => u.type === "corporate") as User[];
const [corporateId, setCorporateId] = React.useState<string>("");
const corporate =
corporateUsers.find((u) => u.id === corporateId) || corporateUsers[0];
const corporateUsers = users.filter((u) => u.type === "corporate") as User[];
const [corporateId, setCorporateId] = React.useState<string>("");
const corporate = corporateUsers.find((u) => u.id === corporateId) || corporateUsers[0];
const groupsFromCorporate = corporate
? groups.filter((g) => g.admin === corporate.id)
: [];
const groupsFromCorporate = corporate ? groups.filter((g) => g.admin === corporate.id) : [];
const groupsParticipants = groupsFromCorporate
.flatMap((g) => g.participants)
.reduce((accm: User[], p) => {
const user = users.find((u) => u.id === p) as User;
if (user) {
return [...accm, user];
}
return accm;
}, []);
const groupsParticipants = groupsFromCorporate
.flatMap((g) => g.participants)
.reduce((accm: User[], p) => {
const user = users.find((u) => u.id === p) as User;
if (user) {
return [...accm, user];
}
return accm;
}, []);
return (
<>
<Select
options={corporateUsers.map((x: User) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
value={corporate ? { value: corporate.id, label: corporate.name } : null}
onChange={(value) => setCorporateId(value?.value!)}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
{groupsParticipants.map((u) => (
<Card user={u} key={u.id} />
))}
</>
);
return (
<>
<Select
options={corporateUsers.map((x: User) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
value={corporate ? {value: corporate.id, label: corporate.name} : null}
onChange={(value) => setCorporateId(value?.value!)}
styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
{groupsParticipants.map((u) => (
<Card user={u} key={u.id} />
))}
</>
);
};
export default CorporateStudentsLevels;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
import React from "react";
import {CorporateUser} from "@/interfaces/user";
import {BsBank, BsPersonFill} from "react-icons/bs";
import IconCard from "./IconCard";
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
interface Props {
users: CorporateUser[];
}
const MasterStatistical = (props: Props) => {
const {users} = props;
const usersList = React.useMemo(() => users.map((x) => x.id), [users]);
const {assignments} = useAssignmentsCorporates({corporates: usersList});
return (
<div className="flex flex-wrap gap-2 items-center text-center">
<IconCard Icon={BsBank} label="Consolidate" value={0} color="purple" onClick={() => console.log("clicked")} />
{users.map((group) => (
<IconCard
key={group.id}
Icon={BsBank}
label={group.corporateInformation?.companyInformation?.name}
value={0}
color="purple"
onClick={() => console.log("clicked", group)}
/>
))}
<IconCard onClick={() => console.log("clicked")} Icon={BsPersonFill} label="Consolidate Highest Student" color="purple" />
</div>
);
};
export default MasterStatistical;

View File

@@ -2,477 +2,408 @@
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import { CorporateUser, Group, Stat, User } from "@/interfaces/user";
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import { dateSorter } from "@/utils";
import {dateSorter} from "@/utils";
import moment from "moment";
import { useEffect, useState } from "react";
import {useEffect, useState} from "react";
import {
BsArrowLeft,
BsArrowRepeat,
BsClipboard2Data,
BsClipboard2DataFill,
BsClipboard2Heart,
BsClipboard2X,
BsClipboardPulse,
BsClock,
BsEnvelopePaper,
BsGlobeCentralSouthAsia,
BsPaperclip,
BsPeople,
BsPerson,
BsPersonAdd,
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPlus,
BsRepeat,
BsRepeat1,
BsArrowLeft,
BsArrowRepeat,
BsClipboard2Data,
BsClipboard2DataFill,
BsClipboard2Heart,
BsClipboard2X,
BsClipboardPulse,
BsClock,
BsEnvelopePaper,
BsGlobeCentralSouthAsia,
BsPaperclip,
BsPeople,
BsPerson,
BsPersonAdd,
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPlus,
BsRepeat,
BsRepeat1,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { Module } from "@/interfaces";
import { groupByExam } from "@/utils/stats";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats";
import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useAssignments from "@/hooks/useAssignments";
import { Assignment } from "@/interfaces/results";
import {Assignment} from "@/interfaces/results";
import AssignmentCard from "./AssignmentCard";
import Button from "@/components/Low/Button";
import clsx from "clsx";
import ProgressBar from "@/components/Low/ProgressBar";
import AssignmentCreator from "./AssignmentCreator";
import AssignmentView from "./AssignmentView";
import { getUserCorporate } from "@/utils/groups";
import {getUserCorporate} from "@/utils/groups";
import {checkAccess} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
interface Props {
user: User;
user: User;
}
export default function TeacherDashboard({ user }: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const [corporateUserToShow, setCorporateUserToShow] =
useState<CorporateUser>();
export default function TeacherDashboard({user}: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
const { stats } = useStats();
const { users, reload } = useUsers();
const { groups } = useGroups(user.id);
const {
assignments,
isLoading: isAssignmentsLoading,
reload: reloadAssignments,
} = useAssignments({ assigner: user.id });
const {stats} = useStats();
const {users, reload} = useUsers();
const {groups} = useGroups({adminAdmins: user.id});
const {permissions} = usePermissions(user.id);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
useEffect(() => {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
useEffect(() => {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
useEffect(() => {
getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]);
useEffect(() => {
getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]);
const studentFilter = (user: User) =>
user.type === "student" &&
groups.flatMap((g) => g.participants).includes(user.id);
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
const getStatsByStudent = (user: User) =>
stats.filter((s) => s.user === user.id);
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
>
<img
src={displayUser.profilePicture}
alt={displayUser.name}
className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const StudentsList = () => {
const filter = (x: User) =>
x.type === "student" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id));
const StudentsList = () => {
const filter = (x: User) =>
x.type === "student" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id));
return (
<UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({total})</h2>
</div>
)}
/>
);
};
return (
<UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({total})</h2>
</div>
)}
/>
);
};
const GroupsList = () => {
const filter = (x: Group) =>
x.admin === user.id || x.participants.includes(user.id);
const GroupsList = () => {
const filter = (x: Group) => x.admin === user.id;
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">
Groups ({groups.filter(filter).length})
</h2>
</div>
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
</div>
<GroupList user={user} />
</>
);
};
<GroupList user={user} />
</>
);
};
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: users.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(
s.score.correct,
s.score.total,
s.module,
s.focus!
),
}));
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: users.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: { [key in Module]: number } = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
const levels: {[key in Module]: number} = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
return calculateAverageLevel(levels);
};
const AssignmentsPage = () => {
const activeFilter = (a: Assignment) =>
moment(a.endDate).isAfter(moment()) &&
moment(a.startDate).isBefore(moment()) &&
a.assignees.length > a.results.length;
const pastFilter = (a: Assignment) =>
(moment(a.endDate).isBefore(moment()) ||
a.assignees.length === a.results.length) &&
!a.archived;
const archivedFilter = (a: Assignment) => a.archived;
const futureFilter = (a: Assignment) =>
moment(a.startDate).isAfter(moment());
const AssignmentsPage = () => {
const activeFilter = (a: Assignment) =>
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
const archivedFilter = (a: Assignment) => a.archived;
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
return (
<>
<AssignmentView
isOpen={!!selectedAssignment && !isCreatingAssignment}
onClose={() => {
setSelectedAssignment(undefined);
setIsCreatingAssignment(false);
reloadAssignments();
}}
assignment={selectedAssignment}
/>
<AssignmentCreator
assignment={selectedAssignment}
groups={groups.filter(
(x) => x.admin === user.id || x.participants.includes(user.id)
)}
users={users.filter(
(x) =>
x.type === "student" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id))
)}
assigner={user.id}
isCreating={isCreatingAssignment}
cancelCreation={() => {
setIsCreatingAssignment(false);
setSelectedAssignment(undefined);
reloadAssignments();
}}
/>
<div className="w-full flex justify-between items-center">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<span>Reload</span>
<BsArrowRepeat
className={clsx(
"text-xl",
isAssignmentsLoading && "animate-spin"
)}
/>
</div>
</div>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">
Active Assignments ({assignments.filter(activeFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => setSelectedAssignment(a)}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">
Planned Assignments ({assignments.filter(futureFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
<div
onClick={() => setIsCreatingAssignment(true)}
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"
>
<BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span>
</div>
{assignments.filter(futureFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => {
setSelectedAssignment(a);
setIsCreatingAssignment(true);
}}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">
Past Assignments ({assignments.filter(pastFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowArchive
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">
Archived Assignments ({assignments.filter(archivedFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowUnarchive
/>
))}
</div>
</section>
</>
);
};
return (
<>
<AssignmentView
isOpen={!!selectedAssignment && !isCreatingAssignment}
onClose={() => {
setSelectedAssignment(undefined);
setIsCreatingAssignment(false);
reloadAssignments();
}}
assignment={selectedAssignment}
/>
<AssignmentCreator
assignment={selectedAssignment}
groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
users={users.filter(
(x) =>
x.type === "student" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id)
: groups.flatMap((g) => g.participants).includes(x.id)),
)}
assigner={user.id}
isCreating={isCreatingAssignment}
cancelCreation={() => {
setIsCreatingAssignment(false);
setSelectedAssignment(undefined);
reloadAssignments();
}}
/>
<div className="w-full flex justify-between items-center">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
</div>
</div>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => (
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
<div className="flex flex-wrap gap-2">
<div
onClick={() => setIsCreatingAssignment(true)}
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
<BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span>
</div>
{assignments.filter(futureFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => {
setSelectedAssignment(a);
setIsCreatingAssignment(true);
}}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowArchive
allowExcelDownload
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowUnarchive
allowExcelDownload
/>
))}
</div>
</section>
</>
);
};
const DefaultDashboard = () => (
<>
{corporateUserToShow && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
Linked to:{" "}
<b>
{corporateUserToShow?.corporateInformation?.companyInformation
.name || corporateUserToShow.name}
</b>
</div>
)}
<section
className={clsx(
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
!!corporateUserToShow && "mt-12 xl:mt-6"
)}
>
<IconCard
onClick={() => setPage("students")}
Icon={BsPersonFill}
label="Students"
value={users.filter(studentFilter).length}
color="purple"
/>
<IconCard
Icon={BsClipboard2Data}
label="Exams Performed"
value={
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
).length
}
color="purple"
/>
<IconCard
Icon={BsPaperclip}
label="Average Level"
value={averageLevelCalculator(
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
)
).toFixed(1)}
color="purple"
/>
<IconCard
Icon={BsPeople}
label="Groups"
value={groups.length}
color="purple"
onClick={() => setPage("groups")}
/>
<div
onClick={() => setPage("assignments")}
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"
>
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light">
{assignments.filter((a) => !a.archived).length}
</span>
</span>
</div>
</section>
const DefaultDashboard = () => (
<>
{corporateUserToShow && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
</div>
)}
<section
className={clsx(
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
!!corporateUserToShow && "mt-12 xl:mt-6",
)}>
<IconCard
onClick={() => setPage("students")}
Icon={BsPersonFill}
label="Students"
value={users.filter(studentFilter).length}
color="purple"
/>
<IconCard
Icon={BsClipboard2Data}
label="Exams Performed"
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
color="purple"
/>
<IconCard
Icon={BsPaperclip}
label="Average Level"
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
color="purple"
/>
{checkAccess(user, ["teacher", "developer"], permissions, "viewGroup") && (
<IconCard
Icon={BsPeople}
label="Groups"
value={groups.filter((x) => x.admin === user.id).length}
color="purple"
onClick={() => setPage("groups")}
/>
)}
<div
onClick={() => setPage("assignments")}
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
</span>
</div>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort(
(a, b) =>
calculateAverageLevel(b.levels) -
calculateAverageLevel(a.levels)
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length -
Object.keys(groupByExam(getStatsByStudent(a))).length
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</>
);
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" ||
selectedUser.type === "teacher"
? () => setPage("students")
: undefined
}
onViewTeachers={
selectedUser.type === "corporate"
? () => setPage("teachers")
: undefined
}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{page === "students" && <StudentsList />}
{page === "groups" && <GroupsList />}
{page === "assignments" && <AssignmentsPage />}
{page === "" && <DefaultDashboard />}
</>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
}
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{page === "students" && <StudentsList />}
{page === "groups" && <GroupsList />}
{page === "assignments" && <AssignmentsPage />}
{page === "" && <DefaultDashboard />}
</>
);
}

View File

@@ -1,221 +0,0 @@
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import {renderExercise} from "@/components/Exercises";
import Button from "@/components/Low/Button";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {LevelExam, LevelPart, UserSolution, WritingExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {Fragment, useEffect, useState} from "react";
import {BsChevronDown, BsChevronUp} from "react-icons/bs";
import {toast} from "react-toastify";
interface Props {
exam: LevelExam;
showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
}
function TextComponent({part}: {part: LevelPart}) {
return (
<div className="flex flex-col gap-2 w-full">
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{!!part.context &&
part.context
.split(/\n|(\\n)/g)
.filter((x) => x && x.length > 0 && x !== "\\n")
.map((line, index) => (
<Fragment key={index}>
<p key={index}>{line}</p>
</Fragment>
))}
</div>
);
}
export default function Level({exam, showSolutions = false, onFinish}: Props) {
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
const [showBlankModal, setShowBlankModal] = useState(false);
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {partIndex, setPartIndex} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex(exerciseIndex + 1);
}
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) {
setShowBlankModal(false);
return;
}
onFinish(userSolutions);
};
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
}
if (storeQuestionIndex > 0) {
const exercise = getExercise();
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
}
setStoreQuestionIndex(0);
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex(exerciseIndex + 1);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex(partIndex + 1);
setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0);
return;
}
if (
solution &&
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
(x) => x === 0,
) &&
!showSolutions &&
!hasExamEnded
) {
setShowBlankModal(true);
return;
}
setHasExamEnded(false);
if (solution) {
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
} else {
onFinish(userSolutions);
}
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
}
if (storeQuestionIndex > 0) {
const exercise = getExercise();
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
}
setStoreQuestionIndex(0);
setExerciseIndex(exerciseIndex - 1);
};
const getExercise = () => {
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
};
const calculateExerciseIndex = () => {
if (partIndex === 0)
return (
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
return (
exercisesDone +
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
storeQuestionIndex +
multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
};
const renderText = () => (
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}>
<>
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">
Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read.
</h4>
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div>
<TextComponent part={exam.parts[partIndex]} />
</>
</div>
);
return (
<>
<div className="flex flex-col h-full w-full gap-8 items-center">
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
<ModuleTitle
minTimer={exam.minTimer}
exerciseIndex={calculateExerciseIndex()}
module="level"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions}
/>
<div
className={clsx(
"mb-20 w-full",
partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4",
)}>
{partIndex > -1 && !!exam.parts[partIndex].context && renderText()}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
</div>
{exerciseIndex === -1 && partIndex > 0 && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => {
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex(partIndex - 1);
}}
className="max-w-[200px] w-full">
Back
</Button>
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)}
{exerciseIndex === -1 && partIndex === 0 && (
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Start now
</Button>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,37 @@
import Button from "@/components/Low/Button";
import { Module } from "@/interfaces";
import { LevelPart, UserSolution } from "@/interfaces/exam";
import { ReactNode } from "react";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
interface Props {
partIndex: number;
part: LevelPart // for now
onNext: () => void;
}
const PartDivider: React.FC<Props> = ({ partIndex, part, onNext }) => {
const moduleIcon: { [key in Module]: ReactNode } = {
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
level: <BsClipboard className="text-white w-6 h-6" />,
};
return (
<div className="flex flex-col w-3/6 h-fit border bg-white rounded-3xl p-12 gap-8">
{/** only level for now */}
<div className="flex flex-row gap-4 items-center"><div className="w-12 h-12 bg-ielts-level flex items-center justify-center rounded-lg">{moduleIcon["level"]}</div><p className="text-3xl">{`Part ${partIndex + 1}`}</p></div>
{part.intro!.split('\\n\\n').map((x, index) => <p key={`line-${index}`} className="text-2xl text-clip">{x}</p>)}
<div className="flex items-center justify-center mt-4">
<Button color="purple" onClick={() => onNext()} className="max-w-[200px] self-end w-full text-2xl">
{partIndex === 0 ? `Start now`: `Start Part ${partIndex + 1}`}
</Button>
</div>
</div>
)
}
export default PartDivider;

View File

@@ -0,0 +1,146 @@
import { LevelPart } from "@/interfaces/exam";
import { useEffect, useRef } from "react";
interface Props {
part: LevelPart,
contextWord: string | undefined,
setContextWordLine: React.Dispatch<React.SetStateAction<number | undefined>>
}
const TextComponent: React.FC<Props> = ({part, contextWord, setContextWordLine}) => {
const textRef = useRef<HTMLDivElement>(null);
const calculateLineNumbers = () => {
if (textRef.current) {
const computedStyle = window.getComputedStyle(textRef.current);
const containerWidth = textRef.current.clientWidth;
const offscreenElement = document.createElement('div');
offscreenElement.style.position = 'absolute';
offscreenElement.style.top = '-9999px';
offscreenElement.style.left = '-9999px';
offscreenElement.style.width = `${containerWidth}px`;
offscreenElement.style.font = computedStyle.font;
offscreenElement.style.lineHeight = computedStyle.lineHeight;
offscreenElement.style.whiteSpace = 'pre-wrap';
offscreenElement.style.wordWrap = 'break-word';
offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign;
const paragraphs = part.context!.split('\n\n');
let currentLine = 1;
let contextWordLine: number | null = null;
const paragraphLineStarts: number[] = [];
paragraphs.forEach((paragraph, pIndex) => {
const p = document.createElement('p');
p.style.margin = '0';
p.style.padding = '0';
paragraph.split(/(\s+)/).forEach((word: string) => {
const span = document.createElement('span');
span.textContent = word;
p.appendChild(span);
});
offscreenElement.appendChild(p);
if (pIndex < paragraphs.length - 1) {
const gap = document.createElement('div');
gap.style.height = '16px'; // gap-4
offscreenElement.appendChild(gap);
}
});
document.body.appendChild(offscreenElement);
let currentLineTop: number | undefined;
const elements = offscreenElement.querySelectorAll('p, div');
elements.forEach((element) => {
if (element.tagName === 'P') {
const spans = element.querySelectorAll<HTMLSpanElement>('span');
paragraphLineStarts.push(currentLine);
spans.forEach(span => {
const rect = span.getBoundingClientRect();
const top = rect.top;
if (currentLineTop === undefined || top > currentLineTop) {
if (currentLineTop !== undefined) {
currentLine++;
}
currentLineTop = top;
}
if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) {
contextWordLine = currentLine;
}
});
} else if (element.tagName === 'DIV') { // Gap
currentLine++;
currentLineTop = undefined;
}
});
if (contextWordLine) {
setContextWordLine(contextWordLine);
}
document.body.removeChild(offscreenElement);
}
};
useEffect(() => {
calculateLineNumbers();
const resizeObserver = new ResizeObserver(() => {
calculateLineNumbers();
});
if (textRef.current) {
// eslint-disable-next-line react-hooks/exhaustive-deps
resizeObserver.observe(textRef.current);
}
return () => {
if (textRef.current) {
// eslint-disable-next-line react-hooks/exhaustive-deps
resizeObserver.unobserve(textRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [part.context, contextWord]);
/*if (typeof part.showContextLines === "undefined") {
return (
<div className="flex flex-col gap-2 w-full">
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{!!part.context &&
part.context
.split(/\n|(\\n)/g)
.filter((x) => x && x.length > 0 && x !== "\\n")
.map((line, index) => (
<Fragment key={index}>
<p key={index}>{line}</p>
</Fragment>
))}
</div>
);
}*/
return (
<div className="flex flex-col gap-2 w-full">
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
<div className="flex mt-2">
<div ref={textRef} className="h-fit ml-2 flex flex-col gap-4">
{part.context!.split('\n\n').map((line, index) => {
return <p key={`line-${index}`}><span className="mr-6">{index + 1}</span>{line}</p>
})}
</div>
</div>
</div>
);
}
export default TextComponent;

442
src/exams/Level/index.tsx Normal file
View File

@@ -0,0 +1,442 @@
import QuestionsModal from "@/components/QuestionsModal";
import { renderExercise } from "@/components/Exercises";
import Button from "@/components/Low/Button";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import { renderSolution } from "@/components/Solutions";
import { Module } from "@/interfaces";
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap, UserSolution } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import { countExercises } from "@/utils/moduleUtils";
import clsx from "clsx";
import { use, useEffect, useState } from "react";
import TextComponent from "./TextComponent";
import PartDivider from "./PartDivider";
import Timer from "@/components/Medium/Timer";
import { Stat } from "@/interfaces/user";
interface Props {
exam: LevelExam;
showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
editing?: boolean;
partDividers?: boolean;
}
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every(
word => word && typeof word === 'object' && 'id' in word && 'options' in word
);
}
export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) {
const levelBgColor = "bg-ielts-level-light";
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
const [showQuestionsModal, setShowQuestionsModal] = useState(false);
const { setBgColor } = useExamStore((state) => state);
const { userSolutions, setUserSolutions } = useExamStore((state) => state);
const { hasExamEnded, setHasExamEnded } = useExamStore((state) => state);
const { partIndex, setPartIndex } = useExamStore((state) => state);
const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state);
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps])
const [currentExercise, setCurrentExercise] = useState<Exercise>();
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
const [contextWord, setContextWord] = useState<string | undefined>(undefined);
const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined);
useEffect(() => {
if (showSolutions && exerciseIndex && exam.shuffle && userSolutions[exerciseIndex].shuffleMaps) {
setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[])
}
}, [showSolutions, exerciseIndex, setShuffleMaps, userSolutions, exam.shuffle])
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex(exerciseIndex + 1);
}
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const getExercise = () => {
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
if (!exercise) return undefined;
exercise = {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
if (exam.shuffle && exercise.type === "multipleChoice" && !showSolutions) {
console.log("Shuffling MC ");
const exerciseShuffles = userSolutions[exerciseIndex].shuffleMaps;
if (exerciseShuffles && exerciseShuffles.length == 0) {
const newShuffleMaps: ShuffleMap[] = [];
exercise.questions = exercise.questions.map(question => {
const options = [...question.options];
let shuffledOptions = [...options].sort(() => Math.random() - 0.5);
const newOptions = options.map((option, index) => ({
id: option.id,
text: shuffledOptions[index].text
}));
const optionMapping = options.reduce<{ [key: string]: string }>((acc, originalOption) => {
const shuffledPosition = newOptions.find(newOpt => newOpt.text === originalOption.text)?.id;
if (shuffledPosition) {
acc[shuffledPosition] = originalOption.id;
}
return acc;
}, {});
newShuffleMaps.push({ id: question.id, map: optionMapping });
return { ...question, options: newOptions };
});
setShuffleMaps(newShuffleMaps);
} else {
console.log("retrieving MC shuffles");
exercise.questions = exercise.questions.map(question => {
const questionShuffleMap = shuffleMaps.find(map => map.id === question.id);
if (questionShuffleMap) {
const newOptions = question.options.map(option => ({
id: option.id,
text: question.options.find(o => questionShuffleMap.map[o.id] === option.id)?.text || option.text
}));
return { ...question, options: newOptions };
}
return question;
});
}
} else if (exam.shuffle && exercise.type === "fillBlanks" && typeCheckWordsMC(exercise.words) && !showSolutions) {
if (shuffleMaps.length === 0 && !showSolutions) {
const newShuffleMaps: ShuffleMap[] = [];
console.log("Shuffling Words");
exercise.words = exercise.words.map(word => {
if ('options' in word) {
const options = { ...word.options };
const originalKeys = Object.keys(options);
const shuffledKeys = [...originalKeys].sort(() => Math.random() - 0.5);
const newOptions = shuffledKeys.reduce((acc, key, index) => {
acc[key as keyof typeof options] = options[originalKeys[index] as keyof typeof options];
return acc;
}, {} as { [key in keyof typeof options]: string });
const optionMapping = originalKeys.reduce((acc, key, index) => {
acc[key as keyof typeof options] = shuffledKeys[index];
return acc;
}, {} as { [key in keyof typeof options]: string });
newShuffleMaps.push({ id: word.id, map: optionMapping });
return { ...word, options: newOptions };
}
return word;
});
setShuffleMaps(newShuffleMaps);
} else {
console.log("Retrieving Words shuffle");
exercise.words = exercise.words.map(word => {
if ('options' in word) {
const shuffleMap = shuffleMaps.find(map => map.id === word.id);
if (shuffleMap) {
const options = { ...word.options };
const shuffledOptions = Object.keys(options).reduce((acc, key) => {
const shuffledKey = shuffleMap.map[key as keyof typeof options];
acc[shuffledKey as keyof typeof options] = options[key as keyof typeof options];
return acc;
}, {} as { [key in keyof typeof options]: string });
return { ...word, options: shuffledOptions };
}
}
return word;
});
}
}
console.log(exercise);
return exercise;
};
useEffect(() => {
if (exerciseIndex !== -1) {
setCurrentExercise(getExercise());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex, exerciseIndex, shuffleMaps, exam.parts[partIndex].context]);
useEffect(() => {
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
if (exerciseIndex !== -1 && currentExercise && currentExercise.type === "multipleChoice" && currentExercise.questions[storeQuestionIndex].prompt) {
const match = currentExercise.questions[storeQuestionIndex].prompt.match(regex);
if (match) {
const word = match[1];
const originalLineNumber = match[2];
if (word !== contextWord) {
setContextWord(word);
}
const updatedPrompt = currentExercise.questions[storeQuestionIndex].prompt.replace(
`in line ${originalLineNumber}`,
`in line ${contextWordLine || originalLineNumber}`
);
currentExercise.questions[storeQuestionIndex].prompt = updatedPrompt;
} else {
setContextWord(undefined);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentExercise, storeQuestionIndex]);
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]);
}
/*if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") {
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]);
}*/
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex(exerciseIndex + 1);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded && (showQuestionsModal || showSolutions)) {
if (!showSolutions && exam.parts[0].intro) {
setShowPartDivider(true);
setBgColor(levelBgColor);
}
setPartIndex(partIndex + 1);
setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0);
setStoreQuestionIndex(0);
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions) {
setShowQuestionsModal(true);
return;
}
if (
solution &&
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
(x) => x === 0,
) &&
!showSolutions &&
!editing &&
!hasExamEnded
) {
setShowQuestionsModal(true);
return;
}
setHasExamEnded(false);
if (solution) {
let stat = { ...solution, module: "level" as Module, exam: exam.id }
if (exam.shuffle) {
stat.shuffleMaps = shuffleMaps
}
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...stat }]);
} else {
onFinish(userSolutions);
}
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]);
}
setExerciseIndex(exerciseIndex - 1);
if (exerciseIndex - 1 === -1) {
setPartIndex(partIndex - 1);
const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex];
if (previousExercise.type === "multipleChoice") {
setStoreQuestionIndex(previousExercise.questions.length - 1)
}
const multipleChoiceQuestionsDone = [];
for (let i = 0; i < exam.parts.length; i++) {
if (i == (partIndex - 1)) break;
for (let j = 0; j < exam.parts[i].exercises.length; j++) {
const exercise = exam.parts[i].exercises[j];
if (exercise.type === "multipleChoice") {
multipleChoiceQuestionsDone.push({ id: exercise.id, amount: exercise.questions.length - 1 })
}
if (exercise.type === "fillBlanks") {
multipleChoiceQuestionsDone.push({ id: exercise.id, amount: exercise.words.length - 1 })
}
}
}
setMultipleChoicesDone(multipleChoiceQuestionsDone);
}
};
useEffect(() => {
if (exerciseIndex === -1) {
nextExercise()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex])
const calculateExerciseIndex = () => {
if (partIndex === 0) {
return (
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex //+ multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
}
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
return (
exercisesDone +
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
storeQuestionIndex
+ multipleChoicesDone.reduce((acc, curr) => { return acc + curr.amount }, 0)
);
};
const renderText = () => (
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}>
<>
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">
Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read.
</h4>
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div>
<TextComponent
part={exam.parts[partIndex]}
contextWord={contextWord}
setContextWordLine={setContextWordLine}
/>
</>
</div>
);
const partLabel = () => {
if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words))
return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${currentExercise.words[currentExercise.words.length - 1].id})\n\n${currentExercise.prompt}`
if (currentExercise?.type === "multipleChoice") {
return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})\n\n${currentExercise.prompt}`
}
if (typeof exam.parts[partIndex].context === "string") {
const nextExercise = exam.parts[partIndex].exercises[0] as MultipleChoiceExercise;
return `Part ${partIndex + 1} (Questions ${nextExercise.questions[0].id} - ${nextExercise.questions[nextExercise.questions.length - 1].id})\n\n${nextExercise.prompt}`
}
}
const modalKwargs = () => {
const allSolutionsCorrectLength = exam.parts[partIndex].exercises.every((exercise) => {
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
if (exercise.type === "multipleChoice") {
return userSolution?.solutions.length === exercise.questions.length;
}
if (exercise.type === "fillBlanks") {
return userSolution?.solutions.length === exercise.words.length;
}
return false;
});
return {
blankQuestions: !allSolutionsCorrectLength,
finishingWhat: "part",
onClose: partIndex !== exam.parts.length - 1 ? (
function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
) : function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); onFinish(userSolutions); } else { setShowQuestionsModal(false) } }
}
}
return (
<>
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
<QuestionsModal isOpen={showQuestionsModal} {...modalKwargs()} />
{
!(partIndex === 0 && storeQuestionIndex === 0 && showPartDivider) &&
<Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} />
}
{exam.parts[0].intro && showPartDivider ? <PartDivider part={exam.parts[partIndex]} partIndex={partIndex} onNext={() => { setShowPartDivider(false); setBgColor("bg-white") }} /> : (
<>
<ModuleTitle
partLabel={partLabel()}
minTimer={exam.minTimer}
exerciseIndex={calculateExerciseIndex()}
module="level"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions || editing}
showTimer={typeof exam.parts[0].intro === "undefined"}
/>
<div
className={clsx(
"mb-20 w-full",
partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4",
)}>
{partIndex > -1 && !!exam.parts[partIndex].context && renderText()}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
!editing &&
currentExercise &&
renderExercise(currentExercise, exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
(showSolutions || editing) &&
currentExercise &&
renderSolution(currentExercise, nextExercise, previousExercise)}
</div>
{/*exerciseIndex === -1 && partIndex > 0 && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => {
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex(partIndex - 1);
}}
className="max-w-[200px] w-full"
disabled={
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
typeof exam.parts[0].intro === "string" && storeQuestionIndex === 0}
>
Back
</Button>
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)*/}
{exerciseIndex === -1 && partIndex === 0 && (
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Start now
</Button>
)}
</>
)}
</div>
</>
);
}

View File

@@ -5,7 +5,7 @@ import {renderSolution} from "@/components/Solutions";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import AudioPlayer from "@/components/Low/AudioPlayer";
import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import BlankQuestionsModal from "@/components/QuestionsModal";
import useExamStore from "@/stores/examStore";
import {countExercises} from "@/utils/moduleUtils";

View File

@@ -15,7 +15,7 @@ import ProgressBar from "@/components/Low/ProgressBar";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import {Divider} from "primereact/divider";
import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import BlankQuestionsModal from "@/components/QuestionsModal";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";

View File

@@ -1,442 +1,310 @@
/* eslint-disable @next/next/no-img-element */
import { useState } from "react";
import { Module } from "@/interfaces";
import {useState} from "react";
import {Module} from "@/interfaces";
import clsx from "clsx";
import { User } from "@/interfaces/user";
import {User} from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar";
import {
BsArrowRepeat,
BsBook,
BsCheck,
BsCheckCircle,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
import { totalExamsByModule } from "@/utils/stats";
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {totalExamsByModule} from "@/utils/stats";
import useStats from "@/hooks/useStats";
import Button from "@/components/Low/Button";
import { calculateAverageLevel } from "@/utils/score";
import { sortByModuleName } from "@/utils/moduleUtils";
import { capitalize } from "lodash";
import {calculateAverageLevel} from "@/utils/score";
import {sortByModuleName} from "@/utils/moduleUtils";
import {capitalize} from "lodash";
import ProfileSummary from "@/components/ProfileSummary";
import { Variant } from "@/interfaces/exam";
import useSessions, { Session } from "@/hooks/useSessions";
import {Variant} from "@/interfaces/exam";
import useSessions, {Session} from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/examStore";
import moment from "moment";
interface Props {
user: User;
page: "exercises" | "exams";
onStart: (
modules: Module[],
avoidRepeated: boolean,
variant: Variant,
) => void;
disableSelection?: boolean;
user: User;
page: "exercises" | "exams";
onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
disableSelection?: boolean;
}
export default function Selection({
user,
page,
onStart,
disableSelection = false,
}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const [variant, setVariant] = useState<Variant>("full");
export default function Selection({user, page, onStart, disableSelection = false}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const [variant, setVariant] = useState<Variant>("full");
const { stats } = useStats(user?.id);
const { sessions, isLoading, reload } = useSessions(user.id);
const {stats} = useStats(user?.id);
const {sessions, isLoading, reload} = useSessions(user.id);
const state = useExamStore((state) => state);
const state = useExamStore((state) => state);
const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) =>
prev.includes(module) ? modules : [...modules, module],
);
};
const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
};
const loadSession = async (session: Session) => {
state.setSelectedModules(session.selectedModules);
state.setExam(session.exam);
state.setExams(session.exams);
state.setSessionId(session.sessionId);
state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex);
};
const loadSession = async (session: Session) => {
state.setSelectedModules(session.selectedModules);
state.setExam(session.exam);
state.setExams(session.exams);
state.setSessionId(session.sessionId);
state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex);
};
return (
<>
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
{user && (
<ProfileSummary
user={user}
items={[
{
icon: (
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
),
label: "Reading",
value: totalExamsByModule(stats, "reading"),
tooltip: "The amount of reading exams performed.",
},
{
icon: (
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
),
label: "Listening",
value: totalExamsByModule(stats, "listening"),
tooltip: "The amount of listening exams performed.",
},
{
icon: (
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
),
label: "Writing",
value: totalExamsByModule(stats, "writing"),
tooltip: "The amount of writing exams performed.",
},
{
icon: (
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
),
label: "Speaking",
value: totalExamsByModule(stats, "speaking"),
tooltip: "The amount of speaking exams performed.",
},
{
icon: (
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
),
label: "Level",
value: totalExamsByModule(stats, "level"),
tooltip: "The amount of level exams performed.",
},
]}
/>
)}
return (
<>
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
{user && (
<ProfileSummary
user={user}
items={[
{
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
label: "Reading",
value: totalExamsByModule(stats, "reading"),
tooltip: "The amount of reading exams performed.",
},
{
icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
label: "Listening",
value: totalExamsByModule(stats, "listening"),
tooltip: "The amount of listening exams performed.",
},
{
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
label: "Writing",
value: totalExamsByModule(stats, "writing"),
tooltip: "The amount of writing exams performed.",
},
{
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
label: "Speaking",
value: totalExamsByModule(stats, "speaking"),
tooltip: "The amount of speaking exams performed.",
},
{
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
label: "Level",
value: totalExamsByModule(stats, "level"),
tooltip: "The amount of level exams performed.",
},
]}
/>
)}
<section className="flex flex-col gap-3">
<span className="text-lg font-bold">About {capitalize(page)}</span>
<span className="text-mti-gray-taupe">
{page === "exercises" && (
<>
In the realm of language acquisition, practice makes perfect,
and our exercises are the key to unlocking your full potential.
Dive into a world of interactive and engaging exercises that
cater to diverse learning styles. From grammar drills that build
a strong foundation to vocabulary challenges that broaden your
lexicon, our exercises are carefully designed to make learning
English both enjoyable and effective. Whether you&apos;re
looking to reinforce specific skills or embark on a holistic
language journey, our exercises are your companions in the
pursuit of excellence. Embrace the joy of learning as you
navigate through a variety of activities that cater to every
facet of language acquisition. Your linguistic adventure starts
here!
</>
)}
{page === "exams" && (
<>
Welcome to the heart of success on your English language
journey! Our exams are crafted with precision to assess and
enhance your language skills. Each test is a passport to your
linguistic prowess, designed to challenge and elevate your
abilities. Whether you&apos;re a beginner or a seasoned learner,
our exams cater to all levels, providing a comprehensive
evaluation of your reading, writing, speaking, and listening
skills. Prepare to embark on a journey of self-discovery and
language mastery as you navigate through our thoughtfully
curated exams. Your success is not just a destination; it&apos;s
a testament to your dedication and our commitment to empowering
you with the English language.
</>
)}
</span>
</section>
<section className="flex flex-col gap-3">
<span className="text-lg font-bold">About {capitalize(page)}</span>
<span className="text-mti-gray-taupe">
{page === "exercises" && (
<>
In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full
potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar
drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully
designed to make learning English both enjoyable and effective. Whether you&apos;re looking to reinforce specific
skills or embark on a holistic language journey, our exercises are your companions in the pursuit of excellence.
Embrace the joy of learning as you navigate through a variety of activities that cater to every facet of language
acquisition. Your linguistic adventure starts here!
</>
)}
{page === "exams" && (
<>
Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and
enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate
your abilities. Whether you&apos;re a beginner or a seasoned learner, our exams cater to all levels, providing a
comprehensive evaluation of your reading, writing, speaking, and listening skills. Prepare to embark on a journey of
self-discovery and language mastery as you navigate through our thoughtfully curated exams. Your success is not just a
destination; it&apos;s a testament to your dedication and our commitment to empowering you with the English language.
</>
)}
</span>
</section>
{sessions.length > 0 && (
<section className="flex flex-col gap-3 md:gap-3">
<div className="flex items-center gap-4">
<div
onClick={reload}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
>
<span className="text-mti-black text-lg font-bold">
Unfinished Sessions
</span>
<BsArrowRepeat
className={clsx("text-xl", isLoading && "animate-spin")}
/>
</div>
</div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{sessions
.sort((a, b) => moment(b.date).diff(moment(a.date)))
.map((session) => (
<SessionCard
session={session}
key={session.sessionId}
reload={reload}
loadSession={loadSession}
/>
))}
</span>
</section>
)}
{sessions.length > 0 && (
<section className="flex flex-col gap-3 md:gap-3">
<div className="flex items-center gap-4">
<div
onClick={reload}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
<span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
</div>
</div>
<span className="text-mti-gray-taupe flex gap-8 overflow-x-auto pb-2">
{sessions
.sort((a, b) => moment(b.date).diff(moment(a.date)))
.map((session) => (
<SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} />
))}
</span>
</section>
)}
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
<div
onClick={
!disableSelection && !selectedModules.includes("level")
? () => toggleModule("reading")
: undefined
}
className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("reading") || disableSelection
? "border-mti-purple-light"
: "border-mti-gray-platinum",
)}
>
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsBook className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Reading:</span>
<p className="text-left text-xs">
Expand your vocabulary, improve your reading comprehension and
improve your ability to interpret texts in English.
</p>
{!selectedModules.includes("reading") &&
!selectedModules.includes("level") &&
!disableSelection && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("reading") || disableSelection) && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
<div
onClick={
!disableSelection && !selectedModules.includes("level")
? () => toggleModule("listening")
: undefined
}
className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("listening") || disableSelection
? "border-mti-purple-light"
: "border-mti-gray-platinum",
)}
>
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsHeadphones className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Listening:</span>
<p className="text-left text-xs">
Improve your ability to follow conversations in English and your
ability to understand different accents and intonations.
</p>
{!selectedModules.includes("listening") &&
!selectedModules.includes("level") &&
!disableSelection && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("listening") || disableSelection) && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
<div
onClick={
!disableSelection && !selectedModules.includes("level")
? () => toggleModule("writing")
: undefined
}
className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("writing") || disableSelection
? "border-mti-purple-light"
: "border-mti-gray-platinum",
)}
>
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsPen className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Writing:</span>
<p className="text-left text-xs">
Allow you to practice writing in a variety of formats, from simple
paragraphs to complex essays.
</p>
{!selectedModules.includes("writing") &&
!selectedModules.includes("level") &&
!disableSelection && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("writing") || disableSelection) && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
<div
onClick={
!disableSelection && !selectedModules.includes("level")
? () => toggleModule("speaking")
: undefined
}
className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("speaking") || disableSelection
? "border-mti-purple-light"
: "border-mti-gray-platinum",
)}
>
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsMegaphone className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Speaking:</span>
<p className="text-left text-xs">
You&apos;ll have access to interactive dialogs, pronunciation
exercises and speech recordings.
</p>
{!selectedModules.includes("speaking") &&
!selectedModules.includes("level") &&
!disableSelection && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("speaking") || disableSelection) && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
{!disableSelection && (
<div
onClick={
selectedModules.length === 0 ||
selectedModules.includes("level")
? () => toggleModule("level")
: undefined
}
className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("level") || disableSelection
? "border-mti-purple-light"
: "border-mti-gray-platinum",
)}
>
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsClipboard className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Level:</span>
<p className="text-left text-xs">
You&apos;ll be able to test your english level with multiple
choice questions.
</p>
{!selectedModules.includes("level") &&
selectedModules.length === 0 &&
!disableSelection && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("level") || disableSelection) && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{!selectedModules.includes("level") &&
selectedModules.length > 0 && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
)}
</section>
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
<div className="flex w-full flex-col items-center gap-3">
<div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
onClick={() => setAvoidRepeatedExams((prev) => !prev)}
>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
avoidRepeatedExams && "!bg-mti-purple-light ",
)}
>
<BsCheck color="white" className="h-full w-full" />
</div>
<span
className="tooltip"
data-tip="If possible, the platform will choose exams not yet done."
>
Avoid Repeated Questions
</span>
</div>
<div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
// onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
>
<input type="checkbox" className="hidden" disabled />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
variant === "full" && "!bg-mti-purple-light ",
)}
>
<BsCheck color="white" className="h-full w-full" />
</div>
<span>Full length exams</span>
</div>
</div>
<div
className="tooltip w-full"
data-tip={`Your screen size is too small to do ${page}`}
>
<Button
color="purple"
className="w-full max-w-xs px-12 md:hidden"
disabled
>
Start Exam
</Button>
</div>
<Button
onClick={() =>
onStart(
!disableSelection
? selectedModules.sort(sortByModuleName)
: ["reading", "listening", "writing", "speaking"],
avoidRepeatedExams,
variant,
)
}
color="purple"
className="-md:hidden w-full max-w-xs px-12 md:self-end"
disabled={selectedModules.length === 0 && !disableSelection}
>
Start Exam
</Button>
</div>
</div>
</>
);
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
<div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsBook className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Reading:</span>
<p className="text-left text-xs">
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
</p>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("reading") || disableSelection) && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
</div>
<div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsHeadphones className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Listening:</span>
<p className="text-left text-xs">
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
</p>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("listening") || disableSelection) && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
</div>
<div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsPen className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Writing:</span>
<p className="text-left text-xs">
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
</p>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("writing") || disableSelection) && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
</div>
<div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsMegaphone className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Speaking:</span>
<p className="text-left text-xs">
You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings.
</p>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("speaking") || disableSelection) && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
</div>
{!disableSelection && (
<div
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsClipboard className="h-7 w-7 text-white" />
</div>
<span className="font-semibold">Level:</span>
<p className="text-left text-xs">You&apos;ll be able to test your english level with multiple choice questions.</p>
{!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)}
{(selectedModules.includes("level") || disableSelection) && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)}
{!selectedModules.includes("level") && selectedModules.length > 0 && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
)}
</section>
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
<div className="flex w-full flex-col items-center gap-3">
<div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
avoidRepeatedExams && "!bg-mti-purple-light ",
)}>
<BsCheck color="white" className="h-full w-full" />
</div>
<span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
Avoid Repeated Questions
</span>
</div>
<div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
variant === "full" && "!bg-mti-purple-light ",
)}>
<BsCheck color="white" className="h-full w-full" />
</div>
<span>Full length exams</span>
</div>
</div>
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
<Button color="purple" className="w-full max-w-xs px-12 md:hidden" disabled>
Start Exam
</Button>
</div>
<Button
onClick={() =>
onStart(
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
avoidRepeatedExams,
variant,
)
}
color="purple"
className="-md:hidden w-full max-w-xs px-12 md:self-end"
disabled={selectedModules.length === 0 && !disableSelection}>
Start Exam
</Button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,235 @@
/* eslint-disable jsx-a11y/alt-text */
import React from "react";
import { Document, Page, View, Text, Image } from "@react-pdf/renderer";
import { ModuleScore } from "@/interfaces/module.scores";
import { styles } from "./styles";
import TestReportFooter from "./test.report.footer";
import { StyleSheet } from "@react-pdf/renderer";
const customStyles = StyleSheet.create({
testDetails: {
display: "flex",
gap: 4,
},
testDetailsContainer: {
display: "flex",
gap: 16,
},
table: {
width: "100%",
},
tableRow: {
flexDirection: "row",
},
tableCol70: {
width: "70%", // First column width (50%)
borderStyle: "solid",
borderWidth: 1,
borderColor: "#000",
// padding: 5,
},
tableCol25: {
width: "16.67%", // Remaining four columns each get 1/6 of the total width (50% / 3 = 16.67%)
borderStyle: "solid",
borderWidth: 1,
borderColor: "#000",
padding: 5,
},
tableCol20: {
width: "20%", // Width for each of the 5 sub-columns (50% / 5 = 20%)
borderStyle: "solid",
borderWidth: 1,
borderColor: "#000",
padding: 5,
},
tableCol10: {
width: "10%", // Width for each of the 5 sub-columns (50% / 5 = 20%)
borderStyle: "solid",
borderWidth: 1,
borderColor: "#000",
padding: 5,
},
tableCellHeader: {
fontSize: 12,
textAlign: "center",
},
tableCellHeaderColor: {
backgroundColor: "#d3d3d3",
},
tableCell: {
fontSize: 10,
textAlign: "center",
},
});
interface Props {
date: string;
name: string;
email: string;
id: string;
gender?: string;
passportId: string;
corporateName: string;
downloadDate: string;
userId: string;
uniqueExercises: { name: string; result: string }[];
timeSpent: string;
score: string;
}
const LevelTestReport = ({
date,
name,
email,
id,
gender,
passportId,
corporateName,
downloadDate,
userId,
uniqueExercises,
timeSpent,
score,
}: Props) => {
const defaultTextStyle = [styles.textFont, { fontSize: 8 }];
return (
<Document>
<Page style={styles.body} orientation="landscape">
<Text style={[styles.textFont, styles.textBold, { fontSize: 11 }]}>
Corporate Name: {corporateName}
</Text>
<View style={styles.textMargin}>
<Text style={defaultTextStyle}>
Report Download date: {downloadDate}
</Text>
</View>
<Text style={[styles.textFont, styles.textBold, { fontSize: 11 }]}>
Test Information: {id}
</Text>
<View style={styles.textMargin}>
<Text style={defaultTextStyle}>Date of Test: {date}</Text>
<Text style={defaultTextStyle}>Candidates name: {name}</Text>
<Text style={defaultTextStyle}>Email: {email}</Text>
<Text style={defaultTextStyle}>National ID: {passportId}</Text>
<Text style={defaultTextStyle}>Gender: {gender}</Text>
<Text style={defaultTextStyle}>Candidate ID: {userId}</Text>
</View>
<View style={customStyles.table}>
{/* Header Row */}
<View style={customStyles.tableRow}>
<View
style={[
customStyles.tableCol70,
customStyles.tableCellHeaderColor,
]}
>
<Text style={[customStyles.tableCellHeader, { padding: 5 }]}>
Test sections
</Text>
</View>
<View
style={[
customStyles.tableCol20,
customStyles.tableCellHeaderColor,
]}
>
<Text style={customStyles.tableCellHeader}>Time spent</Text>
</View>
<View
style={[
customStyles.tableCol10,
customStyles.tableCellHeaderColor,
]}
>
<Text style={customStyles.tableCellHeader}>Score</Text>
</View>
</View>
<View style={customStyles.tableRow}>
<View style={customStyles.tableCol70}>
<View style={customStyles.tableRow}>
{uniqueExercises.map((exercise, index) => (
<View
style={[
customStyles.tableCol20,
index !== uniqueExercises.length - 1
? { borderWidth: 0, borderRightWidth: 1 }
: { borderWidth: 0 },
]}
key={index}
>
<Text style={customStyles.tableCell}>Part {index + 1}</Text>
</View>
))}
</View>
</View>
<View style={customStyles.tableCol20}>
<Text style={customStyles.tableCell}></Text>
</View>
<View style={customStyles.tableCol10}>
<Text style={customStyles.tableCell}></Text>
</View>
</View>
<View style={customStyles.tableRow}>
<View style={customStyles.tableCol70}>
<View style={customStyles.tableRow}>
{uniqueExercises.map((exercise, index) => (
<View
style={[
customStyles.tableCol20,
index !== uniqueExercises.length - 1
? { borderWidth: 0, borderRightWidth: 1 }
: { borderWidth: 0 },
]}
key={index}
>
<Text style={customStyles.tableCell}>{exercise.name}</Text>
</View>
))}
</View>
</View>
<View style={customStyles.tableCol20}>
<Text style={customStyles.tableCell}></Text>
</View>
<View style={customStyles.tableCol10}>
<Text style={customStyles.tableCell}></Text>
</View>
</View>
<View style={customStyles.tableRow}>
<View style={customStyles.tableCol70}>
<View style={customStyles.tableRow}>
{uniqueExercises.map((exercise, index) => (
<View
style={[
customStyles.tableCol20,
index !== uniqueExercises.length - 1
? { borderWidth: 0, borderRightWidth: 1 }
: { borderWidth: 0 },
]}
key={index}
>
<Text style={customStyles.tableCell}>
{exercise.result}
</Text>
</View>
))}
</View>
</View>
<View style={customStyles.tableCol20}>
<Text style={customStyles.tableCell}>{timeSpent}</Text>
</View>
<View style={customStyles.tableCol10}>
<Text style={customStyles.tableCell}>{score}</Text>
</View>
</View>
</View>
<TestReportFooter userId={userId} />
</Page>
</Document>
);
};
export default LevelTestReport;

View File

@@ -0,0 +1,34 @@
import { Assignment } from "@/interfaces/results";
import axios from "axios";
import { useEffect, useState } from "react";
export default function useAssignmentsCorporates({
corporates,
}: {
corporates: string[];
}) {
const [assignments, setAssignments] = useState<Assignment[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
if (corporates.length === 0) {
setAssignments([]);
return;
}
setIsLoading(true);
axios
.get<Assignment[]>(
`/api/assignments/corporate?ids=${corporates.join(",")}`
)
.then(async (response) => {
setAssignments(response.data);
})
.finally(() => setIsLoading(false));
};
useEffect(getData, [corporates]);
return { assignments, isLoading, isError, reload: getData };
}

View File

@@ -2,7 +2,7 @@ import {Assignment} from "@/interfaces/results";
import axios from "axios";
import {useEffect, useState} from "react";
export default function useAssignments({assigner, assignees}: {assigner?: string; assignees?: string}) {
export default function useAssignments({assigner, assignees, corporate}: {assigner?: string; assignees?: string; corporate?: string}) {
const [assignments, setAssignments] = useState<Assignment[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
@@ -10,12 +10,13 @@ export default function useAssignments({assigner, assignees}: {assigner?: string
const getData = () => {
setIsLoading(true);
axios
.get<Assignment[]>("/api/assignments")
.then((response) => {
.get<Assignment[]>(!corporate ? "/api/assignments" : `/api/assignments/corporate/${corporate}`)
.then(async (response) => {
if (assigner) {
setAssignments(response.data.filter((a) => a.assigner === assigner));
return;
}
if (assignees) {
setAssignments(response.data.filter((a) => a.assignees.filter((x) => assignees.includes(x)).length > 0));
return;
@@ -26,7 +27,7 @@ export default function useAssignments({assigner, assignees}: {assigner?: string
.finally(() => setIsLoading(false));
};
useEffect(getData, [assignees, assigner]);
useEffect(getData, [assignees, assigner, corporate]);
return {assignments, isLoading, isError, reload: getData};
}

View File

@@ -2,32 +2,40 @@ import {Group, User} from "@/interfaces/user";
import axios from "axios";
import {useEffect, useState} from "react";
export default function useGroups(admin?: string, userType?: string) {
interface Props {
admin?: string;
userType?: string;
adminAdmins?: string;
}
export default function useGroups({admin, userType, adminAdmins}: Props) {
const [groups, setGroups] = useState<Group[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const isMasterType = userType?.startsWith('master');
const isMasterType = userType?.startsWith("master");
const getData = () => {
setIsLoading(true);
const url = admin ? `/api/groups?admin=${admin}` : "/api/groups";
const url = admin && !adminAdmins ? `/api/groups?admin=${admin}` : "/api/groups";
axios
.get<Group[]>(url)
.then((response) => {
if(isMasterType) {
return setGroups(response.data);
}
const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || "");
if (isMasterType) return setGroups(response.data);
const filteredGroups = admin ? response.data.filter(filter) : response.data;
const filterByAdmins = !!adminAdmins
? [adminAdmins, ...response.data.filter((g) => g.participants.includes(adminAdmins)).flatMap((g) => g.admin)]
: [admin];
const adminFilter = (g: Group) => filterByAdmins.includes(g.admin) || g.participants.includes(admin || "");
const filteredGroups = !!admin || !!adminAdmins ? response.data.filter(adminFilter) : response.data;
return setGroups(admin ? filteredGroups.map((g) => ({...g, disableEditing: g.disableEditing || g.admin !== admin})) : filteredGroups);
})
.finally(() => setIsLoading(false));
};
useEffect(getData, [admin, isMasterType]);
useEffect(getData, [admin, adminAdmins, isMasterType]);
return {groups, isLoading, isError, reload: getData};
}

View File

@@ -27,6 +27,10 @@ export function useListSearch<T>(fields: string[][], rows: T[]) {
if (typeof value === "string") {
return value.toLowerCase().includes(searchText);
}
if (typeof value === "number") {
return (value as Number).toString().includes(searchText);
}
});
});
}, [fields, rows, text]);

View File

@@ -1,15 +1,16 @@
import React from "react";
import axios from "axios";
import { toast } from "react-toastify";
import { BsFilePdf } from "react-icons/bs";
import { BsFilePdf, BsFileExcel} from "react-icons/bs";
type DownloadingPdf = {
[key: string]: boolean;
};
type PdfEndpoint = "stats" | "assignments";
type FileType = "pdf" | "excel";
export const usePDFDownload = (endpoint: PdfEndpoint) => {
export const usePDFDownload = (endpoint: PdfEndpoint, file: FileType = 'pdf') => {
const [downloadingPdf, setDownloadingPdf] = React.useState<DownloadingPdf>(
{}
);
@@ -17,7 +18,7 @@ export const usePDFDownload = (endpoint: PdfEndpoint) => {
const triggerDownload = async (id: string) => {
try {
setDownloadingPdf((prev) => ({ ...prev, [id]: true }));
const res = await axios.post(`/api/${endpoint}/${id}/export`);
const res = await axios.post(`/api/${endpoint}/${id}/export/${file}`);
toast.success("Report ready!");
const link = document.createElement("a");
link.href = res.data;
@@ -45,8 +46,11 @@ export const usePDFDownload = (endpoint: PdfEndpoint) => {
<span className={`${loadingClasses} loading loading-infinity w-6`} />
);
}
const Icon = file === "excel" ? BsFileExcel : BsFilePdf;
return (
<BsFilePdf
<Icon
className={`${downloadClasses} text-2xl cursor-pointer`}
onClick={(e) => {
e.stopPropagation();

View File

@@ -0,0 +1,28 @@
import {Exam} from "@/interfaces/exam";
import {Permission, PermissionType} from "@/interfaces/permissions";
import {ExamState} from "@/stores/examStore";
import axios from "axios";
import {useEffect, useState} from "react";
export default function usePermissions(user: string) {
const [permissions, setPermissions] = useState<PermissionType[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Permission[]>(`/api/permissions`)
.then((response) => {
const permissionTypes = response.data
.filter((x) => !x.users.includes(user))
.reduce((acc, curr) => [...acc, curr.type], [] as PermissionType[]);
setPermissions(permissionTypes);
})
.finally(() => setIsLoading(false));
};
useEffect(getData, [user]);
return {permissions, isLoading, isError, reload: getData};
}

View File

@@ -12,6 +12,7 @@ interface ExamBase {
isDiagnostic: boolean;
variant?: Variant;
difficulty?: Difficulty;
shuffle?: boolean;
createdBy?: string; // option as it has been added later
createdAt?: string; // option as it has been added later
}
@@ -36,6 +37,7 @@ export interface LevelExam extends ExamBase {
export interface LevelPart {
context?: string;
intro?: string;
exercises: Exercise[];
}
@@ -65,6 +67,7 @@ export interface UserSolution {
};
exercise: string;
isDisabled?: boolean;
shuffleMaps?: ShuffleMap[]
}
export interface WritingExam extends ExamBase {
@@ -77,7 +80,7 @@ interface WordCounter {
limit: number;
}
export interface SpeakingExam extends ExamBase {
export interface SpeakingExam extends ExamBase {
module: "speaking";
exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
instructorGender: InstructorGender;
@@ -96,8 +99,8 @@ export type Exercise =
export interface Evaluation {
comment: string;
overall: number;
task_response: {[key: string]: number | {grade: number; comment: string}};
misspelled_pairs?: {correction: string | null; misspelled: string}[];
task_response: { [key: string]: number | { grade: number; comment: string } };
misspelled_pairs?: { correction: string | null; misspelled: string }[];
}
@@ -110,10 +113,9 @@ type InteractiveTranscriptType = { [key in InteractiveTranscriptKey]?: string };
type InteractiveFixedTextType = { [key in InteractiveFixedTextKey]?: string };
interface InteractiveSpeakingEvaluation extends Evaluation,
InteractivePerfectAnswerType,
InteractiveTranscriptType,
InteractiveFixedTextType
{}
InteractivePerfectAnswerType,
InteractiveTranscriptType,
InteractiveFixedTextType { }
interface SpeakingEvaluation extends CommonEvaluation {
@@ -187,10 +189,10 @@ export interface InteractiveSpeakingExercise {
first_title?: string;
second_title?: string;
text: string;
prompts: {text: string; video_url: string}[];
prompts: { text: string; video_url: string }[];
userSolutions: {
id: string;
solution: {questionIndex: number; question: string; answer: string}[];
solution: { questionIndex: number; question: string; answer: string }[];
evaluation?: InteractiveSpeakingEvaluation;
}[];
topic?: string;
@@ -199,13 +201,23 @@ export interface InteractiveSpeakingExercise {
variant?: "initial" | "final";
}
export interface FillBlanksMCOption {
id: string;
options: {
A: string;
B: string;
C: string;
D: string;
}
}
export interface FillBlanksExercise {
prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it."
type: "fillBlanks";
id: string;
words: (string | {letter: string; word: string})[]; // *EXAMPLE: ["preserve", "unaware"]
words: (string | { letter: string; word: string } | FillBlanksMCOption)[]; // *EXAMPLE: ["preserve", "unaware"]
text: string; // *EXAMPLE: "They tried to {{1}} burning"
allowRepetition: boolean;
allowRepetition?: boolean;
solutions: {
id: string; // *EXAMPLE: "1"
solution: string; // *EXAMPLE: "preserve"
@@ -214,6 +226,7 @@ export interface FillBlanksExercise {
id: string; // *EXAMPLE: "1"
solution: string; // *EXAMPLE: "preserve"
}[];
variant?: string;
}
export interface TrueFalseExercise {
@@ -221,7 +234,7 @@ export interface TrueFalseExercise {
id: string;
prompt: string; // *EXAMPLE: "Select the appropriate option."
questions: TrueFalseQuestion[];
userSolutions: {id: string; solution: "true" | "false" | "not_given"}[];
userSolutions: { id: string; solution: "true" | "false" | "not_given" }[];
}
export interface TrueFalseQuestion {
@@ -250,7 +263,7 @@ export interface MatchSentencesExercise {
type: "matchSentences";
id: string;
prompt: string;
userSolutions: {question: string; option: string}[];
userSolutions: { question: string; option: string }[];
sentences: MatchSentenceExerciseSentence[];
allowRepetition: boolean;
options: MatchSentenceExerciseOption[];
@@ -273,7 +286,7 @@ export interface MultipleChoiceExercise {
id: string;
prompt: string; // *EXAMPLE: "Select the appropriate option."
questions: MultipleChoiceQuestion[];
userSolutions: {question: string; option: string}[];
userSolutions: { question: string; option: string }[];
}
export interface MultipleChoiceQuestion {
@@ -286,4 +299,12 @@ export interface MultipleChoiceQuestion {
src?: string; // *EXAMPLE: "https://i.imgur.com/rEbrSqA.png" (only used if the variant is "image")
text?: string; // *EXAMPLE: "wallet, pens and novel" (only used if the variant is "text")
}[];
shuffleMap?: Record<string, string>;
}
export interface ShuffleMap {
id: string;
map: {
[key: string]: string;
}
}

View File

@@ -1,49 +1,89 @@
export const markets = ["au", "br", "de"] as const;
export interface PermissionTopic {
topic: string;
list: string[];
}
export const permissions = [
// generate codes are basicly invites
"createCodeStudent",
"createCodeTeacher",
"createCodeCorporate",
"createCodeCountryManager",
"createCodeAdmin",
// exams
"createReadingExam",
"createListeningExam",
"createWritingExam",
"createSpeakingExam",
"createLevelExam",
// view pages
"viewExams",
"viewExercises",
"viewRecords",
"viewStats",
"viewTickets",
"viewPaymentRecords",
// view data
"viewStudent",
"viewTeacher",
"viewCorporate",
"viewCountryManager",
"viewAdmin",
// edit data
"editStudent",
"editTeacher",
"editCorporate",
"editCountryManager",
"editAdmin",
// delete data
"deleteStudent",
"deleteTeacher",
"deleteCorporate",
"deleteCountryManager",
"deleteAdmin",
{
topic: "Manage Corporate",
list: [
"viewCorporate",
"editCorporate",
"deleteCorporate",
"createCodeCorporate",
],
},
{
topic: "Manage Admin",
list: ["viewAdmin", "editAdmin", "deleteAdmin", "createCodeAdmin"],
},
{
topic: "Manage Student",
list: ["viewStudent", "editStudent", "deleteStudent", "createCodeStudent"],
},
{
topic: "Manage Teacher",
list: ["viewTeacher", "editTeacher", "deleteTeacher", "createCodeTeacher"],
},
{
topic: "Manage Country Manager",
list: [
"viewCountryManager",
"editCountryManager",
"deleteCountryManager",
"createCodeCountryManager",
],
},
{
topic: "Manage Exams",
list: [
"createReadingExam",
"createListeningExam",
"createWritingExam",
"createSpeakingExam",
"createLevelExam",
],
},
{
topic: "View Pages",
list: [
"viewExams",
"viewExercises",
"viewRecords",
"viewStats",
"viewTickets",
"viewPaymentRecords",
],
},
{
topic: "Manage Group",
list: ["viewGroup", "editGroup", "deleteGroup", "createGroup"],
},
{
topic: "Manage Codes",
list: ["viewCodes", "deleteCodes", "createCodes"],
},
{
topic: "Others",
list: ["all"],
},
] as const;
export type PermissionType = (typeof permissions)[keyof typeof permissions];
const permissionsList = [
...new Set(
permissions.reduce(
(accm: string[], permission) => [...accm, ...permission.list],
[]
)
),
];
export type PermissionType =
(typeof permissionsList)[keyof typeof permissionsList];
export interface Permission {
id: string;
type: PermissionType;
topic: string;
users: string[];
}

View File

@@ -1,5 +1,5 @@
import { Module } from ".";
import { InstructorGender } from "./exam";
import { InstructorGender, ShuffleMap } from "./exam";
import { PermissionType } from "./permissions";
export type User =
@@ -27,7 +27,8 @@ export interface BasicUser {
subscriptionExpirationDate?: null | Date;
registrationDate?: Date;
status: UserStatus;
permissions: PermissionType[],
permissions: PermissionType[];
lastLogin?: Date;
}
export interface StudentUser extends BasicUser {
@@ -148,6 +149,11 @@ export interface Stat {
missing: number;
};
isDisabled?: boolean;
shuffleMaps?: ShuffleMap[];
pdf?: {
path: string;
version: string;
};
}
export interface Group {

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,366 +1,280 @@
import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
import { PERMISSIONS } from "@/constants/userPermissions";
import {PERMISSIONS} from "@/constants/userPermissions";
import useUsers from "@/hooks/useUsers";
import { Type, User } from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user";
import {Type, User} from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user";
import axios from "axios";
import clsx from "clsx";
import { capitalize, uniqBy } from "lodash";
import {capitalize, uniqBy} from "lodash";
import moment from "moment";
import { useEffect, useState } from "react";
import {useEffect, useState} from "react";
import ReactDatePicker from "react-datepicker";
import { toast } from "react-toastify";
import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id";
import { useFilePicker } from "use-file-picker";
import {useFilePicker} from "use-file-picker";
import readXlsxFile from "read-excel-file";
import Modal from "@/components/Modal";
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
import { checkAccess } from "@/utils/permissions";
import { PermissionType } from "@/interfaces/permissions";
const EMAIL_REGEX = new RegExp(
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
);
import {BsFileEarmarkEaselFill, BsQuestionCircleFill} from "react-icons/bs";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import {PermissionType} from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
const USER_TYPE_PERMISSIONS: {
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
[key in Type]: {perm: PermissionType | undefined; list: Type[]};
} = {
student: {
perm: "createCodeStudent",
list: [],
},
teacher: {
perm: "createCodeTeacher",
list: [],
},
agent: {
perm: "createCodeCountryManager",
list: [],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
mastercorporate: {
perm: undefined,
list: ["student", "teacher", "corporate"],
},
admin: {
perm: "createCodeAdmin",
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"mastercorporate",
],
},
developer: {
perm: undefined,
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"developer",
"mastercorporate",
],
},
student: {
perm: "createCodeStudent",
list: [],
},
teacher: {
perm: "createCodeTeacher",
list: [],
},
agent: {
perm: "createCodeCountryManager",
list: [],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
mastercorporate: {
perm: undefined,
list: ["student", "teacher", "corporate"],
},
admin: {
perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
},
developer: {
perm: undefined,
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
},
};
export default function BatchCodeGenerator({ user }: { user: User }) {
const [infos, setInfos] = useState<
{ email: string; name: string; passport_id: string }[]
>([]);
const [isLoading, setIsLoading] = useState(false);
const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).toDate()
: null
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false);
export default function BatchCodeGenerator({user}: {user: User}) {
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false);
const { users } = useUsers();
const {users} = useUsers();
const {permissions} = usePermissions(user?.id || "");
const { openFilePicker, filesContent, clear } = useFilePicker({
accept: ".xlsx",
multiple: false,
readAs: "ArrayBuffer",
});
const {openFilePicker, filesContent, clear} = useFilePicker({
accept: ".xlsx",
multiple: false,
readAs: "ArrayBuffer",
});
useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
useEffect(() => {
if (filesContent.length > 0) {
const file = filesContent[0];
readXlsxFile(file.content).then((rows) => {
try {
const information = uniqBy(
rows
.map((row) => {
const [
firstName,
lastName,
country,
passport_id,
email,
...phone
] = row as string[];
return EMAIL_REGEX.test(email.toString().trim())
? {
email: email.toString().trim().toLowerCase(),
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
passport_id: passport_id?.toString().trim() || undefined,
}
: undefined;
})
.filter((x) => !!x) as typeof infos,
(x) => x.email
);
useEffect(() => {
if (filesContent.length > 0) {
const file = filesContent[0];
readXlsxFile(file.content).then((rows) => {
try {
const information = uniqBy(
rows
.map((row) => {
const [firstName, lastName, country, passport_id, email, ...phone] = row as string[];
return EMAIL_REGEX.test(email.toString().trim())
? {
email: email.toString().trim().toLowerCase(),
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
passport_id: passport_id?.toString().trim() || undefined,
}
: undefined;
})
.filter((x) => !!x) as typeof infos,
(x) => x.email,
);
if (information.length === 0) {
toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
);
return clear();
}
if (information.length === 0) {
toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
);
return clear();
}
setInfos(information);
} catch {
toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
);
return clear();
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]);
setInfos(information);
} catch {
toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
);
return clear();
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]);
const generateAndInvite = async () => {
const newUsers = infos.filter(
(x) => !users.map((u) => u.email).includes(x.email)
);
const existingUsers = infos
.filter((x) => users.map((u) => u.email).includes(x.email))
.map((i) => users.find((u) => u.email === i.email))
.filter((x) => !!x && x.type === "student") as User[];
const generateAndInvite = async () => {
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
const existingUsers = infos
.filter((x) => users.map((u) => u.email).includes(x.email))
.map((i) => users.find((u) => u.email === i.email))
.filter((x) => !!x && x.type === "student") as User[];
const newUsersSentence =
newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
const existingUsersSentence =
existingUsers.length > 0
? `invite ${existingUsers.length} registered student(s)`
: undefined;
if (
!confirm(
`You are about to ${[newUsersSentence, existingUsersSentence]
.filter((x) => !!x)
.join(" and ")}, are you sure you want to continue?`
)
)
return;
const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined;
if (
!confirm(
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
)
)
return;
setIsLoading(true);
Promise.all(
existingUsers.map(
async (u) =>
await axios.post(`/api/invites`, { to: u.id, from: user.id })
)
)
.then(() =>
toast.success(
`Successfully invited ${existingUsers.length} registered student(s)!`
)
)
.finally(() => {
if (newUsers.length === 0) setIsLoading(false);
});
setIsLoading(true);
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, from: user.id})))
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`))
.finally(() => {
if (newUsers.length === 0) setIsLoading(false);
});
if (newUsers.length > 0) generateCode(type, newUsers);
setInfos([]);
};
if (newUsers.length > 0) generateCode(type, newUsers);
setInfos([]);
};
const generateCode = (type: Type, informations: typeof infos) => {
const uid = new ShortUniqueId();
const codes = informations.map(() => uid.randomUUID(6));
const generateCode = (type: Type, informations: typeof infos) => {
const uid = new ShortUniqueId();
const codes = informations.map(() => uid.randomUUID(6));
setIsLoading(true);
axios
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
type,
codes,
infos: informations,
expiryDate,
})
.then(({ data, status }) => {
if (data.ok) {
toast.success(
`Successfully generated${
data.valid ? ` ${data.valid}/${informations.length}` : ""
} ${capitalize(type)} codes and they have been notified by e-mail!`,
{ toastId: "success" }
);
return;
}
setIsLoading(true);
axios
.post<{ok: boolean; valid?: number; reason?: string}>("/api/code", {
type,
codes,
infos: informations,
expiryDate,
})
.then(({data, status}) => {
if (data.ok) {
toast.success(
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
type,
)} codes and they have been notified by e-mail!`,
{toastId: "success"},
);
return;
}
if (status === 403) {
toast.error(data.reason, { toastId: "forbidden" });
}
})
.catch(({ response: { status, data } }) => {
if (status === 403) {
toast.error(data.reason, { toastId: "forbidden" });
return;
}
if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"});
}
})
.catch(({response: {status, data}}) => {
if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"});
return;
}
toast.error(`Something went wrong, please try again later!`, {
toastId: "error",
});
})
.finally(() => {
setIsLoading(false);
return clear();
});
};
toast.error(`Something went wrong, please try again later!`, {
toastId: "error",
});
})
.finally(() => {
setIsLoading(false);
return clear();
});
};
return (
<>
<Modal
isOpen={showHelp}
onClose={() => setShowHelp(false)}
title="Excel File Format"
>
<div className="mt-4 flex flex-col gap-2">
<span>Please upload an Excel file with the following format:</span>
<table className="w-full">
<thead>
<tr>
<th className="border border-neutral-200 px-2 py-1">
First Name
</th>
<th className="border border-neutral-200 px-2 py-1">
Last Name
</th>
<th className="border border-neutral-200 px-2 py-1">Country</th>
<th className="border border-neutral-200 px-2 py-1">
Passport/National ID
</th>
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
<th className="border border-neutral-200 px-2 py-1">
Phone Number
</th>
</tr>
</thead>
</table>
<span className="mt-4">
<b>Notes:</b>
<ul>
<li>- All incorrect e-mails will be ignored;</li>
<li>- All already registered e-mails will be ignored;</li>
<li>
- You may have a header row with the format above, however, it
is not necessary;
</li>
<li>
- All of the e-mails in the file will receive an e-mail to join
EnCoach with the role selected below.
</li>
</ul>
</span>
</div>
</Modal>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<div className="flex items-end justify-between">
<label className="text-mti-gray-dim text-base font-normal">
Choose an Excel file
</label>
<div
className="tooltip cursor-pointer"
data-tip="Excel File Format"
onClick={() => setShowHelp(true)}
>
<BsQuestionCircleFill />
</div>
</div>
<Button
onClick={openFilePicker}
isLoading={isLoading}
disabled={isLoading}
>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button>
{user &&
checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">
Expiry Date
</label>
<Checkbox
isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled}
disabled={!!user.subscriptionExpirationDate}
>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out"
)}
filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(user.subscriptionExpirationDate
? moment(date).isBefore(user.subscriptionExpirationDate)
: true)
}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
<label className="text-mti-gray-dim text-base font-normal">
Select the type of user they should be
</label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)}
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
>
{Object.keys(USER_TYPE_LABELS)
.filter((x) => {
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, list, perm);
})
.map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
<Button
onClick={generateAndInvite}
disabled={
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
}
>
Generate & Send
</Button>
</div>
</>
);
return (
<>
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
<div className="mt-4 flex flex-col gap-2">
<span>Please upload an Excel file with the following format:</span>
<table className="w-full">
<thead>
<tr>
<th className="border border-neutral-200 px-2 py-1">First Name</th>
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
<th className="border border-neutral-200 px-2 py-1">Country</th>
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
<th className="border border-neutral-200 px-2 py-1">Phone Number</th>
</tr>
</thead>
</table>
<span className="mt-4">
<b>Notes:</b>
<ul>
<li>- All incorrect e-mails will be ignored;</li>
<li>- All already registered e-mails will be ignored;</li>
<li>- You may have a header row with the format above, however, it is not necessary;</li>
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
</ul>
</span>
</div>
</Modal>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<div className="flex items-end justify-between">
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
<div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
<BsQuestionCircleFill />
</div>
</div>
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button>
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)}
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
{Object.keys(USER_TYPE_LABELS)
.filter((x) => {
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, getTypesOfUser(list), permissions, perm);
})
.map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
Generate & Send
</Button>
)}
</div>
</>
);
}

View File

@@ -1,243 +1,230 @@
import Button from "@/components/Low/Button";
import useUsers from "@/hooks/useUsers";
import { Type as UserType, User } from "@/interfaces/user";
import {Type as UserType, User} from "@/interfaces/user";
import axios from "axios";
import { uniqBy } from "lodash";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { useFilePicker } from "use-file-picker";
import {uniqBy} from "lodash";
import {useEffect, useState} from "react";
import {toast} from "react-toastify";
import {useFilePicker} from "use-file-picker";
import readXlsxFile from "read-excel-file";
import Modal from "@/components/Modal";
import { BsQuestionCircleFill } from "react-icons/bs";
import { PermissionType } from "@/interfaces/permissions";
const EMAIL_REGEX = new RegExp(
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
);
import {BsQuestionCircleFill} from "react-icons/bs";
import {PermissionType} from "@/interfaces/permissions";
import moment from "moment";
import {checkAccess} from "@/utils/permissions";
import Checkbox from "@/components/Low/Checkbox";
import ReactDatePicker from "react-datepicker";
import clsx from "clsx";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
type Type = Exclude<UserType, "admin" | "developer" | "agent" | "mastercorporate">
type Type = Exclude<UserType, "admin" | "developer" | "agent" | "mastercorporate">;
const USER_TYPE_LABELS: {[key in Type]: string} = {
student: "Student",
teacher: "Teacher",
corporate: "Corporate",
student: "Student",
teacher: "Teacher",
corporate: "Corporate",
};
const USER_TYPE_PERMISSIONS: {
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
[key in Type]: {perm: PermissionType | undefined; list: Type[]};
} = {
student: {
perm: "createCodeStudent",
list: [],
},
teacher: {
perm: "createCodeTeacher",
list: [],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
student: {
perm: "createCodeStudent",
list: [],
},
teacher: {
perm: "createCodeTeacher",
list: [],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
};
export default function BatchCreateUser({ user }: { user: User }) {
const [infos, setInfos] = useState<
{ email: string; name: string; passport_id:string, type: Type, demographicInformation: {
country: string,
passport_id:string,
phone: string
} }[]
>([]);
const [isLoading, setIsLoading] = useState(false);
const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false);
export default function BatchCreateUser({user}: {user: User}) {
const [infos, setInfos] = useState<
{
email: string;
name: string;
passport_id: string;
type: Type;
demographicInformation: {
country: string;
passport_id: string;
phone: string;
};
}[]
>([]);
const [isLoading, setIsLoading] = useState(false);
const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false);
const { users } = useUsers();
const {users} = useUsers();
const { openFilePicker, filesContent, clear } = useFilePicker({
accept: ".xlsx",
multiple: false,
readAs: "ArrayBuffer",
});
const {openFilePicker, filesContent, clear} = useFilePicker({
accept: ".xlsx",
multiple: false,
readAs: "ArrayBuffer",
});
useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
useEffect(() => {
if (filesContent.length > 0) {
const file = filesContent[0];
readXlsxFile(file.content).then((rows) => {
try {
const information = uniqBy(
rows
.map((row) => {
const [
firstName,
lastName,
country,
passport_id,
email,
phone,
group
] = row as string[];
return EMAIL_REGEX.test(email.toString().trim())
? {
email: email.toString().trim().toLowerCase(),
name: `${firstName ?? ""} ${lastName ?? ""}`.trim().toLowerCase(),
type: type,
passport_id: passport_id?.toString().trim() || undefined,
groupName: group,
demographicInformation: {
country: country,
passport_id: passport_id?.toString().trim() || undefined,
phone,
}
}
: undefined;
})
.filter((x) => !!x) as typeof infos,
(x) => x.email
);
useEffect(() => {
if (filesContent.length > 0) {
const file = filesContent[0];
readXlsxFile(file.content).then((rows) => {
try {
const information = uniqBy(
rows
.map((row) => {
const [firstName, lastName, country, passport_id, email, phone, group] = row as string[];
return EMAIL_REGEX.test(email.toString().trim())
? {
email: email.toString().trim().toLowerCase(),
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
type: type,
passport_id: passport_id?.toString().trim() || undefined,
groupName: group,
demographicInformation: {
country: country,
passport_id: passport_id?.toString().trim() || undefined,
phone,
},
}
: undefined;
})
.filter((x) => !!x) as typeof infos,
(x) => x.email,
);
if (information.length === 0) {
toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
);
return clear();
}
if (information.length === 0) {
toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
);
return clear();
}
setInfos(information);
} catch {
toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
);
return clear();
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]);
setInfos(information);
} catch {
toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
);
return clear();
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]);
const makeUsers = async () => {
const newUsers = infos.filter(
(x) => !users.map((u) => u.email).includes(x.email)
);
const confirmed = confirm(
`You are about to add ${newUsers.length}, are you sure you want to continue?`
)
if (!confirmed)
return;
if (newUsers.length > 0)
{
setIsLoading(true);
Promise.all(newUsers.map(async (user) => {
await axios.post("/api/make_user", user)
})).then((res) =>{
toast.success(
`Successfully added ${newUsers.length} user(s)!`
)}).finally(() => {
return clear();
})
}
setIsLoading(false);
setInfos([]);
};
const makeUsers = async () => {
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
if (!confirm(`You are about to add ${newUsers.length}, are you sure you want to continue?`)) return;
if (newUsers.length > 0) {
setIsLoading(true);
return (
<>
<Modal
isOpen={showHelp}
onClose={() => setShowHelp(false)}
title="Excel File Format"
>
<div className="mt-4 flex flex-col gap-2">
<span>Please upload an Excel file with the following format:</span>
<table className="w-full">
<thead>
<tr>
<th className="border border-neutral-200 px-2 py-1">
First Name
</th>
<th className="border border-neutral-200 px-2 py-1">
Last Name
</th>
<th className="border border-neutral-200 px-2 py-1">Country</th>
<th className="border border-neutral-200 px-2 py-1">
Passport/National ID
</th>
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
<th className="border border-neutral-200 px-2 py-1">
Phone Number
</th>
<th className="border border-neutral-200 px-2 py-1">
Group Name
</th>
</tr>
</thead>
</table>
<span className="mt-4">
<b>Notes:</b>
<ul>
<li>- All incorrect e-mails will be ignored;</li>
<li>- All already registered e-mails will be ignored;</li>
<li>
- You may have a header row with the format above, however, it
is not necessary;
</li>
<li>
- All of the e-mails in the file will receive an e-mail to join
EnCoach with the role selected below.
</li>
</ul>
</span>
</div>
</Modal>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<div className="flex items-end justify-between">
<label className="text-mti-gray-dim text-base font-normal">
Choose an Excel file
</label>
<div
className="tooltip cursor-pointer"
data-tip="Excel File Format"
onClick={() => setShowHelp(true)}
>
<BsQuestionCircleFill />
</div>
</div>
<Button
onClick={openFilePicker}
isLoading={isLoading}
disabled={isLoading}
>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button>
<label className="text-mti-gray-dim text-base font-normal">
Select the type of user they should be
</label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as Type)}
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
>
{Object.keys(USER_TYPE_LABELS)
.map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
<Button
className="my-auto"
onClick={makeUsers}
disabled={
infos.length === 0
}
>
Create
</Button>
</div>
</>
);
try {
for (const newUser of newUsers) await axios.post("/api/make_user", {...newUser, type, expiryDate});
toast.success(`Successfully added ${newUsers.length} user(s)!`);
} catch {
toast.error("Something went wrong, please try again later!");
} finally {
setIsLoading(false);
setInfos([]);
clear();
}
}
};
return (
<>
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
<div className="mt-4 flex flex-col gap-2">
<span>Please upload an Excel file with the following format:</span>
<table className="w-full">
<thead>
<tr>
<th className="border border-neutral-200 px-2 py-1">First Name</th>
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
<th className="border border-neutral-200 px-2 py-1">Country</th>
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
<th className="border border-neutral-200 px-2 py-1">Phone Number</th>
<th className="border border-neutral-200 px-2 py-1">Group Name</th>
</tr>
</thead>
</table>
<span className="mt-4">
<b>Notes:</b>
<ul>
<li>- All incorrect e-mails will be ignored;</li>
<li>- All already registered e-mails will be ignored;</li>
<li>- You may have a header row with the format above, however, it is not necessary;</li>
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
</ul>
</span>
</div>
</Modal>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<div className="flex items-end justify-between">
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
<div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
<BsQuestionCircleFill />
</div>
</div>
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button>
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as Type)}
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
{Object.keys(USER_TYPE_LABELS).map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
<Button className="my-auto" onClick={makeUsers} disabled={infos.length === 0}>
Create
</Button>
</div>
</>
);
}

View File

@@ -1,197 +1,162 @@
import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
import { PERMISSIONS } from "@/constants/userPermissions";
import { Type, User } from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user";
import {PERMISSIONS} from "@/constants/userPermissions";
import {Type, User} from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user";
import axios from "axios";
import clsx from "clsx";
import { capitalize } from "lodash";
import {capitalize} from "lodash";
import moment from "moment";
import { useEffect, useState } from "react";
import {useEffect, useState} from "react";
import ReactDatePicker from "react-datepicker";
import { toast } from "react-toastify";
import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id";
import { checkAccess } from "@/utils/permissions";
import { PermissionType } from "@/interfaces/permissions";
import {checkAccess} from "@/utils/permissions";
import {PermissionType} from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions";
const USER_TYPE_PERMISSIONS: {
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
[key in Type]: {perm: PermissionType | undefined; list: Type[]};
} = {
student: {
perm: "createCodeStudent",
list: [],
},
teacher: {
perm: "createCodeTeacher",
list: [],
},
agent: {
perm: "createCodeCountryManager",
list: [],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
mastercorporate: {
perm: undefined,
list: ["student", "teacher", "corporate"],
},
admin: {
perm: "createCodeAdmin",
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"mastercorporate",
],
},
developer: {
perm: undefined,
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"developer",
"mastercorporate",
],
},
student: {
perm: "createCodeStudent",
list: [],
},
teacher: {
perm: "createCodeTeacher",
list: [],
},
agent: {
perm: "createCodeCountryManager",
list: [],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
mastercorporate: {
perm: undefined,
list: ["student", "teacher", "corporate"],
},
admin: {
perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
},
developer: {
perm: undefined,
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
},
};
export default function CodeGenerator({ user }: { user: User }) {
const [generatedCode, setGeneratedCode] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).toDate()
: null
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
export default function CodeGenerator({user}: {user: User}) {
const [generatedCode, setGeneratedCode] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
const {permissions} = usePermissions(user?.id || "");
useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
const generateCode = (type: Type) => {
const uid = new ShortUniqueId();
const code = uid.randomUUID(6);
const generateCode = (type: Type) => {
const uid = new ShortUniqueId();
const code = uid.randomUUID(6);
axios
.post("/api/code", { type, codes: [code], expiryDate })
.then(({ data, status }) => {
if (data.ok) {
toast.success(`Successfully generated a ${capitalize(type)} code!`, {
toastId: "success",
});
setGeneratedCode(code);
return;
}
axios
.post("/api/code", {type, codes: [code], expiryDate})
.then(({data, status}) => {
if (data.ok) {
toast.success(`Successfully generated a ${capitalize(type)} code!`, {
toastId: "success",
});
setGeneratedCode(code);
return;
}
if (status === 403) {
toast.error(data.reason, { toastId: "forbidden" });
}
})
.catch(({ response: { status, data } }) => {
if (status === 403) {
toast.error(data.reason, { toastId: "forbidden" });
return;
}
if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"});
}
})
.catch(({response: {status, data}}) => {
if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"});
return;
}
toast.error(`Something went wrong, please try again later!`, {
toastId: "error",
});
});
};
toast.error(`Something went wrong, please try again later!`, {
toastId: "error",
});
});
};
return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<label className="font-normal text-base text-mti-gray-dim">
User Code Generator
</label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)}
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
>
{Object.keys(USER_TYPE_LABELS)
.filter((x) => {
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, list, perm);
})
.map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
{user &&
checkAccess(user, ["developer", "admin", "corporate"]) && (
<>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">
Expiry Date
</label>
<Checkbox
isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled}
disabled={!!user.subscriptionExpirationDate}
>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out"
)}
filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(user.subscriptionExpirationDate
? moment(date).isBefore(user.subscriptionExpirationDate)
: true)
}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
<Button
onClick={() => generateCode(type)}
disabled={isExpiryDateEnabled ? !expiryDate : false}
>
Generate
</Button>
<label className="font-normal text-base text-mti-gray-dim">
Generated Code:
</label>
<div
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out"
)}
data-tip="Click to copy"
onClick={() => {
if (generatedCode) navigator.clipboard.writeText(generatedCode);
}}
>
{generatedCode}
</div>
{generatedCode && (
<span className="text-sm text-mti-gray-dim font-light">
Give this code to the user to complete their registration
</span>
)}
</div>
);
return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)}
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
{Object.keys(USER_TYPE_LABELS)
.filter((x) => {
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, list, permissions, perm);
})
.map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}>
Generate
</Button>
)}
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
<div
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
data-tip="Click to copy"
onClick={() => {
if (generatedCode) navigator.clipboard.writeText(generatedCode);
}}>
{generatedCode}
</div>
{generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>}
</div>
);
}

View File

@@ -4,355 +4,306 @@ import Select from "@/components/Low/Select";
import useCodes from "@/hooks/useCodes";
import useUser from "@/hooks/useUser";
import useUsers from "@/hooks/useUsers";
import { Code, User } from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import {Code, User} from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import axios from "axios";
import moment from "moment";
import { useEffect, useState, useMemo } from "react";
import { BsTrash } from "react-icons/bs";
import { toast } from "react-toastify";
import {useEffect, useState, useMemo} from "react";
import {BsTrash} from "react-icons/bs";
import {toast} from "react-toastify";
import ReactDatePicker from "react-datepicker";
import clsx from "clsx";
import {checkAccess} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
const columnHelper = createColumnHelper<Code>();
const CreatorCell = ({ id, users }: { id: string; users: User[] }) => {
const [creatorUser, setCreatorUser] = useState<User>();
const CreatorCell = ({id, users}: {id: string; users: User[]}) => {
const [creatorUser, setCreatorUser] = useState<User>();
useEffect(() => {
setCreatorUser(users.find((x) => x.id === id));
}, [id, users]);
useEffect(() => {
setCreatorUser(users.find((x) => x.id === id));
}, [id, users]);
return (
<>
{(creatorUser?.type === "corporate"
? creatorUser?.corporateInformation?.companyInformation?.name
: creatorUser?.name || "N/A") || "N/A"}{" "}
{creatorUser && `(${USER_TYPE_LABELS[creatorUser.type]})`}
</>
);
return (
<>
{(creatorUser?.type === "corporate" ? creatorUser?.corporateInformation?.companyInformation?.name : creatorUser?.name || "N/A") || "N/A"}{" "}
{creatorUser && `(${USER_TYPE_LABELS[creatorUser.type]})`}
</>
);
};
export default function CodeList({ user }: { user: User }) {
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
export default function CodeList({user}: {user: User}) {
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>(
user?.type === "corporate" ? user : undefined
);
const [filterAvailability, setFilterAvailability] = useState<
"in-use" | "unused"
>();
const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>(user?.type === "corporate" ? user : undefined);
const [filterAvailability, setFilterAvailability] = useState<"in-use" | "unused">();
// const [filteredCodes, setFilteredCodes] = useState<Code[]>([]);
const {permissions} = usePermissions(user?.id || "");
const { users } = useUsers();
const { codes, reload } = useCodes(
user?.type === "corporate" ? user?.id : undefined
);
// const [filteredCodes, setFilteredCodes] = useState<Code[]>([]);
const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate());
const {users} = useUsers();
const {codes, reload} = useCodes(user?.type === "corporate" ? user?.id : undefined);
const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate());
const [endDate, setEndDate] = useState<Date | null>(moment().endOf("day").toDate());
const filteredCodes = useMemo(() => {
return codes.filter((x) => {
// TODO: if the expiry date is missing, it does not make sense to filter by date
// so we need to find a way to handle this edge case
if(startDate && endDate && x.expiryDate) {
const date = moment(x.expiryDate);
if(date.isBefore(startDate) || date.isAfter(endDate)) {
return false;
}
}
if (filteredCorporate && x.creator !== filteredCorporate.id) return false;
if (filterAvailability) {
if (filterAvailability === "in-use" && !x.userId) return false;
if (filterAvailability === "unused" && x.userId) return false;
}
const filteredCodes = useMemo(() => {
return codes.filter((x) => {
// TODO: if the expiry date is missing, it does not make sense to filter by date
// so we need to find a way to handle this edge case
if (startDate && endDate && x.expiryDate) {
const date = moment(x.expiryDate);
if (date.isBefore(startDate) || date.isAfter(endDate)) {
return false;
}
}
if (filteredCorporate && x.creator !== filteredCorporate.id) return false;
if (filterAvailability) {
if (filterAvailability === "in-use" && !x.userId) return false;
if (filterAvailability === "unused" && x.userId) return false;
}
return true;
});
}, [codes, startDate, endDate, filteredCorporate, filterAvailability]);
return true;
});
}, [codes, startDate, endDate, filteredCorporate, filterAvailability]);
const toggleCode = (id: string) => {
setSelectedCodes((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
const toggleCode = (id: string) => {
setSelectedCodes((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const toggleAllCodes = (checked: boolean) => {
if (checked)
return setSelectedCodes(
filteredCodes.filter((x) => !x.userId).map((x) => x.code)
);
const toggleAllCodes = (checked: boolean) => {
if (checked) return setSelectedCodes(filteredCodes.filter((x) => !x.userId).map((x) => x.code));
return setSelectedCodes([]);
};
return setSelectedCodes([]);
};
const deleteCodes = async (codes: string[]) => {
if (
!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)
)
return;
const deleteCodes = async (codes: string[]) => {
if (!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)) return;
const params = new URLSearchParams();
codes.forEach((code) => params.append("code", code));
const params = new URLSearchParams();
codes.forEach((code) => params.append("code", code));
axios
.delete(`/api/code?${params.toString()}`)
.then(() => {
toast.success(`Deleted the codes!`);
setSelectedCodes([]);
})
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Code not found!");
return;
}
axios
.delete(`/api/code?${params.toString()}`)
.then(() => {
toast.success(`Deleted the codes!`);
setSelectedCodes([]);
})
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Code not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this code!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this code!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
const deleteCode = async (code: Code) => {
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`))
return;
const deleteCode = async (code: Code) => {
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`)) return;
axios
.delete(`/api/code/${code.code}`)
.then(() => toast.success(`Deleted the "${code.code}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Code not found!");
return;
}
axios
.delete(`/api/code/${code.code}`)
.then(() => toast.success(`Deleted the "${code.code}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Code not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this code!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this code!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
const defaultColumns = [
columnHelper.accessor("code", {
id: "codeCheckbox",
header: () => (
<Checkbox
disabled={filteredCodes.filter((x) => !x.userId).length === 0}
isChecked={
selectedCodes.length ===
filteredCodes.filter((x) => !x.userId).length &&
filteredCodes.filter((x) => !x.userId).length > 0
}
onChange={(checked) => toggleAllCodes(checked)}
>
{""}
</Checkbox>
),
cell: (info) =>
!info.row.original.userId ? (
<Checkbox
isChecked={selectedCodes.includes(info.getValue())}
onChange={() => toggleCode(info.getValue())}
>
{""}
</Checkbox>
) : null,
}),
columnHelper.accessor("code", {
header: "Code",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("creationDate", {
header: "Creation Date",
cell: (info) =>
info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A",
}),
columnHelper.accessor("email", {
header: "Invited E-mail",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("creator", {
header: "Creator",
cell: (info) => <CreatorCell id={info.getValue()} users={users} />,
}),
columnHelper.accessor("userId", {
header: "Availability",
cell: (info) =>
info.getValue() ? (
<span className="flex gap-1 items-center text-mti-green">
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
</span>
) : (
<span className="flex gap-1 items-center text-mti-red">
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
</span>
),
}),
{
header: "",
id: "actions",
cell: ({ row }: { row: { original: Code } }) => {
return (
<div className="flex gap-4">
{!row.original.userId && (
<div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteCode(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
];
const allowedToDelete = checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "deleteCodes");
const table = useReactTable({
data: filteredCodes,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const defaultColumns = [
columnHelper.accessor("code", {
id: "codeCheckbox",
header: () => (
<Checkbox
disabled={filteredCodes.filter((x) => !x.userId).length === 0}
isChecked={
selectedCodes.length === filteredCodes.filter((x) => !x.userId).length && filteredCodes.filter((x) => !x.userId).length > 0
}
onChange={(checked) => toggleAllCodes(checked)}>
{""}
</Checkbox>
),
cell: (info) =>
!info.row.original.userId ? (
<Checkbox isChecked={selectedCodes.includes(info.getValue())} onChange={() => toggleCode(info.getValue())}>
{""}
</Checkbox>
) : null,
}),
columnHelper.accessor("code", {
header: "Code",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("creationDate", {
header: "Creation Date",
cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A"),
}),
columnHelper.accessor("email", {
header: "Invited E-mail",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("creator", {
header: "Creator",
cell: (info) => <CreatorCell id={info.getValue()} users={users} />,
}),
columnHelper.accessor("userId", {
header: "Availability",
cell: (info) =>
info.getValue() ? (
<span className="flex gap-1 items-center text-mti-green">
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
</span>
) : (
<span className="flex gap-1 items-center text-mti-red">
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
</span>
),
}),
{
header: "",
id: "actions",
cell: ({row}: {row: {original: Code}}) => {
return (
<div className="flex gap-4">
{allowedToDelete && !row.original.userId && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteCode(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
];
return (
<>
<div className="flex items-center justify-between pb-4 pt-1">
<div className="flex items-center gap-4">
<Select
className="!w-96 !py-1"
disabled={user?.type === "corporate"}
isClearable
placeholder="Corporate"
value={
filteredCorporate
? {
label: `${
filteredCorporate.type === "corporate"
? filteredCorporate.corporateInformation
?.companyInformation?.name || filteredCorporate.name
: filteredCorporate.name
} (${USER_TYPE_LABELS[filteredCorporate.type]})`,
value: filteredCorporate.id,
}
: null
}
options={users
.filter((x) =>
["admin", "developer", "corporate"].includes(x.type)
)
.map((x) => ({
label: `${
x.type === "corporate"
? x.corporateInformation?.companyInformation?.name || x.name
: x.name
} (${USER_TYPE_LABELS[x.type]})`,
value: x.id,
user: x,
}))}
onChange={(value) =>
setFilteredCorporate(
value ? users.find((x) => x.id === value?.value) : undefined
)
}
/>
<Select
className="!w-96 !py-1"
placeholder="Availability"
isClearable
options={[
{ label: "In Use", value: "in-use" },
{ label: "Unused", value: "unused" },
]}
onChange={(value) =>
setFilterAvailability(
value ? (value.value as typeof filterAvailability) : undefined
)
}
/>
<ReactDatePicker
dateFormat="dd/MM/yyyy"
className="px-4 py-6 w-full text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
selected={startDate}
startDate={startDate}
endDate={endDate}
selectsRange
showMonthDropdown
filterDate={(date: Date) =>
moment(date).isSameOrBefore(moment(new Date()))
}
onChange={([initialDate, finalDate]: [Date, Date]) => {
setStartDate(initialDate ?? moment("01/01/2023").toDate());
if (finalDate) {
// basicly selecting a final day works as if I'm selecting the first
// minute of that day. this way it covers the whole day
setEndDate(moment(finalDate).endOf("day").toDate());
return;
}
setEndDate(null);
}}
/>
</div>
<div className="flex gap-4 items-center">
<span>{selectedCodes.length} code(s) selected</span>
<Button
disabled={selectedCodes.length === 0}
variant="outline"
color="red"
className="!py-1 px-10"
onClick={() => deleteCodes(selectedCodes)}
>
Delete
</Button>
</div>
</div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</>
);
const table = useReactTable({
data: filteredCodes,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
return (
<>
<div className="flex items-center justify-between pb-4 pt-1">
<div className="flex items-center gap-4">
<Select
className="!w-96 !py-1"
disabled={user?.type === "corporate"}
isClearable
placeholder="Corporate"
value={
filteredCorporate
? {
label: `${
filteredCorporate.type === "corporate"
? filteredCorporate.corporateInformation?.companyInformation?.name || filteredCorporate.name
: filteredCorporate.name
} (${USER_TYPE_LABELS[filteredCorporate.type]})`,
value: filteredCorporate.id,
}
: null
}
options={users
.filter((x) => ["admin", "developer", "corporate"].includes(x.type))
.map((x) => ({
label: `${x.type === "corporate" ? x.corporateInformation?.companyInformation?.name || x.name : x.name} (${
USER_TYPE_LABELS[x.type]
})`,
value: x.id,
user: x,
}))}
onChange={(value) => setFilteredCorporate(value ? users.find((x) => x.id === value?.value) : undefined)}
/>
<Select
className="!w-96 !py-1"
placeholder="Availability"
isClearable
options={[
{label: "In Use", value: "in-use"},
{label: "Unused", value: "unused"},
]}
onChange={(value) => setFilterAvailability(value ? (value.value as typeof filterAvailability) : undefined)}
/>
<ReactDatePicker
dateFormat="dd/MM/yyyy"
className="px-4 py-6 w-full text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
selected={startDate}
startDate={startDate}
endDate={endDate}
selectsRange
showMonthDropdown
filterDate={(date: Date) => moment(date).isSameOrBefore(moment(new Date()))}
onChange={([initialDate, finalDate]: [Date, Date]) => {
setStartDate(initialDate ?? moment("01/01/2023").toDate());
if (finalDate) {
// basicly selecting a final day works as if I'm selecting the first
// minute of that day. this way it covers the whole day
setEndDate(moment(finalDate).endOf("day").toDate());
return;
}
setEndDate(null);
}}
/>
</div>
{allowedToDelete && (
<div className="flex gap-4 items-center">
<span>{selectedCodes.length} code(s) selected</span>
<Button
disabled={selectedCodes.length === 0}
variant="outline"
color="red"
className="!py-1 px-10"
onClick={() => deleteCodes(selectedCodes)}>
Delete
</Button>
</div>
)}
</div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</>
);
}

View File

@@ -14,29 +14,31 @@ import {toast} from "react-toastify";
import readXlsxFile from "read-excel-file";
import {useFilePicker} from "use-file-picker";
import {getUserCorporate} from "@/utils/groups";
import { isAgentUser, isCorporateUser } from "@/resources/user";
import {isAgentUser, isCorporateUser, USER_TYPE_LABELS} from "@/resources/user";
import {checkAccess} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
const columnHelper = createColumnHelper<Group>();
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
const LinkedCorporate = ({userId, users, groups}: {userId: string, users: User[], groups: Group[]}) => {
const LinkedCorporate = ({userId, users, groups}: {userId: string; users: User[]; groups: Group[]}) => {
const [companyName, setCompanyName] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const user = users.find((u) => u.id === userId)
if (!user) return setCompanyName("")
if (isCorporateUser(user)) return setCompanyName(user.corporateInformation?.companyInformation?.name || user.name)
if (isAgentUser(user)) return setCompanyName(user.agentInformation?.companyName || user.name)
const belongingGroups = groups.filter((x) => x.participants.includes(userId))
const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x))
if (belongingGroupsAdmins.length === 0) return setCompanyName("")
const admin = (belongingGroupsAdmins[0] as CorporateUser)
setCompanyName(admin.corporateInformation?.companyInformation.name || admin.name)
useEffect(() => {
const user = users.find((u) => u.id === userId);
if (!user) return setCompanyName("");
if (isCorporateUser(user)) return setCompanyName(user.corporateInformation?.companyInformation?.name || user.name);
if (isAgentUser(user)) return setCompanyName(user.agentInformation?.companyName || user.name);
const belongingGroups = groups.filter((x) => x.participants.includes(userId));
const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x));
if (belongingGroupsAdmins.length === 0) return setCompanyName("");
const admin = belongingGroupsAdmins[0] as CorporateUser;
setCompanyName(admin.corporateInformation?.companyInformation.name || admin.name);
}, [userId, users, groups]);
return isLoading ? <span className="animate-pulse">Loading...</span> : <>{companyName}</>;
@@ -107,7 +109,7 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
const submit = () => {
setIsLoading(true);
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) {
toast.error("That group name is reserved and cannot be used, please enter another one.");
setIsLoading(false);
return;
@@ -152,7 +154,13 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
}))}
options={users
.filter((x) => (user.type === "teacher" ? x.type === "student" : x.type === "student" || x.type === "teacher"))
.filter((x) =>
user.type === "teacher"
? x.type === "student"
: user.type === "corporate"
? x.type === "student" || x.type === "teacher"
: x.type === "student" || x.type === "teacher" || x.type === "corporate",
)
.map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
onChange={(value) => setParticipants(value.map((x) => x.value))}
isMulti
@@ -194,16 +202,20 @@ const filterTypes = ["corporate", "teacher", "mastercorporate"];
export default function GroupList({user}: {user: User}) {
const [isCreating, setIsCreating] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group>();
const [filterByUser, setFilterByUser] = useState(false);
const {permissions} = usePermissions(user?.id || "");
const {users} = useUsers();
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined, user?.type);
const {groups, reload} = useGroups({
admin: user && filterTypes.includes(user?.type) ? user.id : undefined,
userType: user?.type,
});
useEffect(() => {
if (user && (['corporate', 'teacher', 'mastercorporate'].includes(user.type))) {
setFilterByUser(true);
}
}, [user]);
const {groups: corporateGroups} = useGroups({
admin: user && filterTypes.includes(user?.type) ? user.id : undefined,
userType: user?.type,
adminAdmins: user?.id,
});
const deleteGroup = (group: Group) => {
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
@@ -227,7 +239,7 @@ export default function GroupList({user}: {user: User}) {
columnHelper.accessor("admin", {
header: "Admin",
cell: (info) => (
<div className="tooltip" data-tip={capitalize(users.find((x) => x.id === info.getValue())?.type)}>
<div className="tooltip" data-tip={USER_TYPE_LABELS[users.find((x) => x.id === info.getValue())?.type || "student"]}>
{users.find((x) => x.id === info.getValue())?.name}
</div>
),
@@ -250,14 +262,14 @@ export default function GroupList({user}: {user: User}) {
cell: ({row}: {row: {original: Group}}) => {
return (
<>
{user && (user.type === "developer" || user.type === "admin" || user.id === row.original.admin) && (
{user && (checkAccess(user, ["developer", "admin"]) || user.id === row.original.admin) && (
<div className="flex gap-2">
{(!row.original.disableEditing || ["developer", "admin"].includes(user.type)) && (
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "editGroup") && (
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}>
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
</div>
)}
{(!row.original.disableEditing || ["developer", "admin"].includes(user.type)) && (
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "deleteGroup") && (
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
</div>
@@ -290,13 +302,14 @@ export default function GroupList({user}: {user: User}) {
user={user}
onClose={closeModal}
users={
user?.type === "corporate" || user?.type === "teacher"
checkAccess(user, ["corporate", "teacher", "mastercorporate"])
? users.filter(
(u) =>
groups
.filter((g) => g.admin === user.id)
.flatMap((g) => g.participants)
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id),
.includes(u.id) ||
(user?.type === "teacher" ? corporateGroups : groups).flatMap((g) => g.participants).includes(u.id),
)
: users
}
@@ -327,11 +340,13 @@ export default function GroupList({user}: {user: User}) {
</tbody>
</table>
<button
onClick={() => setIsCreating(true)}
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
New Group
</button>
{checkAccess(user, ["teacher", "corporate", "mastercorporate", "admin", "developer"], permissions, "createGroup") && (
<button
onClick={() => setIsCreating(true)}
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
New Group
</button>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { User } from "@/interfaces/user";
import { Tab } from "@headlessui/react";
import {User} from "@/interfaces/user";
import {Tab, TabGroup, TabList, TabPanel, TabPanels} from "@headlessui/react";
import clsx from "clsx";
import CodeList from "./CodeList";
import DiscountList from "./DiscountList";
@@ -7,132 +7,118 @@ import ExamList from "./ExamList";
import GroupList from "./GroupList";
import PackageList from "./PackageList";
import UserList from "./UserList";
import {checkAccess} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
export default function Lists({ user }: { user: User }) {
return (
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1">
<Tab
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}
>
User List
</Tab>
{user?.type === "developer" && (
<Tab
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}
>
Exam List
</Tab>
)}
<Tab
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}
>
Group List
</Tab>
{user && ["developer", "admin", "corporate"].includes(user.type) && (
<Tab
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}
>
Code List
</Tab>
)}
{user && ["developer", "admin"].includes(user.type) && (
<Tab
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}
>
Package List
</Tab>
)}
{user && ["developer", "admin"].includes(user.type) && (
<Tab
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}
>
Discount List
</Tab>
)}
</Tab.List>
<Tab.Panels className="mt-2">
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<UserList user={user} />
</Tab.Panel>
{user?.type === "developer" && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<ExamList user={user} />
</Tab.Panel>
)}
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<GroupList user={user} />
</Tab.Panel>
{user && ["developer", "admin", "corporate"].includes(user.type) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<CodeList user={user} />
</Tab.Panel>
)}
{user && ["developer", "admin"].includes(user.type) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<PackageList user={user} />
</Tab.Panel>
)}
{user && ["developer", "admin"].includes(user.type) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<DiscountList user={user} />
</Tab.Panel>
)}
</Tab.Panels>
</Tab.Group>
);
export default function Lists({user}: {user: User}) {
const {permissions} = usePermissions(user?.id || "");
return (
<TabGroup>
<TabList className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1">
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}>
User List
</Tab>
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "teacher"]) && (
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}>
Exam List
</Tab>
)}
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}>
Group List
</Tab>
{checkAccess(user, ["developer", "admin", "corporate"]) && (
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}>
Code List
</Tab>
)}
{checkAccess(user, ["developer", "admin"]) && (
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}>
Package List
</Tab>
)}
{checkAccess(user, ["developer", "admin"]) && (
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}>
Discount List
</Tab>
)}
</TabList>
<TabPanels className="mt-2">
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<UserList user={user} />
</TabPanel>
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "teacher"]) && (
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<ExamList user={user} />
</TabPanel>
)}
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<GroupList user={user} />
</TabPanel>
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "viewCodes") && (
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<CodeList user={user} />
</TabPanel>
)}
{checkAccess(user, ["developer", "admin"]) && (
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<PackageList user={user} />
</TabPanel>
)}
{checkAccess(user, ["developer", "admin"]) && (
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<DiscountList user={user} />
</TabPanel>
)}
</TabPanels>
</TabGroup>
);
}

View File

@@ -12,7 +12,7 @@ import Selection from "@/exams/Selection";
import Speaking from "@/exams/Speaking";
import Writing from "@/exams/Writing";
import useUser from "@/hooks/useUser";
import {Exam, UserSolution, Variant} from "@/interfaces/exam";
import {Exam, LevelExam, UserSolution, Variant} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation";
@@ -23,6 +23,7 @@ import {toast, ToastContainer} from "react-toastify";
import {v4 as uuidv4} from "uuid";
import useSessions from "@/hooks/useSessions";
import ShortUniqueId from "short-unique-id";
import clsx from "clsx";
interface Props {
page: "exams" | "exercises";
@@ -54,6 +55,7 @@ export default function ExamPage({page}: Props) {
const {showSolutions, setShowSolutions} = useExamStore((state) => state);
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
const {inactivity, setInactivity} = useExamStore((state) => state);
const {bgColor, setBgColor} = useExamStore((state) => state);
const {user} = useUser({redirectTo: "/login"});
const router = useRouter();
@@ -257,6 +259,7 @@ export default function ExamPage({page}: Props) {
user: user?.id || "",
date: new Date().getTime(),
isDisabled: solution.isDisabled,
shuffleMaps: solution.shuffleMaps,
...(assignment ? {assignment: assignment.id} : {}),
}));
@@ -279,6 +282,13 @@ export default function ExamPage({page}: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statsAwaitingEvaluation]);
useEffect(()=> {
if(exam && exam.module === "level" && exam.parts[0].intro && !showSolutions) {
setBgColor("bg-ielts-level-light");
}
}, [exam, showSolutions, setBgColor])
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
setTimeout(async () => {
try {
@@ -459,6 +469,19 @@ export default function ExamPage({page}: Props) {
inactivity: totalInactivity,
}}
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);
}
setShowSolutions(true);
setModuleIndex(index || 0);
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);
@@ -499,6 +522,7 @@ export default function ExamPage({page}: Props) {
{user && (
<Layout
user={user}
bgColor={bgColor}
className="justify-between"
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>

View File

@@ -230,7 +230,11 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
);
};
const LevelGeneration = () => {
interface Props {
id: string;
}
const LevelGeneration = ({ id } : Props) => {
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<LevelExam>();
@@ -420,10 +424,16 @@ const LevelGeneration = () => {
return;
}
if(!id) {
toast.error("Please insert a title before submitting");
return;
}
setIsLoading(true);
const exam = {
...generatedExam,
id,
parts: generatedExam.parts.map((p, i) => ({...p, exercises: parts[i].part!.exercises})),
};

View File

@@ -228,7 +228,11 @@ interface ListeningPart {
| string;
}
const ListeningGeneration = () => {
interface Props {
id: string;
}
const ListeningGeneration = ({ id } : Props) => {
const [part1, setPart1] = useState<ListeningPart>();
const [part2, setPart2] = useState<ListeningPart>();
const [part3, setPart3] = useState<ListeningPart>();
@@ -258,11 +262,16 @@ const ListeningGeneration = () => {
console.log({parts});
if (parts.length === 0) return toast.error("Please generate at least one section!");
if(!id) {
toast.error("Please insert a title before submitting");
return;
}
setIsLoading(true);
axios
.post(`/api/exam/listening/generate/listening`, {
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
id,
parts,
minTimer,
difficulty,

View File

@@ -258,7 +258,11 @@ const PartTab = ({
);
};
const ReadingGeneration = () => {
interface Props {
id: string;
}
const ReadingGeneration = ({ id } : Props) => {
const [part1, setPart1] = useState<ReadingPart>();
const [part2, setPart2] = useState<ReadingPart>();
const [part3, setPart3] = useState<ReadingPart>();
@@ -300,13 +304,18 @@ const ReadingGeneration = () => {
return;
}
if(!id) {
toast.error("Please insert a title before submitting");
return;
}
setIsLoading(true);
const exam: ReadingExam = {
parts,
isDiagnostic: false,
minTimer,
module: "reading",
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
id,
type: "academic",
variant: parts.length === 3 ? "full" : "partial",
difficulty,
@@ -328,7 +337,7 @@ const ReadingGeneration = () => {
})
.catch((error) => {
console.log(error);
toast.error("Something went wrong while generating, please try again later.");
toast.error(error.response.data.error || "Something went wrong while generating, please try again later.");
})
.finally(() => setIsLoading(false));
};

View File

@@ -221,7 +221,11 @@ interface SpeakingPart {
avatar?: (typeof AVATARS)[number];
}
const SpeakingGeneration = () => {
interface Props {
id: string;
}
const SpeakingGeneration = ({ id } : Props) => {
const [part1, setPart1] = useState<SpeakingPart>();
const [part2, setPart2] = useState<SpeakingPart>();
const [part3, setPart3] = useState<SpeakingPart>();
@@ -243,6 +247,11 @@ const SpeakingGeneration = () => {
const submitExam = () => {
if (!part1?.result && !part2?.result && !part3?.result) return toast.error("Please generate at least one task!");
if(!id) {
toast.error("Please insert a title before submitting");
return;
}
setIsLoading(true);
const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x);
@@ -256,7 +265,7 @@ const SpeakingGeneration = () => {
}));
const exam: SpeakingExam = {
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
id,
isDiagnostic: false,
exercises: exercises as (SpeakingExercise | InteractiveSpeakingExercise)[],
minTimer,

View File

@@ -75,7 +75,11 @@ const TaskTab = ({task, index, difficulty, setTask}: {task?: string; difficulty:
);
};
const WritingGeneration = () => {
interface Props {
id: string;
}
const WritingGeneration = ({ id } : Props) => {
const [task1, setTask1] = useState<string>();
const [task2, setTask2] = useState<string>();
const [minTimer, setMinTimer] = useState(60);
@@ -116,6 +120,11 @@ const WritingGeneration = () => {
return;
}
if(!id) {
toast.error("Please insert a title before submitting");
return;
}
const exercise1 = task1
? ({
id: v4(),
@@ -152,7 +161,7 @@ const WritingGeneration = () => {
minTimer,
module: "writing",
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
id,
variant: exercise1 && exercise2 ? "full" : "partial",
difficulty,
};

View File

@@ -31,7 +31,7 @@ export default function PaymentDue({user, hasExpired = false, reload}: Props) {
const {packages} = usePackages();
const {discounts} = useDiscounts();
const {users} = useUsers();
const {groups} = useGroups();
const {groups} = useGroups({});
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id});
useEffect(() => {

View File

@@ -0,0 +1,442 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { app, storage } from "@/firebase";
import {
getFirestore,
doc,
getDoc,
updateDoc,
getDocs,
query,
collection,
where,
documentId,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
import { CorporateUser, MasterCorporateUser } from "@/interfaces/user";
import { User } from "@/interfaces/user";
import { Module } from "@/interfaces";
import moment from "moment-timezone";
import ExcelJS from "exceljs";
import { getStudentGroupsForUsersWithoutAdmin } from "@/utils/groups.be";
import { getSpecificUsers, getUser } from "@/utils/users.be";
import { getUserName } from "@/utils/users";
interface GroupScoreSummaryHelper {
score: [number, number];
label: string;
sessions: string[];
}
interface AssignmentData {
assigner: string;
assignees: string[];
results: any;
exams: { module: Module }[];
startDate: string;
excel: {
path: string;
version: string;
};
name: string;
}
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
// if (req.method === "GET") return get(req, res);
if (req.method === "POST") return await post(req, res);
}
function logWorksheetData(worksheet: any) {
worksheet.eachRow((row: any, rowNumber: number) => {
console.log(`Row ${rowNumber}:`);
row.eachCell((cell: any, colNumber: number) => {
console.log(` Cell ${colNumber}: ${cell.value}`);
});
});
}
function commonExcel({
data,
userName,
users,
sectionName,
customTable,
customTableHeaders,
renderCustomTableData,
}: {
data: AssignmentData;
userName: string;
users: User[];
sectionName: string;
customTable: string[][];
customTableHeaders: string[];
renderCustomTableData: (data: any) => string[];
}) {
const allStats = data.results.flatMap((r: any) => r.stats);
const uniqueExercises = [...new Set(allStats.map((s: any) => s.exercise))];
const assigneesData = data.assignees
.map((assignee: string) => {
const userStats = allStats.filter((s: any) => s.user === assignee);
const dates = userStats.map((s: any) => moment(s.date));
return {
userId: assignee,
user: users.find((u) => u.id === assignee),
...userStats.reduce(
(acc: any, curr: any) => {
return {
...acc,
correct: acc.correct + curr.score.correct,
missing: acc.missing + curr.score.missing,
total: acc.total + curr.score.total,
};
},
{ correct: 0, missing: 0, total: 0 }
),
firstDate: moment.min(...dates),
lastDate: moment.max(...dates),
stats: userStats,
};
})
.sort((a, b) => b.correct - a.correct);
const results = assigneesData.map((r: any) => r.correct);
const highestScore = Math.max(...results);
const lowestScore = Math.min(...results);
const averageScore = results.reduce((a, b) => a + b, 0) / results.length;
const firstDate = moment.min(assigneesData.map((r: any) => r.firstDate));
const lastDate = moment.max(assigneesData.map((r: any) => r.lastDate));
const firstSectionData = [
{
label: sectionName,
value: userName,
},
{
label: "Report Download date :",
value: moment().format("DD/MM/YYYY"),
},
{ label: "Test Information :", value: data.name },
{
label: "Date of Test :",
value: moment(data.startDate).format("DD/MM/YYYY"),
},
{ label: "Number of Candidates :", value: data.assignees.length },
{ label: "Highest score :", value: highestScore },
{ label: "Lowest score :", value: lowestScore },
{ label: "Average score :", value: averageScore },
{ label: "", value: "" },
{
label: "Date and time of First submission :",
value: firstDate.format("DD/MM/YYYY"),
},
{
label: "Date and time of Last submission :",
value: lastDate.format("DD/MM/YYYY"),
},
];
// Create a new workbook and add a worksheet
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("Report Data");
// Populate the worksheet with the data
firstSectionData.forEach(({ label, value }, index) => {
worksheet.getCell(`A${index + 1}`).value = label; // First column (labels)
worksheet.getCell(`B${index + 1}`).value = value; // Second column (values)
});
// added empty arrays to force row spacings
const customTableAndLine = [[],...customTable, []];
customTableAndLine.forEach((row: string[], index) => {
worksheet.addRow(row);
});
// Define the static part of the headers (before "Test Sections")
const staticHeaders = [
"Sr N",
"Candidate ID",
"First and Last Name",
"Passport/ID",
"Email ID",
"Gender",
...customTableHeaders,
];
// Define additional headers after "Test Sections"
const additionalHeaders = ["Time Spent", "Date", "Score"];
// Calculate the dynamic columns based on the testSectionsArray
const testSectionHeaders = uniqueExercises.map(
(section, index) => `Part ${index + 1}`
);
const tableColumnHeadersFirstPart = [
...staticHeaders,
...uniqueExercises.map((a) => "Test Sections"),
];
// Add the main header row, merging static columns and "Test Sections"
const tableColumnHeaders = [
...tableColumnHeadersFirstPart,
...additionalHeaders,
];
worksheet.addRow(tableColumnHeaders);
// 1 headers rows
const startIndexTable = firstSectionData.length + customTableAndLine.length + 1;
// // Merge "Test Sections" over dynamic number of columns
// const tableColumns = staticHeaders.length + numberOfTestSections;
// K10:M12 = 10,11,12,13
// horizontally group Test Sections
worksheet.mergeCells(
startIndexTable,
staticHeaders.length + 1,
startIndexTable,
tableColumnHeadersFirstPart.length
);
// Add the dynamic second and third header rows for test sections and sub-columns
worksheet.addRow([
...Array(staticHeaders.length).fill(""),
...testSectionHeaders,
"",
"",
"",
]);
worksheet.addRow([
...Array(staticHeaders.length).fill(""),
...uniqueExercises.map(() => "Grammar & Vocabulary"),
"",
"",
"",
]);
worksheet.addRow([
...Array(staticHeaders.length).fill(""),
...uniqueExercises.map(
(exercise) => allStats.find((s: any) => s.exercise === exercise).type
),
"",
"",
"",
]);
// vertically group based on the part, exercise and type
staticHeaders.forEach((header, index) => {
worksheet.mergeCells(startIndexTable, index + 1, startIndexTable + 3, index + 1);
});
assigneesData.forEach((data, index) => {
worksheet.addRow([
index + 1,
data.userId,
data.user.name,
data.user.demographicInformation?.passportId,
data.user.email,
data.user.demographicInformation?.gender,
...renderCustomTableData(data),
...uniqueExercises.map((exercise) => {
const score = data.stats.find(
(s: any) => s.exercise === exercise && s.user === data.userId
).score;
return `${score.correct}/${score.total}`;
}),
`${Math.ceil(
data.stats.reduce((acc: number, curr: any) => acc + curr.timeSpent, 0) /
60
)} minutes`,
data.lastDate.format("DD/MM/YYYY HH:mm"),
data.correct,
]);
});
worksheet.addRow([""]);
worksheet.addRow([""]);
for (let i = 0; i < tableColumnHeaders.length; i++) {
worksheet.getColumn(i + 1).width = 30;
}
// Apply styles to the headers
[startIndexTable].forEach((rowNumber) => {
worksheet.getRow(rowNumber).eachCell((cell) => {
if (cell.value) {
cell.fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FFBFBFBF" }, // Grey color for headers
};
cell.font = { bold: true };
cell.alignment = { vertical: "middle", horizontal: "center" };
}
});
});
worksheet.addRow(["Printed by: Confidential Information"]);
worksheet.addRow(["info@encoach.com"]);
// Convert workbook to Buffer (Node.js) or Blob (Browser)
return workbook.xlsx.writeBuffer();
}
function corporateAssignment(
user: CorporateUser,
data: AssignmentData,
users: User[]
) {
return commonExcel({
data,
userName: user.corporateInformation?.companyInformation?.name || "",
users,
sectionName: "Corporate Name :",
customTable: [],
customTableHeaders: [],
renderCustomTableData: () => [],
});
}
async function mastercorporateAssignment(
user: MasterCorporateUser,
data: AssignmentData,
users: User[]
) {
const userGroups = await getStudentGroupsForUsersWithoutAdmin(
user.id,
data.assignees
);
const adminUsers = [...new Set(userGroups.map((g) => g.admin))];
const userGroupsParticipants = userGroups.flatMap((g) => g.participants);
const adminsData = await getSpecificUsers(adminUsers);
const companiesData = adminsData.map((user) => {
const name = getUserName(user);
const users = userGroupsParticipants
.filter((p) => data.assignees.includes(p));
const stats = data.results
.flatMap((r: any) => r.stats)
.filter((s: any) => users.includes(s.user));
const correct = stats.reduce((acc: number, s: any) => acc + s.score.correct, 0);
const total = stats.reduce(
(acc: number, curr: any) => acc + curr.score.total,
0
);
return {
name,
correct,
total,
};
});
const customTable = [
...companiesData,
{
name: "Total",
correct: companiesData.reduce((acc, curr) => acc + curr.correct, 0),
total: companiesData.reduce((acc, curr) => acc + curr.total, 0),
},
].map((c) => [c.name, `${c.correct}/${c.total}`])
const customTableHeaders = [{ name: "Corporate", helper: (data: any) => data.user.corporateName}];
return commonExcel({
data,
userName: user.corporateInformation?.companyInformation?.name || "",
users: users.map((u) => {
const userGroup = userGroups.find((g) => g.participants.includes(u.id));
const admin = adminsData.find((a) => a.id === userGroup?.admin);
return {
...u,
corporateName: getUserName(admin),
}
}),
sectionName: "Master Corporate Name :",
customTable: [['Corporate Summary'], ...customTable],
customTableHeaders: customTableHeaders.map((h) => h.name),
renderCustomTableData: (data) => customTableHeaders.map((h) => h.helper(data)),
});
}
async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to export
if (req.session.user) {
const { id } = req.query as { id: string };
const docSnap = await getDoc(doc(db, "assignments", id));
const data = docSnap.data() as AssignmentData;
if (!data) {
res.status(400).end();
return;
}
// if (
// data.excel &&
// data.excel.path &&
// data.excel.version === process.env.EXCEL_VERSION
// ) {
// // if it does, return the excel url
// const fileRef = ref(storage, data.excel.path);
// const url = await getDownloadURL(fileRef);
// res.status(200).end(url);
// return;
// }
const docsSnap = await getDocs(
query(collection(db, "users"), where(documentId(), "in", data.assignees))
);
const users = docsSnap.docs.map((d) => ({
...d.data(),
id: d.id,
})) as User[];
const docUser = await getDoc(doc(db, "users", data.assigner));
if (docUser.exists()) {
// we'll need the user in order to get the user data (name, email, focus, etc);
const user = docUser.data() as User;
// generate the file ref for storage
const fileName = `${Date.now().toString()}.xlsx`;
const refName = `assignment_report/${fileName}`;
const fileRef = ref(storage, refName);
const getExcelFn = () => {
switch (user.type) {
case "teacher":
case "corporate":
return corporateAssignment(user as CorporateUser, data, users);
case "mastercorporate":
return mastercorporateAssignment(user as MasterCorporateUser, data, users);
default:
throw new Error("Invalid user type");
}
};
const buffer = await getExcelFn();
// upload the pdf to storage
const snapshot = await uploadBytes(fileRef, buffer, {
contentType:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
// update the stats entries with the pdf url to prevent duplication
await updateDoc(docSnap.ref, {
excel: {
path: refName,
version: process.env.EXCEL_VERSION,
},
});
const url = await getDownloadURL(fileRef);
res.status(200).end(url);
return;
}
}
res.status(401).json({ message: "Unauthorized" });
}

View File

@@ -0,0 +1,40 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
import {Module} from "@/interfaces";
import {getExams} from "@/utils/exams.be";
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
import {capitalize, flatten, uniqBy} from "lodash";
import {User} from "@/interfaces/user";
import moment from "moment";
import {sendEmail} from "@/email";
import {getAllAssignersByCorporate} from "@/utils/groups.be";
import {getAssignmentsByAssigners} from "@/utils/assignments.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
if (req.method === "GET") return GET(req, res);
res.status(404).json({ok: false});
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query as {id: string};
const assigners = await getAllAssignersByCorporate(id);
const assignments = await getAssignmentsByAssigners([...assigners, id]);
res.status(200).json(uniqBy(assignments, "id"));
}

View File

@@ -0,0 +1,34 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { getAllAssignersByCorporate } from "@/utils/groups.be";
import { getAssignmentsByAssigners } from "@/utils/assignments.be";
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
if (req.method === "GET") return await GET(req, res);
res.status(404).json({ ok: false });
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
const { ids } = req.query as { ids: string };
try {
const idsList = ids.split(",");
const assigners = await Promise.all(idsList.map(getAllAssignersByCorporate));
const assignmentList = [...assigners.flat(), ...idsList];
const assignments = await getAssignmentsByAssigners(assignmentList);
res.status(200).json(assignments);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
}

View File

@@ -1,174 +1,160 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
setDoc,
doc,
query,
collection,
where,
getDocs,
getDoc,
deleteDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Code, Type } from "@/interfaces/user";
import { PERMISSIONS } from "@/constants/userPermissions";
import { uuidv4 } from "@firebase/util";
import { prepareMailer, prepareMailOptions } from "@/email";
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, setDoc, doc, query, collection, where, getDocs, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Code, Group, Type} from "@/interfaces/user";
import {PERMISSIONS} from "@/constants/userPermissions";
import {uuidv4} from "@firebase/util";
import {prepareMailer, prepareMailOptions} from "@/email";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
if (req.method === "POST") return post(req, res);
if (req.method === "DELETE") return del(req, res);
if (req.method === "GET") return get(req, res);
if (req.method === "POST") return post(req, res);
if (req.method === "DELETE") return del(req, res);
return res.status(404).json({ ok: false });
return res.status(404).json({ok: false});
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res
.status(401)
.json({ ok: false, reason: "You must be logged in to generate a code!" });
return;
}
if (!req.session.user) {
res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
return;
}
const { creator } = req.query as { creator?: string };
const q = query(
collection(db, "codes"),
where("creator", "==", creator || ""),
);
const snapshot = await getDocs(creator ? q : collection(db, "codes"));
const {creator} = req.query as {creator?: string};
const q = query(collection(db, "codes"), where("creator", "==", creator || ""));
const snapshot = await getDocs(creator ? q : collection(db, "codes"));
res.status(200).json(snapshot.docs.map((doc) => doc.data()));
res.status(200).json(snapshot.docs.map((doc) => doc.data()));
}
async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res
.status(401)
.json({ ok: false, reason: "You must be logged in to generate a code!" });
return;
}
if (!req.session.user) {
res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
return;
}
const { type, codes, infos, expiryDate } = req.body as {
type: Type;
codes: string[];
infos?: { email: string; name: string; passport_id?: string }[];
expiryDate: null | Date;
};
const permission = PERMISSIONS.generateCode[type];
const {type, codes, infos, expiryDate} = req.body as {
type: Type;
codes: string[];
infos?: {email: string; name: string; passport_id?: string}[];
expiryDate: null | Date;
};
const permission = PERMISSIONS.generateCode[type];
if (!permission.includes(req.session.user.type)) {
res.status(403).json({
ok: false,
reason:
"Your account type does not have permissions to generate a code for that type of user!",
});
return;
}
if (!permission.includes(req.session.user.type)) {
res.status(403).json({
ok: false,
reason: "Your account type does not have permissions to generate a code for that type of user!",
});
return;
}
const codesGeneratedByUserSnapshot = await getDocs(
query(collection(db, "codes"), where("creator", "==", req.session.user.id)),
);
const userCodes = codesGeneratedByUserSnapshot.docs.map((x) => ({
...x.data(),
}));
const codesGeneratedByUserSnapshot = await getDocs(query(collection(db, "codes"), where("creator", "==", req.session.user.id)));
const creatorGroupsSnapshot = await getDocs(query(collection(db, "groups"), where("admin", "==", req.session.user.id)));
if (req.session.user.type === "corporate") {
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length;
const allowedCodes =
req.session.user.corporateInformation?.companyInformation.userAmount || 0;
const creatorGroups = (
creatorGroupsSnapshot.docs.map((x) => ({
...x.data(),
})) as Group[]
).filter((x) => x.name === "Students" || x.name === "Teachers" || x.name === "Corporate");
if (totalCodes > allowedCodes) {
res.status(403).json({
ok: false,
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${
allowedCodes - codesGeneratedByUserSnapshot.docs.length
} codes.`,
});
return;
}
}
const usersInGroups = creatorGroups.flatMap((x) => x.participants);
const userCodes = codesGeneratedByUserSnapshot.docs.map((x) => ({
...x.data(),
})) as Code[];
const codePromises = codes.map(async (code, index) => {
const codeRef = doc(db, "codes", code);
let codeInformation = {
type,
code,
creator: req.session.user!.id,
creationDate: new Date().toISOString(),
expiryDate,
};
if (req.session.user.type === "corporate") {
const totalCodes = userCodes.filter((x) => !x.userId || !usersInGroups.includes(x.userId)).length + usersInGroups.length + codes.length;
const allowedCodes = req.session.user.corporateInformation?.companyInformation.userAmount || 0;
if (infos && infos.length > index) {
const { email, name, passport_id } = infos[index];
const previousCode = userCodes.find((x) => x.email === email) as Code;
if (totalCodes > allowedCodes) {
res.status(403).json({
ok: false,
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${
allowedCodes - codesGeneratedByUserSnapshot.docs.length
} codes.`,
});
return;
}
}
const transport = prepareMailer();
const mailOptions = prepareMailOptions(
{
type,
code: previousCode ? previousCode.code : code,
environment: process.env.ENVIRONMENT,
},
[email.toLowerCase().trim()],
"EnCoach Registration",
"main",
);
const codePromises = codes.map(async (code, index) => {
const codeRef = doc(db, "codes", code);
let codeInformation = {
type,
code,
creator: req.session.user!.id,
creationDate: new Date().toISOString(),
expiryDate,
};
try {
await transport.sendMail(mailOptions);
if (infos && infos.length > index) {
const {email, name, passport_id} = infos[index];
const previousCode = userCodes.find((x) => x.email === email) as Code;
if (!previousCode) {
await setDoc(
codeRef,
{
...codeInformation,
email: email.trim().toLowerCase(),
name: name.trim(),
...(passport_id ? { passport_id: passport_id.trim() } : {}),
},
{ merge: true },
);
}
const transport = prepareMailer();
const mailOptions = prepareMailOptions(
{
type,
code: previousCode ? previousCode.code : code,
environment: process.env.ENVIRONMENT,
},
[email.toLowerCase().trim()],
"EnCoach Registration",
"main",
);
return true;
} catch (e) {
return false;
}
} else {
await setDoc(codeRef, codeInformation);
}
});
try {
await transport.sendMail(mailOptions);
Promise.all(codePromises).then((results) => {
res.status(200).json({ ok: true, valid: results.filter((x) => x).length });
});
if (!previousCode) {
await setDoc(
codeRef,
{
...codeInformation,
email: email.trim().toLowerCase(),
name: name.trim(),
...(passport_id ? {passport_id: passport_id.trim()} : {}),
},
{merge: true},
);
}
return true;
} catch (e) {
return false;
}
} else {
await setDoc(codeRef, codeInformation);
}
});
Promise.all(codePromises).then((results) => {
res.status(200).json({ok: true, valid: results.filter((x) => x).length});
});
}
async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res
.status(401)
.json({ ok: false, reason: "You must be logged in to generate a code!" });
return;
}
if (!req.session.user) {
res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
return;
}
const codes = req.query.code as string[];
const codes = req.query.code as string[];
for (const code of codes) {
const snapshot = await getDoc(doc(db, "codes", code as string));
if (!snapshot.exists()) continue;
for (const code of codes) {
const snapshot = await getDoc(doc(db, "codes", code as string));
if (!snapshot.exists()) continue;
await deleteDoc(snapshot.ref);
}
await deleteDoc(snapshot.ref);
}
res.status(200).json({ codes });
res.status(200).json({codes});
}

View File

@@ -1,54 +1,89 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, setDoc, doc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
import {getExams} from "@/utils/exams.be";
import {Module} from "@/interfaces";
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
setDoc,
doc,
runTransaction,
collection,
query,
where,
getDocs,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Exam, InstructorGender, Variant } from "@/interfaces/exam";
import { getExams } from "@/utils/exams.be";
import { Module } from "@/interfaces";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await GET(req, res);
if (req.method === "POST") return await POST(req, res);
if (req.method === "GET") return await GET(req, res);
if (req.method === "POST") return await POST(req, res);
res.status(404).json({ok: false});
res.status(404).json({ ok: false });
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const {module, avoidRepeated, variant, instructorGender} = req.query as {
module: Module;
avoidRepeated: string;
variant?: Variant;
instructorGender?: InstructorGender;
};
const { module, avoidRepeated, variant, instructorGender } = req.query as {
module: Module;
avoidRepeated: string;
variant?: Variant;
instructorGender?: InstructorGender;
};
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender);
res.status(200).json(exams);
const exams: Exam[] = await getExams(
db,
module,
avoidRepeated,
req.session.user.id,
variant,
instructorGender
);
res.status(200).json(exams);
}
async function POST(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
if (req.session.user.type !== "developer") {
res.status(403).json({ok: false});
return;
}
const {module} = req.query as {module: string};
if (req.session.user.type !== "developer") {
res.status(403).json({ ok: false });
return;
}
const { module } = req.query as { module: string };
const exam = {...req.body, module: module, createdBy: req.session.user.id, createdAt: new Date().toISOString()};
await setDoc(doc(db, module, req.body.id), exam);
try {
const exam = {
...req.body,
module: module,
createdBy: req.session.user.id,
createdAt: new Date().toISOString(),
};
await runTransaction(db, async (transaction) => {
const docRef = doc(db, module, req.body.id);
const docSnap = await transaction.get(docRef);
res.status(200).json(exam);
if (docSnap.exists()) {
throw new Error("Name already exists");
}
const newDocRef = doc(db, module, req.body.id);
transaction.set(newDocRef, exam);
});
res.status(200).json(exam);
} catch (error) {
console.error("Transaction failed: ", error);
res.status(500).json({ ok: false, error: (error as any).message });
}
}

View File

@@ -1,118 +1,68 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
collection,
getDocs,
setDoc,
doc,
query,
where,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Group } from "@/interfaces/user";
import { v4 } from "uuid";
import { updateExpiryDateOnGroup } from "@/utils/groups.be";
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, setDoc, doc, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Group} from "@/interfaces/user";
import {v4} from "uuid";
import {updateExpiryDateOnGroup, getGroupsForUser} from "@/utils/groups.be";
import {uniqBy} from "lodash";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
if (req.method === "GET") await get(req, res);
if (req.method === "POST") await post(req, res);
if (req.method === "GET") await get(req, res);
if (req.method === "POST") await post(req, res);
}
const getGroupsForUser = async (admin: string, participant: string) => {
try {
const queryConstraints = [
...(admin ? [where("admin", "==", admin)] : []),
...(participant
? [where("participants", "array-contains", participant)]
: []),
];
const snapshot = await getDocs(
queryConstraints.length > 0
? query(collection(db, "groups"), ...queryConstraints)
: collection(db, "groups")
);
const groups = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
return groups;
} catch (e) {
console.error(e);
return [];
}
};
async function get(req: NextApiRequest, res: NextApiResponse) {
const { admin, participant } = req.query as {
admin: string;
participant: string;
};
const {admin, participant} = req.query as {
admin: string;
participant: string;
};
if (req.session?.user?.type === "mastercorporate") {
try {
const masterCorporateGroups = await getGroupsForUser(admin, participant);
const corporatesFromMaster = masterCorporateGroups
.filter((g) => g.name === "Corporate")
.flatMap((g) => g.participants);
if (req.session?.user?.type === "mastercorporate") {
try {
const masterCorporateGroups = await getGroupsForUser(admin, participant);
const corporatesFromMaster = masterCorporateGroups.filter((g) => g.name.trim() === "Corporate").flatMap((g) => g.participants);
if (corporatesFromMaster.length === 0) {
res.status(200).json([]);
return;
}
Promise.all(
corporatesFromMaster.map((c) => getGroupsForUser(c, participant))
)
.then((groups) => {
res.status(200).json([...masterCorporateGroups, ...groups.flat()]);
return;
})
.catch((e) => {
console.error(e);
res.status(500).json({ ok: false });
return;
});
} catch (e) {
console.error(e);
res.status(500).json({ ok: false });
return;
}
return;
}
if (corporatesFromMaster.length === 0) return res.status(200).json(masterCorporateGroups);
try {
const groups = await getGroupsForUser(admin, participant);
res.status(200).json(groups);
} catch (e) {
console.error(e);
res.status(500).json({ ok: false });
}
const groups = await Promise.all(corporatesFromMaster.map((c) => getGroupsForUser(c, participant)));
return res.status(200).json([...masterCorporateGroups, ...uniqBy(groups.flat(), "id")]);
} catch (e) {
console.error(e);
res.status(500).json({ok: false});
return;
}
}
try {
const groups = await getGroupsForUser(admin, participant);
res.status(200).json(groups);
} catch (e) {
console.error(e);
res.status(500).json({ok: false});
}
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const body = req.body as Group;
const body = req.body as Group;
await Promise.all(
body.participants.map(
async (p) => await updateExpiryDateOnGroup(p, body.admin)
)
);
await Promise.all(body.participants.map(async (p) => await updateExpiryDateOnGroup(p, body.admin)));
await setDoc(doc(db, "groups", v4()), {
name: body.name,
admin: body.admin,
participants: body.participants,
});
res.status(200).json({ ok: true });
await setDoc(doc(db, "groups", v4()), {
name: body.name,
admin: body.admin,
participants: body.participants,
});
res.status(200).json({ok: true});
}

View File

@@ -1,20 +1,8 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import {
getFirestore,
setDoc,
doc,
query,
collection,
where,
getDocs,
getDoc,
deleteDoc,
limit,
updateDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, setDoc, doc, query, collection, where, getDocs, getDoc, deleteDoc, limit, updateDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {v4} from "uuid";
import {Group} from "@/interfaces/user";
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
@@ -39,114 +27,108 @@ const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return post(req, res);
if (req.method === "POST") return post(req, res);
return res.status(404).json({ ok: false });
return res.status(404).json({ok: false});
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const maker = req.session.user;
if (!maker) {
return res
.status(401)
.json({ ok: false, reason: "You must be logged in to make user!" });
}
const { email, passport_id, type, groupName } = req.body as {
email: string;
passport_id: string;
type: string,
groupName: string
};
// cleaning data
delete req.body.passport_id;
delete req.body.groupName;
const maker = req.session.user;
if (!maker) {
return res.status(401).json({ok: false, reason: "You must be logged in to make user!"});
}
const {email, passport_id, type, groupName, expiryDate} = req.body as {
email: string;
passport_id: string;
type: string;
groupName: string;
expiryDate: null | Date;
};
// cleaning data
delete req.body.passport_id;
delete req.body.groupName;
delete req.body.expiryDate;
await createUserWithEmailAndPassword(auth, email.toLowerCase(), passport_id)
.then(async (userCredentials) => {
const userId = userCredentials.user.uid;
await createUserWithEmailAndPassword(auth, email.toLowerCase(), passport_id)
.then(async (userCredentials) => {
const userId = userCredentials.user.uid;
const user = {
...req.body,
bio: "",
type: type,
focus: "academic",
status: "paymentDue",
desiredLevels: DEFAULT_DESIRED_LEVELS,
levels: DEFAULT_LEVELS,
isFirstLogin: false,
isVerified: true
};
await setDoc(doc(db, "users", userId), user);
if (type === "corporate") {
const defaultTeachersGroup: Group = {
admin: userId,
id: v4(),
name: "Teachers",
participants: [],
disableEditing: true,
};
const user = {
...req.body,
bio: "",
type: type,
focus: "academic",
status: "active",
desiredLevels: DEFAULT_DESIRED_LEVELS,
levels: DEFAULT_LEVELS,
isFirstLogin: false,
isVerified: true,
registrationDate: new Date(),
subscriptionExpirationDate: expiryDate || null,
};
await setDoc(doc(db, "users", userId), user);
if (type === "corporate") {
const defaultTeachersGroup: Group = {
admin: userId,
id: v4(),
name: "Teachers",
participants: [],
disableEditing: true,
};
const defaultStudentsGroup: Group = {
admin: userId,
id: v4(),
name: "Students",
participants: [],
disableEditing: true,
};
const defaultStudentsGroup: Group = {
admin: userId,
id: v4(),
name: "Students",
participants: [],
disableEditing: true,
};
const defaultCorporateGroup: Group = {
admin: userId,
id: v4(),
name: "Corporate",
participants: [],
disableEditing: true,
};
const defaultCorporateGroup: Group = {
admin: userId,
id: v4(),
name: "Corporate",
participants: [],
disableEditing: true,
};
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
await setDoc(doc(db, "groups", defaultCorporateGroup.id), defaultCorporateGroup);
}
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
await setDoc(doc(db, "groups", defaultCorporateGroup.id), defaultCorporateGroup);
}
if(typeof groupName === 'string' && groupName.trim().length > 0){
const q = query(collection(db, "groups"), where("admin", "==", maker.id), where("name", "==", groupName.trim()), limit(1))
const snapshot = await getDocs(q)
if(snapshot.empty){
const values = {
id: v4(),
admin: maker.id,
name: groupName.trim(),
participants: [userId],
disableEditing: false,
}
await setDoc(doc(db, "groups", values.id) , values)
if (typeof groupName === "string" && groupName.trim().length > 0) {
const q = query(collection(db, "groups"), where("admin", "==", maker.id), where("name", "==", groupName.trim()), limit(1));
const snapshot = await getDocs(q);
}else{
if (snapshot.empty) {
const values = {
id: v4(),
admin: maker.id,
name: groupName.trim(),
participants: [userId],
disableEditing: false,
};
const doc = snapshot.docs[0]
const participants : string[] = doc.get('participants');
await setDoc(doc(db, "groups", values.id), values);
} else {
const doc = snapshot.docs[0];
const participants: string[] = doc.get("participants");
if(!participants.includes(userId)){
updateDoc(doc.ref, {
participants: [...participants, userId]
})
}
}
}
})
.catch((error) => {
console.log(error);
return res.status(401).json({error});
});
return res.status(200).json({ ok: true });
if (!participants.includes(userId)) {
updateDoc(doc.ref, {
participants: [...participants, userId],
});
}
}
}
console.log(`Returning - ${email}`);
return res.status(200).json({ok: true});
})
.catch((error) => {
console.log(`Failing - ${email}`);
console.log(error);
return res.status(401).json({error});
});
}

View File

@@ -1,30 +1,46 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import { getFirestore, doc, setDoc } from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, doc, setDoc, getDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {getPermissionDoc} from "@/utils/permissions.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "PATCH") return patch(req, res);
if (req.method === "PATCH") return patch(req, res);
if (req.method === "GET") return get(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id} = req.query as {id: string};
const permissionDoc = await getPermissionDoc(id);
return res.status(200).json({allowed: permissionDoc.users.includes(req.session.user.id)});
}
async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string };
const { users } = req.body;
try {
await setDoc(doc(db, "permissions", id), { users }, { merge: true });
return res.status(200).json({ ok: true });
} catch (err) {
console.error(err);
return res.status(500).json({ ok: false });
}
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id} = req.query as {id: string};
const {users} = req.body;
try {
await setDoc(doc(db, "permissions", id), {users}, {merge: true});
return res.status(200).json({ok: true});
} catch (err) {
console.error(err);
return res.status(500).json({ok: false});
}
}

View File

@@ -0,0 +1,434 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { app, storage } from "@/firebase";
import {
getFirestore,
doc,
getDoc,
updateDoc,
getDocs,
query,
collection,
where,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import ReactPDF from "@react-pdf/renderer";
import TestReport from "@/exams/pdf/test.report";
import LevelTestReport from "@/exams/pdf/level.test.report";
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
import {
DemographicInformation,
Stat,
StudentUser,
User,
} from "@/interfaces/user";
import { Module } from "@/interfaces";
import { ModuleScore } from "@/interfaces/module.scores";
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam";
import { calculateBandScore } from "@/utils/score";
import axios from "axios";
import { moduleLabels } from "@/utils/moduleUtils";
import {
generateQRCode,
getRadialProgressPNG,
streamToBuffer,
} from "@/utils/pdf";
import moment from "moment-timezone";
import { getCorporateNameForStudent } from "@/utils/groups.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
if (req.method === "POST") return post(req, res);
}
const getExamSummary = (score: number) => {
if (score > 0.8) {
return "Scoring between 81% and 100% on the English exam demonstrates an outstanding level of proficiency in writing, speaking, listening, and reading. Mastery of key concepts is evident across all language domains, showcasing not only a high level of skill but also a dedication to excellence. Continuing to challenge oneself with advanced material in writing, speaking, listening, and reading will further refine the already impressive command of the English language.";
}
if (score > 0.6) {
return "Scoring between 61% and 80% on the English exam, encompassing writing, speaking, listening, and reading, reflects a commendable level of proficiency in each domain. There's evidence of a solid grasp of key concepts, and effective application of skills. Room for refinement and deeper exploration in writing, speaking, listening, and reading remains, presenting an opportunity for further mastery.";
}
if (score > 0.4) {
return "Scoring between 41% and 60% on the English exam across writing, speaking, listening, and reading demonstrates a moderate level of understanding in each domain. While there's a commendable grasp of key concepts, refining fundamental skills in writing, speaking, listening, and reading can lead to notable improvement. Consistent effort and targeted focus on weaker areas are recommended.";
}
if (score > 0.2) {
return "Scoring between 21% and 40% on the English exam, spanning writing, speaking, listening, and reading, indicates some understanding of key concepts in each domain. However, there's room for improvement in fundamental skills. Strengthening writing, speaking, listening, and reading abilities through consistent effort and focused study will contribute to overall proficiency.";
}
return "This student's performance on the English exam, encompassing writing, speaking, listening, and reading, reflects a significant need for improvement, scoring between 0% and 20%. There's a notable gap in understanding key concepts across all language domains. Strengthening fundamental skills in writing, speaking, listening, and reading is crucial. Developing a consistent study routine and seeking additional support in each area can contribute to substantial progress.";
};
const getLevelSummary = (score: number) => {
if (score > 0.8) {
return "Scoring between 81% and 100% on the English exam showcases an outstanding level of understanding and proficiency. Your performance reflects a mastery of key concepts, including grammar, vocabulary, and comprehension. You exhibit a high level of skill in applying these elements effectively. Your dedication to excellence is evident, and your consistent, stellar performance is commendable. Continue to challenge yourself with advanced material to further refine your already impressive command of the English language. Your commitment to excellence positions you as a standout student in English studies, and your achievements are a testament to your hard work and capability.";
}
if (score > 0.6) {
return "Scoring between 61% and 80% on the English exam reflects a commendable level of understanding and proficiency. You have demonstrated a solid grasp of key concepts, including grammar, vocabulary, and comprehension. There's evidence of effective application of skills, but room for refinement and deeper exploration remains. Consistent effort in honing nuanced aspects of language will contribute to even greater mastery. Continue engaging with challenging material and seeking opportunities for advanced comprehension. With sustained dedication, you have the potential to elevate your performance to an exceptional level and further excel in your English studies.";
}
if (score > 0.4) {
return "Scoring between 41% and 60% on the English exam reflects a moderate level of understanding. You demonstrate a grasp of some key concepts, but there's room for refinement in areas like grammar, vocabulary, and comprehension. Consistent effort and a strategic focus on weaker areas can lead to notable improvement. Engaging with supplementary resources and seeking feedback will further enhance your skills. With continued dedication, there's a solid foundation to build upon, and achieving a higher level of proficiency is within reach. Keep up the good work and aim for sustained progress in your English studies.";
}
if (score > 0.2) {
return "Scoring between 21% and 40% on the English exam shows some understanding of key concepts, but there's still ample room for improvement. Strengthening foundational skills, such as grammar, vocabulary, and comprehension, is essential. Consistent effort and focused study can help bridge gaps in knowledge and elevate your performance. Consider seeking additional guidance or resources to refine your understanding of the material. With commitment and targeted improvements, you have the potential to make significant strides in your English proficiency.";
}
return "Your performance on the English exam falls within the 0% to 20% range, indicating a need for improvement. There's room to enhance your grasp of fundamental concepts like grammar, vocabulary, and comprehension. Establishing a consistent study routine and seeking extra support can be beneficial. With dedication and targeted efforts, you have the potential to significantly boost your performance in upcoming assessments.";
};
const getPerformanceSummary = (module: Module, score: number) => {
if (module === "level") return getLevelSummary(score);
return getExamSummary(score);
};
interface SkillsFeedbackRequest {
code: Module;
name: string;
grade: number;
}
interface SkillsFeedbackResponse extends SkillsFeedbackRequest {
evaluation: string;
suggestions: string;
bullet_points?: string[];
}
const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => {
const backendRequest = await axios.post(
`${process.env.BACKEND_URL}/grading_summary`,
{ sections },
{
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
}
);
return backendRequest.data?.sections;
};
// perform the request with several retries if needed
const handleSkillsFeedbackRequest = async (
sections: SkillsFeedbackRequest[]
): Promise<SkillsFeedbackResponse[] | null> => {
let i = 0;
try {
const data = await getSkillsFeedback(sections);
return data;
} catch (err) {
if (i < 3) {
i++;
return handleSkillsFeedbackRequest(sections);
}
return null;
}
};
async function getDefaultPDFStream(
stats: Stat[],
user: User,
qrcodeUrl: string
) {
const [stat] = stats;
// generate the QR code for the report
const qrcode = await generateQRCode(qrcodeUrl);
if (!qrcode) {
throw new Error("Failed to generate QR code");
}
// stats may contain multiple exams of the same type so we need to aggregate them
const results = stats
.reduce((accm: ModuleScore[], stat: Stat) => {
const { module, score } = stat;
const fixedModuleStr = module[0].toUpperCase() + module.substring(1);
if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) {
return accm.map((e: ModuleScore) => {
if (e.module === fixedModuleStr) {
return {
...e,
score: e.score + score.correct,
total: e.total + score.total,
};
}
return e;
});
}
const value = {
module: fixedModuleStr,
score: score.correct,
total: score.total,
code: module,
} as ModuleScore;
return [...accm, value];
}, [])
.map((moduleScore: ModuleScore) => {
const { score, total } = moduleScore;
// with all the scores aggreated we can calculate the band score for each module
const bandScore = calculateBandScore(
score,
total,
moduleScore.code as Module,
user.focus
);
return {
...moduleScore,
// generate the closest radial progress png for the score
png: getRadialProgressPNG("azul", score, total),
bandScore,
};
});
// get the skills feedback from the backend based on the module grade
const skillsFeedback = (await handleSkillsFeedbackRequest(
results.map(({ code, bandScore }) => ({
code,
name: moduleLabels[code],
grade: bandScore,
}))
)) as SkillsFeedbackResponse[];
if (!skillsFeedback) {
throw new Error("Failed to get skills feedback");
}
// assign the feedback to the results
const finalResults = results.map((result) => {
const feedback = skillsFeedback.find(
(f: SkillsFeedbackResponse) => f.code === result.code
);
if (feedback) {
return {
...result,
evaluation: feedback?.evaluation,
suggestions: feedback?.suggestions,
bullet_points: feedback?.bullet_points,
};
}
return result;
});
// calculate the overall score out of all the aggregated results
const overallScore = results.reduce((accm, { score }) => accm + score, 0);
const overallTotal = results.reduce((accm, { total }) => accm + total, 0);
const overallResult = overallScore / overallTotal;
const overallPNG = getRadialProgressPNG(
"laranja",
overallScore,
overallTotal
);
// generate the overall detail report
const overallDetail = {
module: "Overall",
score: overallScore,
total: overallTotal,
png: overallPNG,
} as ModuleScore;
const testDetails = [overallDetail, ...finalResults];
// generate the performance summary based on the overall result
const performanceSummary = getPerformanceSummary(stat.module, overallResult);
const title = "ENGLISH SKILLS TEST RESULT REPORT";
const details = <SkillExamDetails testDetails={testDetails} />;
const demographicInformation =
user.demographicInformation as DemographicInformation;
return ReactPDF.renderToStream(
<TestReport
title={title}
date={moment(stat.date)
.tz(user.demographicInformation?.timezone || "UTC")
.format("ll HH:mm:ss")}
name={user.name}
email={user.email}
id={user.id}
gender={demographicInformation?.gender}
summary={performanceSummary}
testDetails={testDetails}
renderDetails={details}
logo={"public/logo_title.png"}
qrcode={qrcode}
summaryPNG={overallPNG}
summaryScore={`${Math.floor(overallResult * 100)}%`}
passportId={demographicInformation?.passport_id || ""}
/>
);
}
async function getPdfUrl(pdfStream: any, docsSnap: any) {
// generate the file ref for storage
const fileName = `${Date.now().toString()}.pdf`;
const refName = `exam_report/${fileName}`;
const fileRef = ref(storage, refName);
// upload the pdf to storage
const pdfBuffer = await streamToBuffer(pdfStream);
const snapshot = await uploadBytes(fileRef, pdfBuffer, {
contentType: "application/pdf",
});
// update the stats entries with the pdf url to prevent duplication
docsSnap.docs.forEach(async (doc: any) => {
await updateDoc(doc.ref, {
pdf: {
path: refName,
version: process.env.PDF_VERSION,
},
});
});
return getDownloadURL(fileRef);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to export
if (req.session.user) {
const { id } = req.query as { id: string };
// fetch stats entries for this particular user with the requested exam session
const docsSnap = await getDocs(
query(collection(db, "stats"), where("session", "==", id))
);
if (docsSnap.empty) {
res.status(400).end();
return;
}
const stats = docsSnap.docs.map((d) => d.data()) as Stat[];
// verify if the stats already have a pdf generated
const hasPDF = stats.find(
(s) => s.pdf?.path && s.pdf?.version === process.env.PDF_VERSION
);
// find the user that generated the stats
const statIndex = stats.findIndex((s) => s.user);
if (statIndex === -1) {
res.status(401).json({ ok: false });
return;
}
const userId = stats[statIndex].user;
if (hasPDF) {
// if it does, return the pdf url
const fileRef = ref(storage, hasPDF.pdf!.path);
const url = await getDownloadURL(fileRef);
res.status(200).end(url);
return;
}
try {
// generate the pdf report
const docUser = await getDoc(doc(db, "users", userId));
if (docUser.exists()) {
// we'll need the user in order to get the user data (name, email, focus, etc);
const [stat] = stats;
if (stat.module === "level") {
const user = docUser.data() as StudentUser;
const uniqueExercises = stats.map((s) => ({
name: "Gramar & Vocabulary",
result: `${s.score.correct}/${s.score.total}`,
}));
const dates = stats.map((s) => moment(s.date));
const timeSpent = `${
stats.reduce((accm, s: Stat) => accm + (s.timeSpent || 0), 0) / 60
} minutes`;
const score = stats.reduce((accm, s) => accm + s.score.correct, 0);
const corporateName = await getCorporateNameForStudent(userId);
const pdfStream = await ReactPDF.renderToStream(
<LevelTestReport
date={moment.max(dates).format("DD/MM/YYYY")}
name={user.name}
email={user.email}
id={stat.exam}
gender={user.demographicInformation?.gender || ""}
passportId={user.demographicInformation?.passport_id || ""}
corporateName={corporateName}
downloadDate={moment().format("DD/MM/YYYY")}
userId={userId}
uniqueExercises={uniqueExercises}
timeSpent={timeSpent}
score={score.toString()}
/>
);
const url = await getPdfUrl(pdfStream, docsSnap);
res.status(200).end(url);
return;
}
const user = docUser.data() as User;
try {
const pdfStream = await getDefaultPDFStream(
stats,
user,
`${req.headers.origin || ""}${req.url}`
);
const url = await getPdfUrl(pdfStream, docsSnap);
res.status(200).end(url);
return;
} catch (err) {
console.error(err);
res.status(500).json({ ok: false });
return;
}
}
res.status(401).json({ ok: false });
return;
} catch (err) {
console.error(err);
res.status(500).json({ ok: false });
return;
}
}
res.status(401).json({ ok: false });
return;
}
async function get(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query as { id: string };
const docsSnap = await getDocs(
query(collection(db, "stats"), where("session", "==", id))
);
if (docsSnap.empty) {
res.status(404).end();
return;
}
const stats = docsSnap.docs.map((d) => d.data());
const hasPDF = stats.find((s) => s.pdf?.path);
if (hasPDF) {
const fileRef = ref(storage, hasPDF.pdf.path);
const url = await getDownloadURL(fileRef);
return res.redirect(url);
}
res.status(500).end();
}

View File

@@ -1,353 +0,0 @@
import type {NextApiRequest, NextApiResponse} from "next";
import {app, storage} from "@/firebase";
import {getFirestore, doc, getDoc, updateDoc, getDocs, query, collection, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import ReactPDF from "@react-pdf/renderer";
import TestReport from "@/exams/pdf/test.report";
import {ref, uploadBytes, getDownloadURL} from "firebase/storage";
import {DemographicInformation, User} from "@/interfaces/user";
import {Module} from "@/interfaces";
import {ModuleScore} from "@/interfaces/module.scores";
import {SkillExamDetails} from "@/exams/pdf/details/skill.exam";
import {LevelExamDetails} from "@/exams/pdf/details/level.exam";
import {calculateBandScore} from "@/utils/score";
import axios from "axios";
import {moduleLabels} from "@/utils/moduleUtils";
import {generateQRCode, getRadialProgressPNG, streamToBuffer} from "@/utils/pdf";
import moment from "moment-timezone";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
if (req.method === "POST") return post(req, res);
}
const getExamSummary = (score: number) => {
if (score > 0.8) {
return "Scoring between 81% and 100% on the English exam demonstrates an outstanding level of proficiency in writing, speaking, listening, and reading. Mastery of key concepts is evident across all language domains, showcasing not only a high level of skill but also a dedication to excellence. Continuing to challenge oneself with advanced material in writing, speaking, listening, and reading will further refine the already impressive command of the English language.";
}
if (score > 0.6) {
return "Scoring between 61% and 80% on the English exam, encompassing writing, speaking, listening, and reading, reflects a commendable level of proficiency in each domain. There's evidence of a solid grasp of key concepts, and effective application of skills. Room for refinement and deeper exploration in writing, speaking, listening, and reading remains, presenting an opportunity for further mastery.";
}
if (score > 0.4) {
return "Scoring between 41% and 60% on the English exam across writing, speaking, listening, and reading demonstrates a moderate level of understanding in each domain. While there's a commendable grasp of key concepts, refining fundamental skills in writing, speaking, listening, and reading can lead to notable improvement. Consistent effort and targeted focus on weaker areas are recommended.";
}
if (score > 0.2) {
return "Scoring between 21% and 40% on the English exam, spanning writing, speaking, listening, and reading, indicates some understanding of key concepts in each domain. However, there's room for improvement in fundamental skills. Strengthening writing, speaking, listening, and reading abilities through consistent effort and focused study will contribute to overall proficiency.";
}
return "This student's performance on the English exam, encompassing writing, speaking, listening, and reading, reflects a significant need for improvement, scoring between 0% and 20%. There's a notable gap in understanding key concepts across all language domains. Strengthening fundamental skills in writing, speaking, listening, and reading is crucial. Developing a consistent study routine and seeking additional support in each area can contribute to substantial progress.";
};
const getLevelSummary = (score: number) => {
if (score > 0.8) {
return "Scoring between 81% and 100% on the English exam showcases an outstanding level of understanding and proficiency. Your performance reflects a mastery of key concepts, including grammar, vocabulary, and comprehension. You exhibit a high level of skill in applying these elements effectively. Your dedication to excellence is evident, and your consistent, stellar performance is commendable. Continue to challenge yourself with advanced material to further refine your already impressive command of the English language. Your commitment to excellence positions you as a standout student in English studies, and your achievements are a testament to your hard work and capability.";
}
if (score > 0.6) {
return "Scoring between 61% and 80% on the English exam reflects a commendable level of understanding and proficiency. You have demonstrated a solid grasp of key concepts, including grammar, vocabulary, and comprehension. There's evidence of effective application of skills, but room for refinement and deeper exploration remains. Consistent effort in honing nuanced aspects of language will contribute to even greater mastery. Continue engaging with challenging material and seeking opportunities for advanced comprehension. With sustained dedication, you have the potential to elevate your performance to an exceptional level and further excel in your English studies.";
}
if (score > 0.4) {
return "Scoring between 41% and 60% on the English exam reflects a moderate level of understanding. You demonstrate a grasp of some key concepts, but there's room for refinement in areas like grammar, vocabulary, and comprehension. Consistent effort and a strategic focus on weaker areas can lead to notable improvement. Engaging with supplementary resources and seeking feedback will further enhance your skills. With continued dedication, there's a solid foundation to build upon, and achieving a higher level of proficiency is within reach. Keep up the good work and aim for sustained progress in your English studies.";
}
if (score > 0.2) {
return "Scoring between 21% and 40% on the English exam shows some understanding of key concepts, but there's still ample room for improvement. Strengthening foundational skills, such as grammar, vocabulary, and comprehension, is essential. Consistent effort and focused study can help bridge gaps in knowledge and elevate your performance. Consider seeking additional guidance or resources to refine your understanding of the material. With commitment and targeted improvements, you have the potential to make significant strides in your English proficiency.";
}
return "Your performance on the English exam falls within the 0% to 20% range, indicating a need for improvement. There's room to enhance your grasp of fundamental concepts like grammar, vocabulary, and comprehension. Establishing a consistent study routine and seeking extra support can be beneficial. With dedication and targeted efforts, you have the potential to significantly boost your performance in upcoming assessments.";
};
const getPerformanceSummary = (module: Module, score: number) => {
if (module === "level") return getLevelSummary(score);
return getExamSummary(score);
};
interface SkillsFeedbackRequest {
code: Module;
name: string;
grade: number;
}
interface SkillsFeedbackResponse extends SkillsFeedbackRequest {
evaluation: string;
suggestions: string;
bullet_points?: string[];
}
const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => {
const backendRequest = await axios.post(
`${process.env.BACKEND_URL}/grading_summary`,
{sections},
{
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
},
);
return backendRequest.data?.sections;
};
// perform the request with several retries if needed
const handleSkillsFeedbackRequest = async (sections: SkillsFeedbackRequest[]): Promise<SkillsFeedbackResponse[] | null> => {
let i = 0;
try {
const data = await getSkillsFeedback(sections);
return data;
} catch (err) {
if (i < 3) {
i++;
return handleSkillsFeedbackRequest(sections);
}
return null;
}
};
async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to export
if (req.session.user) {
const {id} = req.query as {id: string};
// fetch stats entries for this particular user with the requested exam session
const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id)));
if (docsSnap.empty) {
res.status(400).end();
return;
}
const stats = docsSnap.docs.map((d) => d.data());
// verify if the stats already have a pdf generated
const hasPDF = stats.find((s) => s.pdf?.path && s.pdf?.version === process.env.PDF_VERSION);
// find the user that generated the stats
const statIndex = stats.findIndex((s) => s.user);
if(statIndex === -1) {
res.status(401).json({ok: false});
return;
}
const userId = stats[statIndex].user;
if (hasPDF) {
// if it does, return the pdf url
const fileRef = ref(storage, hasPDF.pdf.path);
const url = await getDownloadURL(fileRef);
res.status(200).end(url);
return;
}
try {
// generate the pdf report
const docUser = await getDoc(doc(db, "users", userId));
if (docUser.exists()) {
// we'll need the user in order to get the user data (name, email, focus, etc);
const user = docUser.data() as User;
// generate the QR code for the report
const qrcode = await generateQRCode((req.headers.origin || "") + req.url);
if (!qrcode) {
res.status(500).json({ok: false});
return;
}
// stats may contain multiple exams of the same type so we need to aggregate them
const results = (
stats.reduce((accm: ModuleScore[], {module, score}) => {
const fixedModuleStr = module[0].toUpperCase() + module.substring(1);
if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) {
return accm.map((e: ModuleScore) => {
if (e.module === fixedModuleStr) {
return {
...e,
score: e.score + score.correct,
total: e.total + score.total,
};
}
return e;
});
}
return [
...accm,
{
module: fixedModuleStr,
score: score.correct,
total: score.total,
code: module,
},
];
}, []) as ModuleScore[]
).map((moduleScore) => {
const {score, total} = moduleScore;
// with all the scores aggreated we can calculate the band score for each module
const bandScore = calculateBandScore(score, total, moduleScore.code as Module, user.focus);
return {
...moduleScore,
// generate the closest radial progress png for the score
png: getRadialProgressPNG("azul", score, total),
bandScore,
};
});
// get the skills feedback from the backend based on the module grade
const skillsFeedback = (await handleSkillsFeedbackRequest(
results.map(({code, bandScore}) => ({
code,
name: moduleLabels[code],
grade: bandScore,
})),
)) as SkillsFeedbackResponse[];
if (!skillsFeedback) {
res.status(500).json({ok: false});
return;
}
// assign the feedback to the results
const finalResults = results.map((result) => {
const feedback = skillsFeedback.find((f: SkillsFeedbackResponse) => f.code === result.code);
if (feedback) {
return {
...result,
evaluation: feedback?.evaluation,
suggestions: feedback?.suggestions,
bullet_points: feedback?.bullet_points,
};
}
return result;
});
// calculate the overall score out of all the aggregated results
const overallScore = results.reduce((accm, {score}) => accm + score, 0);
const overallTotal = results.reduce((accm, {total}) => accm + total, 0);
const overallResult = overallScore / overallTotal;
const overallPNG = getRadialProgressPNG("laranja", overallScore, overallTotal);
// generate the overall detail report
const overallDetail = {
module: "Overall",
score: overallScore,
total: overallTotal,
png: overallPNG,
} as ModuleScore;
const testDetails = [overallDetail, ...finalResults];
const [stat] = stats;
// generate the performance summary based on the overall result
const performanceSummary = getPerformanceSummary(stat.module, overallResult);
// level exams have a different report structure than the skill exams
const getCustomData = () => {
if (stat.module === "level") {
return {
title: "ENGLISH LEVEL TEST RESULT REPORT ",
details: <LevelExamDetails detail={overallDetail} title="Level as per CEFR Levels" />,
};
}
return {
title: "ENGLISH SKILLS TEST RESULT REPORT",
details: <SkillExamDetails testDetails={testDetails} />,
};
};
const {title, details} = getCustomData();
const demographicInformation = user.demographicInformation as DemographicInformation;
const pdfStream = await ReactPDF.renderToStream(
<TestReport
title={title}
date={moment(stat.date)
.tz(user.demographicInformation?.timezone || "UTC")
.format("ll HH:mm:ss")}
name={user.name}
email={user.email}
id={userId}
gender={demographicInformation?.gender}
summary={performanceSummary}
testDetails={testDetails}
renderDetails={details}
logo={"public/logo_title.png"}
qrcode={qrcode}
summaryPNG={overallPNG}
summaryScore={`${Math.floor(overallResult * 100)}%`}
passportId={demographicInformation?.passport_id || ""}
/>,
);
// generate the file ref for storage
const fileName = `${Date.now().toString()}.pdf`;
const refName = `exam_report/${fileName}`;
const fileRef = ref(storage, refName);
// upload the pdf to storage
const pdfBuffer = await streamToBuffer(pdfStream);
const snapshot = await uploadBytes(fileRef, pdfBuffer, {
contentType: "application/pdf",
});
// update the stats entries with the pdf url to prevent duplication
docsSnap.docs.forEach(async (doc) => {
await updateDoc(doc.ref, {
pdf: {
path: refName,
version: process.env.PDF_VERSION,
},
});
});
const url = await getDownloadURL(fileRef);
res.status(200).end(url);
return;
}
res.status(401).json({ok: false});
return;
} catch (err) {
res.status(500).json({ok: false});
return;
}
}
res.status(401).json({ok: false});
return;
}
async function get(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query as {id: string};
const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id)));
if (docsSnap.empty) {
res.status(404).end();
return;
}
const stats = docsSnap.docs.map((d) => d.data());
const hasPDF = stats.find((s) => s.pdf?.path);
if (hasPDF) {
const fileRef = ref(storage, hasPDF.pdf.path);
const url = await getDownloadURL(fileRef);
return res.redirect(url);
}
res.status(500).end();
}

View File

@@ -53,7 +53,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
// based on the admin of each group, verify if it exists and it's of type corporate
const groupsAdmins = [...new Set(groups.map((g) => g.admin).filter((id) => id))];
const adminsSnapshot = await getDocs(query(collection(db, "users"), where("id", "in", groupsAdmins), where("type", "==", "corporate")));
const adminsSnapshot =
groupsAdmins.length > 0
? await getDocs(query(collection(db, "users"), where("id", "in", groupsAdmins), where("type", "==", "corporate")))
: {docs: []};
const admins = adminsSnapshot.docs.map((doc) => doc.data());
const docsWithAdmins = docs.map((d) => {

View File

@@ -1,22 +1,12 @@
import { PERMISSIONS } from "@/constants/userPermissions";
import { app, adminApp } from "@/firebase";
import { Group, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import {
collection,
deleteDoc,
doc,
getDoc,
getDocs,
getFirestore,
query,
setDoc,
where,
} from "firebase/firestore";
import { getAuth } from "firebase-admin/auth";
import { withIronSessionApiRoute } from "iron-session/next";
import { NextApiRequest, NextApiResponse } from "next";
import { getPermissions, getPermissionDocs } from "@/utils/permissions.be";
import {PERMISSIONS} from "@/constants/userPermissions";
import {app, adminApp} from "@/firebase";
import {Group, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {collection, deleteDoc, doc, getDoc, getDocs, getFirestore, query, setDoc, where} from "firebase/firestore";
import {getAuth} from "firebase-admin/auth";
import {withIronSessionApiRoute} from "iron-session/next";
import {NextApiRequest, NextApiResponse} from "next";
import {getPermissions, getPermissionDocs} from "@/utils/permissions.be";
const db = getFirestore(app);
const auth = getAuth(adminApp);
@@ -24,132 +14,110 @@ const auth = getAuth(adminApp);
export default withIronSessionApiRoute(user, sessionOptions);
async function user(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
if (req.method === "DELETE") return del(req, res);
if (req.method === "GET") return get(req, res);
if (req.method === "DELETE") return del(req, res);
res.status(404).json(undefined);
res.status(404).json(undefined);
}
async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const { id } = req.query as { id: string };
const {id} = req.query as {id: string};
const docUser = await getDoc(doc(db, "users", req.session.user.id));
if (!docUser.exists()) {
res.status(401).json({ ok: false });
return;
}
const docUser = await getDoc(doc(db, "users", req.session.user.id));
if (!docUser.exists()) {
res.status(401).json({ok: false});
return;
}
const user = docUser.data() as User;
const user = docUser.data() as User;
const docTargetUser = await getDoc(doc(db, "users", id));
if (!docTargetUser.exists()) {
res.status(404).json({ ok: false });
return;
}
const docTargetUser = await getDoc(doc(db, "users", id));
if (!docTargetUser.exists()) {
res.status(404).json({ok: false});
return;
}
const targetUser = { ...docTargetUser.data(), id: docTargetUser.id } as User;
const targetUser = {...docTargetUser.data(), id: docTargetUser.id} as User;
if (
user.type === "corporate" &&
(targetUser.type === "student" || targetUser.type === "teacher")
) {
res.json({ ok: true });
if (user.type === "corporate" && (targetUser.type === "student" || targetUser.type === "teacher")) {
res.json({ok: true});
const userParticipantGroup = await getDocs(
query(
collection(db, "groups"),
where("participants", "array-contains", id)
)
);
await Promise.all([
...userParticipantGroup.docs
.filter((x) => (x.data() as Group).admin === user.id)
.map(
async (x) =>
await setDoc(
x.ref,
{
participants: x
.data()
.participants.filter((y: string) => y !== id),
},
{ merge: true }
)
),
]);
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id)));
await Promise.all([
...userParticipantGroup.docs
.filter((x) => (x.data() as Group).admin === user.id)
.map(
async (x) =>
await setDoc(
x.ref,
{
participants: x.data().participants.filter((y: string) => y !== id),
},
{merge: true},
),
),
]);
return;
}
return;
}
const permission = PERMISSIONS.deleteUser[targetUser.type];
if (!permission.list.includes(user.type)) {
res.status(403).json({ ok: false });
return;
}
const permission = PERMISSIONS.deleteUser[targetUser.type];
if (!permission.list.includes(user.type)) {
res.status(403).json({ok: false});
return;
}
res.json({ ok: true });
res.json({ok: true});
await auth.deleteUser(id);
await deleteDoc(doc(db, "users", id));
const userCodeDocs = await getDocs(
query(collection(db, "codes"), where("userId", "==", id))
);
const userParticipantGroup = await getDocs(
query(collection(db, "groups"), where("participants", "array-contains", id))
);
const userGroupAdminDocs = await getDocs(
query(collection(db, "groups"), where("admin", "==", id))
);
const userStatsDocs = await getDocs(
query(collection(db, "stats"), where("user", "==", id))
);
await auth.deleteUser(id);
await deleteDoc(doc(db, "users", id));
const userCodeDocs = await getDocs(query(collection(db, "codes"), where("userId", "==", id)));
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id)));
const userGroupAdminDocs = await getDocs(query(collection(db, "groups"), where("admin", "==", id)));
const userStatsDocs = await getDocs(query(collection(db, "stats"), where("user", "==", id)));
await Promise.all([
...userCodeDocs.docs.map(async (x) => await deleteDoc(x.ref)),
...userGroupAdminDocs.docs.map(async (x) => await deleteDoc(x.ref)),
...userStatsDocs.docs.map(async (x) => await deleteDoc(x.ref)),
...userParticipantGroup.docs.map(
async (x) =>
await setDoc(
x.ref,
{
participants: x.data().participants.filter((y: string) => y !== id),
},
{ merge: true }
)
),
]);
await Promise.all([
...userCodeDocs.docs.map(async (x) => await deleteDoc(x.ref)),
...userGroupAdminDocs.docs.map(async (x) => await deleteDoc(x.ref)),
...userStatsDocs.docs.map(async (x) => await deleteDoc(x.ref)),
...userParticipantGroup.docs.map(
async (x) =>
await setDoc(
x.ref,
{
participants: x.data().participants.filter((y: string) => y !== id),
},
{merge: true},
),
),
]);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) {
const docUser = await getDoc(doc(db, "users", req.session.user.id));
if (!docUser.exists()) {
res.status(401).json(undefined);
return;
}
if (req.session.user) {
const docUser = await getDoc(doc(db, "users", req.session.user.id));
if (!docUser.exists()) {
res.status(401).json(undefined);
return;
}
const user = docUser.data() as User;
const permissionDocs = await getPermissionDocs();
const user = docUser.data() as User;
await setDoc(docUser.ref, {lastLogin: new Date().toISOString()}, {merge: true});
const userWithPermissions = {
...user,
permissions: getPermissions(req.session.user.id, permissionDocs),
};
req.session.user = {
...userWithPermissions,
id: req.session.user.id,
};
await req.session.save();
req.session.user = {
...user,
id: req.session.user.id,
lastLogin: new Date(),
};
await req.session.save();
res.json({ ...userWithPermissions, id: req.session.user.id });
} else {
res.status(401).json(undefined);
}
res.json({...user, id: req.session.user.id});
} else {
res.status(401).json(undefined);
}
}

View File

@@ -1,19 +1,19 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import useUser from "@/hooks/useUser";
import { toast, ToastContainer } from "react-toastify";
import {toast, ToastContainer} from "react-toastify";
import Layout from "@/components/High/Layout";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { useState } from "react";
import { Module } from "@/interfaces";
import { RadioGroup, Tab } from "@headlessui/react";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {useState} from "react";
import {Module} from "@/interfaces";
import {RadioGroup, Tab} from "@headlessui/react";
import clsx from "clsx";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { capitalize } from "lodash";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {capitalize} from "lodash";
import Button from "@/components/Low/Button";
import { Exercise, ReadingPart } from "@/interfaces/exam";
import {Exercise, ReadingPart} from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import axios from "axios";
import ReadingGeneration from "./(generation)/ReadingGeneration";
@@ -21,109 +21,114 @@ import ListeningGeneration from "./(generation)/ListeningGeneration";
import WritingGeneration from "./(generation)/WritingGeneration";
import LevelGeneration from "./(generation)/LevelGeneration";
import SpeakingGeneration from "./(generation)/SpeakingGeneration";
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
import {checkAccess} from "@/utils/permissions";
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user;
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
if (!user || !user.isVerified) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (!user || !user.isVerified) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (
shouldRedirectHome(user) ||
checkAccess(user, getTypesOfUser(["developer"]))
) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "mastercorporate", "developer", "corporate"])) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: { user: req.session.user },
};
return {
props: {user: req.session.user},
};
}, sessionOptions);
export default function Generation() {
const [module, setModule] = useState<Module>("reading");
const [module, setModule] = useState<Module>("reading");
const { user } = useUser({ redirectTo: "/login" });
const {user} = useUser({redirectTo: "/login"});
return (
<>
<Head>
<title>Exam Generation | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user} className="gap-6">
<h1 className="text-2xl font-semibold">Exam Generation</h1>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">
Module
</label>
<RadioGroup
value={module}
onChange={setModule}
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between"
>
{[...MODULE_ARRAY].map((x) => (
<RadioGroup.Option value={x} key={x}>
{({ checked }) => (
<span
className={clsx(
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
x === "reading" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-reading/70 border-ielts-reading text-white"),
x === "listening" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-listening/70 border-ielts-listening text-white"),
x === "writing" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-writing/70 border-ielts-writing text-white"),
x === "speaking" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
x === "level" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-level/70 border-ielts-level text-white")
)}
>
{capitalize(x)}
</span>
)}
</RadioGroup.Option>
))}
</RadioGroup>
</div>
{module === "reading" && <ReadingGeneration />}
{module === "listening" && <ListeningGeneration />}
{module === "writing" && <WritingGeneration />}
{module === "speaking" && <SpeakingGeneration />}
{module === "level" && <LevelGeneration />}
</Layout>
)}
</>
);
const [title, setTitle] = useState<string>("");
return (
<>
<Head>
<title>Exam Generation | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user} className="gap-6">
<h1 className="text-2xl font-semibold">Exam Generation</h1>
<div className="flex flex-col gap-3">
<Input
type="text"
placeholder="Insert a title here"
name="title"
label="Title"
onChange={setTitle}
roundness="xl"
defaultValue={title}
required
/>
<label className="font-normal text-base text-mti-gray-dim">Module</label>
<RadioGroup
value={module}
onChange={setModule}
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
{[...MODULE_ARRAY].map((x) => (
<RadioGroup.Option value={x} key={x}>
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
x === "reading" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-reading/70 border-ielts-reading text-white"),
x === "listening" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-listening/70 border-ielts-listening text-white"),
x === "writing" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-writing/70 border-ielts-writing text-white"),
x === "speaking" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
x === "level" &&
(!checked
? "bg-white border-mti-gray-platinum"
: "bg-ielts-level/70 border-ielts-level text-white"),
)}>
{capitalize(x)}
</span>
)}
</RadioGroup.Option>
))}
</RadioGroup>
</div>
{module === "reading" && <ReadingGeneration id={title} />}
{module === "listening" && <ListeningGeneration id={title} />}
{module === "writing" && <WritingGeneration id={title} />}
{module === "speaking" && <SpeakingGeneration id={title} />}
{module === "level" && <LevelGeneration id={title} />}
</Layout>
)}
</>
);
}

119
src/pages/groups.tsx Normal file
View File

@@ -0,0 +1,119 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import Navbar from "@/components/Navbar";
import {BsFileEarmarkText, BsPencil, BsStar, BsBook, BsHeadphones, BsPen, BsMegaphone} from "react-icons/bs";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {useEffect, useState} from "react";
import useStats from "@/hooks/useStats";
import {averageScore, groupBySession, totalExams} from "@/utils/stats";
import useUser from "@/hooks/useUser";
import Diagnostic from "@/components/Diagnostic";
import {ToastContainer} from "react-toastify";
import {capitalize} from "lodash";
import {Module} from "@/interfaces";
import ProgressBar from "@/components/Low/ProgressBar";
import Layout from "@/components/High/Layout";
import {calculateAverageLevel} from "@/utils/score";
import axios from "axios";
import DemographicInformationInput from "@/components/DemographicInformationInput";
import moment from "moment";
import Link from "next/link";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import ProfileSummary from "@/components/ProfileSummary";
import StudentDashboard from "@/dashboards/Student";
import AdminDashboard from "@/dashboards/Admin";
import CorporateDashboard from "@/dashboards/Corporate";
import TeacherDashboard from "@/dashboards/Teacher";
import AgentDashboard from "@/dashboards/Agent";
import MasterCorporateDashboard from "@/dashboards/MasterCorporate";
import PaymentDue from "./(status)/PaymentDue";
import {useRouter} from "next/router";
import {PayPalScriptProvider} from "@paypal/react-paypal-js";
import {CorporateUser, MasterCorporateUser, Type, User, userTypes} from "@/interfaces/user";
import Select from "react-select";
import {USER_TYPE_LABELS} from "@/resources/user";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
import {getUserName} from "@/utils/users";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
if (!user || !user.isVerified) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {user: req.session.user},
};
}, sessionOptions);
interface Props {
user: User;
envVariables: {[key: string]: string};
}
export default function Home(props: Props) {
const {user, mutateUser} = useUser({redirectTo: "/login"});
const {groups} = useGroups({});
const {users} = useUsers();
const router = useRouter();
useEffect(() => {
console.log(groups);
}, [groups]);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user}>
<div className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{groups
.filter((x) => x.participants.includes(user.id))
.map((group) => (
<div key={group.id} className="p-4 border rounded-xl flex flex-col gap-2">
<span>
<b>Group: </b>
{group.name}
</span>
<span>
<b>Admin: </b>
{getUserName(users.find((x) => x.id === group.admin))}
</span>
<b>Participants: </b>
<span>{group.participants.map((x) => getUserName(users.find((u) => u.id === x))).join(", ")}</span>
</div>
))}
</div>
</Layout>
)}
</>
);
}

View File

@@ -390,7 +390,7 @@ interface PaypalPaymentWithUserData extends PaypalPayment {
email: string;
}
const paypalFilterRows = [["email"], ["name"]];
const paypalFilterRows = [["email"], ["name"], ["orderId"], ["value"]];
export default function PaymentRecord() {
const [selectedCorporateUser, setSelectedCorporateUser] = useState<User>();
const [selectedAgentUser, setSelectedAgentUser] = useState<User>();
@@ -414,6 +414,14 @@ export default function PaymentRecord() {
const [endDate, setEndDate] = useState<Date | null>(
moment().endOf("day").toDate()
);
const [startDatePaymob, setStartDatePaymob] = useState<Date | null>(
moment("01/01/2023").toDate()
);
const [endDatePaymob, setEndDatePaymob] = useState<Date | null>(
moment().endOf("day").toDate()
);
const [paid, setPaid] = useState<Boolean | null>(IS_PAID_OPTIONS[0].value);
const [commissionTransfer, setCommissionTransfer] = useState<Boolean | null>(
IS_FILE_SUBMITTED_OPTIONS[0].value
@@ -866,11 +874,16 @@ export default function PaymentRecord() {
const updatedPaypalPayments = useMemo(
() =>
paypalPayments.map((p) => {
const user = users.find((x) => x.id === p.userId) as User;
return { ...p, name: user?.name, email: user?.email };
}),
[paypalPayments, users]
paypalPayments
.filter((p) => {
const date = moment(p.createdAt);
return date.isAfter(startDatePaymob) && date.isBefore(endDatePaymob);
})
.map((p) => {
const user = users.find((x) => x.id === p.userId) as User;
return { ...p, name: user?.name, email: user?.email };
}),
[paypalPayments, users, startDatePaymob, endDatePaymob]
);
const paypalColumns = [
@@ -1469,6 +1482,44 @@ export default function PaymentRecord() {
{renderTable(table as Table<Payment>)}
</Tab.Panel>
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide flex flex-col gap-8">
<div
className={clsx(
"grid grid-cols-1 md:grid-cols-2 gap-8 w-full",
user.type !== "corporate" && "lg:grid-cols-3"
)}
>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">
Date
</label>
<ReactDatePicker
dateFormat="dd/MM/yyyy"
className="px-4 py-6 w-full text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
selected={startDatePaymob}
startDate={startDatePaymob}
endDate={endDatePaymob}
selectsRange
showMonthDropdown
filterDate={(date: Date) =>
moment(date).isSameOrBefore(moment(new Date()))
}
onChange={([initialDate, finalDate]: [Date, Date]) => {
setStartDatePaymob(
initialDate ?? moment("01/01/2023").toDate()
);
if (finalDate) {
// basicly selecting a final day works as if I'm selecting the first
// minute of that day. this way it covers the whole day
setEndDatePaymob(
moment(finalDate).endOf("day").toDate()
);
return;
}
setEndDatePaymob(null);
}}
/>
</div>
</div>
{renderSearch()}
{renderTable(paypalTable as Table<PaypalPaymentWithUserData>)}
</Tab.Panel>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,29 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Stat, User } from "@/interfaces/user";
import { useEffect, useRef, useState } from "react";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Stat, User} from "@/interfaces/user";
import {useEffect, useRef, useState} from "react";
import useStats from "@/hooks/useStats";
import { groupByDate } from "@/utils/stats";
import {groupByDate} from "@/utils/stats";
import moment from "moment";
import useUsers from "@/hooks/useUsers";
import useExamStore from "@/stores/examStore";
import { ToastContainer } from "react-toastify";
import { useRouter } from "next/router";
import {ToastContainer} from "react-toastify";
import {useRouter} from "next/router";
import Layout from "@/components/High/Layout";
import clsx from "clsx";
import Select from "@/components/Low/Select";
import useGroups from "@/hooks/useGroups";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import useAssignments from "@/hooks/useAssignments";
import { uuidv4 } from "@firebase/util";
import { usePDFDownload } from "@/hooks/usePDFDownload";
import {uuidv4} from "@firebase/util";
import {usePDFDownload} from "@/hooks/usePDFDownload";
import useRecordStore from "@/stores/recordStore";
import useTrainingContentStore from "@/stores/trainingContentStore";
import StatsGridItem from "@/components/StatGridItem";
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
if (!user || !user.isVerified) {
@@ -46,7 +45,7 @@ export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
}
return {
props: { user: req.session.user },
props: {user: req.session.user},
};
}, sessionOptions);
@@ -55,16 +54,22 @@ const defaultSelectableCorporate = {
label: "All",
};
export default function History({ user }: { user: User }) {
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [state.selectedUser, state.setSelectedUser, state.training, state.setTraining]);
// const [statsUserId, setStatsUserId] = useState<string | undefined>(user.id);
const [groupedStats, setGroupedStats] = useState<{ [key: string]: Stat[] }>();
const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">();
const { assignments } = useAssignments({});
export default function History({user}: {user: User}) {
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [
state.selectedUser,
state.setSelectedUser,
state.training,
state.setTraining,
]);
const { users } = useUsers();
const { stats, isLoading: isStatsLoading } = useStats(statsUserId);
const { groups: allGroups } = useGroups();
// const [statsUserId, setStatsUserId] = useState<string | undefined>(user.id);
const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">();
const {assignments} = useAssignments({});
const {users} = useUsers();
const {stats, isLoading: isStatsLoading} = useStats(user?.type === "student" ? user?.id : statsUserId);
const {groups: allGroups} = useGroups({});
const groups = allGroups.filter((x) => x.admin === user.id);
@@ -104,12 +109,12 @@ export default function History({ user }: { user: User }) {
setFilter((prev) => (prev === value ? undefined : value));
};
const filterStatsByDate = (stats: { [key: string]: Stat[] }) => {
const filterStatsByDate = (stats: {[key: string]: Stat[]}) => {
if (filter && filter !== "assignments") {
const filterDate = moment()
.subtract({ [filter as string]: 1 })
.subtract({[filter as string]: 1})
.format("x");
const filteredStats: { [key: string]: Stat[] } = {};
const filteredStats: {[key: string]: Stat[]} = {};
Object.keys(stats).forEach((timestamp) => {
if (timestamp >= filterDate) filteredStats[timestamp] = stats[timestamp];
@@ -118,7 +123,7 @@ export default function History({ user }: { user: User }) {
}
if (filter && filter === "assignments") {
const filteredStats: { [key: string]: Stat[] } = {};
const filteredStats: {[key: string]: Stat[]} = {};
Object.keys(stats).forEach((timestamp) => {
if (stats[timestamp].map((s) => s.assignment === undefined).includes(false))
@@ -137,13 +142,13 @@ export default function History({ user }: { user: User }) {
useEffect(() => {
const handleRouteChange = (url: string) => {
setTraining(false)
}
router.events.on('routeChangeStart', handleRouteChange)
setTraining(false);
};
router.events.on("routeChangeStart", handleRouteChange);
return () => {
router.events.off('routeChangeStart', handleRouteChange)
}
}, [router.events, setTraining])
router.events.off("routeChangeStart", handleRouteChange);
};
}, [router.events, setTraining]);
const handleTrainingContentSubmission = () => {
if (groupedStats) {
@@ -156,11 +161,10 @@ export default function History({ user }: { user: User }) {
}
return accumulator;
}, {});
setTrainingStats(Object.values(selectedStats).flat())
setTrainingStats(Object.values(selectedStats).flat());
router.push("/training");
}
}
};
const customContent = (timestamp: string) => {
if (!groupedStats) return <></>;
@@ -240,13 +244,13 @@ export default function History({ user }: { user: User }) {
const selectedUser = getSelectedUser();
const selectedUserSelectValue = selectedUser
? {
value: selectedUser.id,
label: `${selectedUser.name} - ${selectedUser.email}`,
}
value: selectedUser.id,
label: `${selectedUser.name} - ${selectedUser.email}`,
}
: {
value: "",
label: "",
};
value: "",
label: "",
};
return (
<>
<Head>
@@ -272,7 +276,7 @@ export default function History({ user }: { user: User }) {
value={selectableCorporates.find((x) => x.value === selectedCorporate)}
onChange={(value) => setSelectedCorporate(value?.value || "")}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -289,7 +293,7 @@ export default function History({ user }: { user: User }) {
value={selectedUserSelectValue}
onChange={(value) => setStatsUserId(value?.value!)}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -313,7 +317,7 @@ export default function History({ user }: { user: User }) {
value={selectedUserSelectValue}
onChange={(value) => setStatsUserId(value?.value!)}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -323,10 +327,12 @@ export default function History({ user }: { user: User }) {
/>
</>
)}
{(training && (
{training && (
<div className="flex flex-row">
<div className="font-semibold text-2xl mr-4">Select up to 10 exercises
{`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}</div>
<div className="font-semibold text-2xl mr-4">
Select up to 10 exercises
{`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}
</div>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed",
@@ -337,7 +343,7 @@ export default function History({ user }: { user: User }) {
Submit
</button>
</div>
))}
)}
</div>
<div className="flex gap-4 w-full justify-center xl:justify-end">
<button

View File

@@ -14,6 +14,8 @@ import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import ExamGenerator from "./(admin)/ExamGenerator";
import BatchCreateUser from "./(admin)/BatchCreateUser";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
@@ -26,7 +28,7 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
};
}
if (shouldRedirectHome(user) || !["developer", "admin", "corporate", "agent", "mastercorporate"].includes(user.type)) {
if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) {
return {
redirect: {
destination: "/",
@@ -42,6 +44,7 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
export default function Admin() {
const {user} = useUser({redirectTo: "/login"});
const {permissions} = usePermissions(user?.id || "");
return (
<>
@@ -57,13 +60,13 @@ export default function Admin() {
<ToastContainer />
{user && (
<Layout user={user} className="gap-6">
<section className="w-full flex -md:flex-col -xl:gap-2 gap-8 justify-between">
<section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8">
<ExamLoader />
<BatchCreateUser user={user} />
{user.type !== "teacher" && (
<BatchCreateUser user={user} />
{checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && (
<>
<CodeGenerator user={user} />
<BatchCodeGenerator user={user} />
<CodeGenerator user={user} />
<BatchCodeGenerator user={user} />
</>
)}
</section>

View File

@@ -71,7 +71,7 @@ export default function Stats() {
const {user} = useUser({redirectTo: "/login"});
const {users} = useUsers();
const {groups} = useGroups(user?.id);
const {groups} = useGroups({admin: user?.id});
const {stats} = useStats(statsUserId, !statsUserId);
useEffect(() => {
@@ -202,7 +202,7 @@ export default function Stats() {
}}
/>
)}
{(["corporate", "teacher", "mastercorporate"].includes(user.type) ) && groups.length > 0 && (
{["corporate", "teacher", "mastercorporate"].includes(user.type) && groups.length > 0 && (
<Select
className="w-full"
options={users

View File

@@ -1,357 +1,371 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import axios from 'axios';
import { Tab } from "@headlessui/react";
import { AiOutlineFileSearch } from "react-icons/ai";
import { MdOutlinePlaylistAddCheckCircle, MdOutlineSelfImprovement } from "react-icons/md";
import { BsChatLeftDots } from "react-icons/bs";
import {useEffect, useState} from "react";
import {useRouter} from "next/router";
import axios from "axios";
import {Tab} from "@headlessui/react";
import {AiOutlineFileSearch} from "react-icons/ai";
import {MdOutlinePlaylistAddCheckCircle, MdOutlineSelfImprovement} from "react-icons/md";
import {BsChatLeftDots} from "react-icons/bs";
import Button from "@/components/Low/Button";
import clsx from "clsx";
import Exercise from "@/training/Exercise";
import TrainingScore from "@/training/TrainingScore";
import { ITrainingContent, ITrainingTip } from "@/training/TrainingInterfaces";
import { Stat, User } from '@/interfaces/user';
import {ITrainingContent, ITrainingTip} from "@/training/TrainingInterfaces";
import {Stat, User} from "@/interfaces/user";
import Head from "next/head";
import Layout from "@/components/High/Layout";
import { ToastContainer } from 'react-toastify';
import { withIronSessionSsr } from "iron-session/next";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { sessionOptions } from "@/lib/session";
import qs from 'qs';
import StatsGridItem from '@/components/StatGridItem';
import {ToastContainer} from "react-toastify";
import {withIronSessionSsr} from "iron-session/next";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {sessionOptions} from "@/lib/session";
import qs from "qs";
import StatsGridItem from "@/components/StatGridItem";
import useExamStore from "@/stores/examStore";
import { usePDFDownload } from "@/hooks/usePDFDownload";
import useAssignments from '@/hooks/useAssignments';
import useUsers from '@/hooks/useUsers';
import {usePDFDownload} from "@/hooks/usePDFDownload";
import useAssignments from "@/hooks/useAssignments";
import useUsers from "@/hooks/useUsers";
import Dropdown from "@/components/Dropdown";
import InfiniteCarousel from '@/components/InfiniteCarousel';
import { LuExternalLink } from "react-icons/lu";
import { uniqBy } from 'lodash';
import { getExamById } from '@/utils/exams';
import { convertToUserSolutions } from '@/utils/stats';
import { sortByModule } from '@/utils/moduleUtils';
import InfiniteCarousel from "@/components/InfiniteCarousel";
import {LuExternalLink} from "react-icons/lu";
import {uniqBy} from "lodash";
import {getExamById} from "@/utils/exams";
import {convertToUserSolutions} from "@/utils/stats";
import {sortByModule} from "@/utils/moduleUtils";
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user;
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
if (!user || !user.isVerified) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (!user || !user.isVerified) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: { user: req.session.user },
};
return {
props: {user: req.session.user},
};
}, sessionOptions);
const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
// Record stuff
const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setInactivity = useExamStore((state) => state.setInactivity);
const setTimeSpent = useExamStore((state) => state.setTimeSpent);
const renderPdfIcon = usePDFDownload("stats");
const TrainingContent: React.FC<{user: User}> = ({user}) => {
// Record stuff
const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setInactivity = useExamStore((state) => state.setInactivity);
const setTimeSpent = useExamStore((state) => state.setTimeSpent);
const renderPdfIcon = usePDFDownload("stats");
const [trainingContent, setTrainingContent] = useState<ITrainingContent | null>(null);
const [loading, setLoading] = useState(true);
const [trainingTips, setTrainingTips] = useState<ITrainingTip[]>([]);
const [currentTipIndex, setCurrentTipIndex] = useState(0);
const { assignments } = useAssignments({});
const { users } = useUsers();
const [trainingContent, setTrainingContent] = useState<ITrainingContent | null>(null);
const [loading, setLoading] = useState(true);
const [trainingTips, setTrainingTips] = useState<ITrainingTip[]>([]);
const [currentTipIndex, setCurrentTipIndex] = useState(0);
const {assignments} = useAssignments({});
const {users} = useUsers();
const router = useRouter();
const { id } = router.query;
const router = useRouter();
const {id} = router.query;
useEffect(() => {
const fetchTrainingContent = async () => {
if (!id || typeof id !== 'string') return;
useEffect(() => {
const fetchTrainingContent = async () => {
if (!id || typeof id !== "string") return;
try {
setLoading(true);
const response = await axios.get<ITrainingContent>(`/api/training/${id}`);
const trainingContent = response.data;
try {
setLoading(true);
const response = await axios.get<ITrainingContent>(`/api/training/${id}`);
const trainingContent = response.data;
const withExamsStats = {
...trainingContent,
exams: await Promise.all(trainingContent.exams.map(async (exam) => {
const stats = await Promise.all(exam.stat_ids.map(async (statId) => {
const statResponse = await axios.get<Stat>(`/api/stats/${statId}`);
return statResponse.data;
}));
return { ...exam, stats };
}))
};
const withExamsStats = {
...trainingContent,
exams: await Promise.all(
trainingContent.exams.map(async (exam) => {
const stats = await Promise.all(
exam.stat_ids.map(async (statId) => {
const statResponse = await axios.get<Stat>(`/api/stats/${statId}`);
return statResponse.data;
}),
);
return {...exam, stats};
}),
),
};
const tips = await axios.get<ITrainingTip[]>('/api/training/walkthrough', {
params: { ids: trainingContent.tip_ids },
paramsSerializer: params => qs.stringify(params, { arrayFormat: 'repeat' })
});
setTrainingTips(tips.data);
setTrainingContent(withExamsStats);
} catch (error) {
router.push('/training');
} finally {
setLoading(false);
}
};
const tips = await axios.get<ITrainingTip[]>("/api/training/walkthrough", {
params: {ids: trainingContent.tip_ids},
paramsSerializer: (params) => qs.stringify(params, {arrayFormat: "repeat"}),
});
setTrainingTips(tips.data);
setTrainingContent(withExamsStats);
} catch (error) {
router.push("/training");
} finally {
setLoading(false);
}
};
fetchTrainingContent();
}, [id]);
fetchTrainingContent();
}, [id, router]);
const handleNext = () => {
setCurrentTipIndex((prevIndex) => (prevIndex + 1));
};
const handleNext = () => {
setCurrentTipIndex((prevIndex) => prevIndex + 1);
};
const handlePrevious = () => {
setCurrentTipIndex((prevIndex) => (prevIndex - 1));
};
const handlePrevious = () => {
setCurrentTipIndex((prevIndex) => prevIndex - 1);
};
const goToExam = (examNumber: number) => {
const stats = trainingContent?.exams[examNumber].stats!;
const examPromises = uniqBy(stats, "exam").map((stat) => {
return getExamById(stat.module, stat.exam);
});
const goToExam = (examNumber: number) => {
const stats = trainingContent?.exams[examNumber].stats!;
const examPromises = uniqBy(stats, "exam").map((stat) => {
return getExamById(stat.module, stat.exam);
});
const { timeSpent, inactivity } = stats[0];
const {timeSpent, inactivity} = stats[0];
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
if (!!timeSpent) setTimeSpent(timeSpent);
if (!!inactivity) setInactivity(inactivity);
setUserSolutions(convertToUserSolutions(stats));
setShowSolutions(true);
setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
router.push("/exercises");
}
});
}
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
if (!!timeSpent) setTimeSpent(timeSpent);
if (!!inactivity) setInactivity(inactivity);
setUserSolutions(convertToUserSolutions(stats));
setShowSolutions(true);
setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
router.push("/exercises");
}
});
};
return (
<>
<Head>
<title>Training | 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 />
return (
<>
<Head>
<title>Training | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
{loading ? (
<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>
) : (trainingContent && (
<div className="flex flex-col gap-8">
<div className="flex flex-row items-center">
<span className="bg-gray-200 text-gray-800 px-3 py-0.5 rounded-full font-semibold text-lg mr-2">{trainingContent.exams.length}</span>
<span>Exams Selected</span>
</div>
<div className='h-[15vh] mb-4'>
<InfiniteCarousel height="150px"
overlay={
<LuExternalLink size={20} />
}
overlayFunc={goToExam}
overlayClassName='bottom-6 right-5 cursor-pointer'
>
{trainingContent.exams.map((exam, examIndex) => (
<StatsGridItem
key={`exam-${examIndex}`}
width='380px'
height='150px'
examNumber={examIndex + 1}
stats={exam.stats || []}
timestamp={exam.date}
user={user}
assignments={assignments}
users={users}
setExams={setExams}
setShowSolutions={setShowSolutions}
setUserSolutions={setUserSolutions}
setSelectedModules={setSelectedModules}
setInactivity={setInactivity}
setTimeSpent={setTimeSpent}
renderPdfIcon={renderPdfIcon}
/>
))}
</InfiniteCarousel>
</div>
<div className='flex flex-col'>
<div className='flex flex-row gap-10 -md:flex-col h-full'>
<div className="flex flex-col rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full max-h-full">
<div className="flex flex-row items-center mb-6 gap-1">
<MdOutlinePlaylistAddCheckCircle color={"#40A1EA"} size={26} />
<h2 className={`text-xl font-semibold text-[#40A1EA]`}>General Evaluation</h2>
</div>
<TrainingScore
trainingContent={trainingContent}
gridView={false}
/>
<div className="w-full h-px bg-[#D9D9D929] my-6"></div>
<div className="flex flex-row gap-2 items-center mb-6">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_112_168" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_112_168)">
<path d="M4 21C3.45 21 2.97917 20.8042 2.5875 20.4125C2.19583 20.0208 2 19.55 2 19V7H4V19H19V21H4ZM8 17C7.45 17 6.97917 16.8042 6.5875 16.4125C6.19583 16.0208 6 15.55 6 15V3H23V15C23 15.55 22.8042 16.0208 22.4125 16.4125C22.0208 16.8042 21.55 17 21 17H8ZM8 15H21V5H8V15ZM10 12H14V7H10V12ZM15 12H19V10H15V12ZM15 9H19V7H15V9Z" fill="#53B2F9" />
</g>
</svg>
<h3 className="text-xl font-semibold text-[#40A1EA]">Performance Breakdown by Exam:</h3>
</div>
<ul className='overflow-auto scrollbar-hide flex-grow'>
{trainingContent.exams.flatMap((exam, index) => (
<li key={index} className="flex flex-col mb-2 bg-[#22E1B30F] p-4 rounded-xl border">
<div className="flex flex-row font-semibold border-b-2 border-[#D9D9D929] text-[#22E1B3] mb-2">
<div className='flex items-center border-r-2 border-[#D9D9D929] pr-2'>
<span className='mr-1'>Exam</span>
<span className="font-semibold bg-gray-200 text-gray-800 px-2 rounded-full text-sm">{index + 1}</span>
</div>
<span className="pl-2">{exam.score}%</span>
</div>
<div className="flex flex-row items-center gap-2">
<BsChatLeftDots size={16} />
<p className="text-sm">{exam.performance_comment}</p>
</div>
</li>
))}
</ul>
</div>
<div className="flex flex-col rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full">
<div className='flex flex-col'>
<div className="flex flex-row items-center mb-4 gap-1">
<AiOutlineFileSearch color="#40A1EA" size={24} />
<h3 className="text-xl font-semibold text-[#40A1EA]">Identified Weak Areas</h3>
</div>
<Tab.Group>
<div className="flex flex-col gap-4">
<Tab.List>
<div className="flex flex-row gap-6 overflow-x-auto pb-1 training-scrollbar">
{trainingContent.weak_areas.map((x, index) => (
<Tab
key={index}
className={({ selected }) =>
clsx(
'text-[#53B2F9] pb-2 border-b-2',
'focus:outline-none',
selected ? 'border-[#1B78BE]' : 'border-[#1B78BE0F]'
)
}
>
{x.area}
</Tab>
))}
</div>
</Tab.List>
<Tab.Panels>
{trainingContent.weak_areas.map((x, index) => (
<Tab.Panel
key={index}
className="p-3 bg-[#FBFBFB] rounded-lg border border-[#0000000F]"
>
<p>{x.comment}</p>
</Tab.Panel>
))}
</Tab.Panels>
</div>
</Tab.Group>
</div>
<div className="w-full h-px bg-[#D9D9D929] my-6"></div>
<div className="flex flex-row items-center mb-4 gap-1">
<MdOutlineSelfImprovement color={"#40A1EA"} size={24} />
<h2 className={`text-xl font-semibold text-[#40A1EA]`}>Subjects that Need Improvement</h2>
</div>
<Layout user={user}>
{loading ? (
<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>
) : (
trainingContent && (
<div className="flex flex-col gap-8">
<div className="flex flex-row items-center">
<span className="bg-gray-200 text-gray-800 px-3 py-0.5 rounded-full font-semibold text-lg mr-2">
{trainingContent.exams.length}
</span>
<span>Exams Selected</span>
</div>
<div className="h-[15vh] mb-4">
<InfiniteCarousel
height="150px"
overlay={<LuExternalLink size={20} />}
overlayFunc={goToExam}
overlayClassName="bottom-6 right-5 cursor-pointer">
{trainingContent.exams.map((exam, examIndex) => (
<StatsGridItem
key={`exam-${examIndex}`}
width="380px"
height="150px"
examNumber={examIndex + 1}
stats={exam.stats || []}
timestamp={exam.date}
user={user}
assignments={assignments}
users={users}
setExams={setExams}
setShowSolutions={setShowSolutions}
setUserSolutions={setUserSolutions}
setSelectedModules={setSelectedModules}
setInactivity={setInactivity}
setTimeSpent={setTimeSpent}
renderPdfIcon={renderPdfIcon}
/>
))}
</InfiniteCarousel>
</div>
<div className="flex flex-col">
<div className="flex flex-row gap-10 -md:flex-col h-full">
<div className="flex flex-col rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full max-h-full">
<div className="flex flex-row items-center mb-6 gap-1">
<MdOutlinePlaylistAddCheckCircle color={"#40A1EA"} size={26} />
<h2 className={`text-xl font-semibold text-[#40A1EA]`}>General Evaluation</h2>
</div>
<TrainingScore trainingContent={trainingContent} gridView={false} />
<div className="w-full h-px bg-[#D9D9D929] my-6"></div>
<div className="flex flex-row gap-2 items-center mb-6">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_112_168" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_112_168)">
<path
d="M4 21C3.45 21 2.97917 20.8042 2.5875 20.4125C2.19583 20.0208 2 19.55 2 19V7H4V19H19V21H4ZM8 17C7.45 17 6.97917 16.8042 6.5875 16.4125C6.19583 16.0208 6 15.55 6 15V3H23V15C23 15.55 22.8042 16.0208 22.4125 16.4125C22.0208 16.8042 21.55 17 21 17H8ZM8 15H21V5H8V15ZM10 12H14V7H10V12ZM15 12H19V10H15V12ZM15 9H19V7H15V9Z"
fill="#53B2F9"
/>
</g>
</svg>
<h3 className="text-xl font-semibold text-[#40A1EA]">Performance Breakdown by Exam:</h3>
</div>
<ul className="overflow-auto scrollbar-hide flex-grow">
{trainingContent.exams.flatMap((exam, index) => (
<li key={index} className="flex flex-col mb-2 bg-[#22E1B30F] p-4 rounded-xl border">
<div className="flex flex-row font-semibold border-b-2 border-[#D9D9D929] text-[#22E1B3] mb-2">
<div className="flex items-center border-r-2 border-[#D9D9D929] pr-2">
<span className="mr-1">Exam</span>
<span className="font-semibold bg-gray-200 text-gray-800 px-2 rounded-full text-sm">
{index + 1}
</span>
</div>
<span className="pl-2">{exam.score}%</span>
</div>
<div className="flex flex-row items-center gap-2">
<BsChatLeftDots size={16} />
<p className="text-sm">{exam.performance_comment}</p>
</div>
</li>
))}
</ul>
</div>
<div className="flex flex-col rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full">
<div className="flex flex-col">
<div className="flex flex-row items-center mb-4 gap-1">
<AiOutlineFileSearch color="#40A1EA" size={24} />
<h3 className="text-xl font-semibold text-[#40A1EA]">Identified Weak Areas</h3>
</div>
<Tab.Group>
<div className="flex flex-col gap-4">
<Tab.List>
<div className="flex flex-row gap-6 overflow-x-auto pb-1 training-scrollbar">
{trainingContent.weak_areas.map((x, index) => (
<Tab
key={index}
className={({selected}) =>
clsx(
"text-[#53B2F9] pb-2 border-b-2",
"focus:outline-none",
selected ? "border-[#1B78BE]" : "border-[#1B78BE0F]",
)
}>
{x.area}
</Tab>
))}
</div>
</Tab.List>
<Tab.Panels>
{trainingContent.weak_areas.map((x, index) => (
<Tab.Panel key={index} className="p-3 bg-[#FBFBFB] rounded-lg border border-[#0000000F]">
<p>{x.comment}</p>
</Tab.Panel>
))}
</Tab.Panels>
</div>
</Tab.Group>
</div>
<div className="w-full h-px bg-[#D9D9D929] my-6"></div>
<div className="flex flex-row items-center mb-4 gap-1">
<MdOutlineSelfImprovement color={"#40A1EA"} size={24} />
<h2 className={`text-xl font-semibold text-[#40A1EA]`}>Subjects that Need Improvement</h2>
</div>
<div className="flex flex-grow bg-[#FBFBFB] border rounded-xl p-4">
<div className='flex flex-col'>
<div className="flex flex-row items-center gap-1 mb-4">
<div className="flex items-center justify-center w-[48px] h-[48px]">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_112_445" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_112_445)">
<path d="M6 17H11V15H6V17ZM16 17H18V15H16V17ZM6 13H11V11H6V13ZM16 13H18V7H16V13ZM6 9H11V7H6V9ZM4 21C3.45 21 2.97917 20.8042 2.5875 20.4125C2.19583 20.0208 2 19.55 2 19V5C2 4.45 2.19583 3.97917 2.5875 3.5875C2.97917 3.19583 3.45 3 4 3H20C20.55 3 21.0208 3.19583 21.4125 3.5875C21.8042 3.97917 22 4.45 22 5V19C22 19.55 21.8042 20.0208 21.4125 20.4125C21.0208 20.8042 20.55 21 20 21H4ZM4 19H20V5H4V19Z" fill="#1C1B1F" />
</g>
</svg>
</div>
<h3 className="text-lg font-semibold">Detailed Breakdown</h3>
</div>
<ul className="flex flex-col flex-grow space-y-4 pb-2 overflow-y-auto scrollbar-hide">
{trainingContent.exams.map((exam, index) => (
<li key={index} className="border rounded-lg bg-white">
<Dropdown title={
<div className='flex flex-row items-center'>
<span className="mr-1">Exam</span>
<span className="font-semibold bg-gray-200 text-gray-800 px-2 rounded-full text-sm mt-0.5">{index + 1}</span>
</div>
} open={index == 0}>
<span>{exam.detailed_summary}</span>
</Dropdown>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
<div className="flex -md:hidden">
<div className="rounded-3xl p-6 shadow-training-inset w-full">
<div className="flex flex-col p-10">
<Exercise key={currentTipIndex} {...trainingTips[currentTipIndex]} />
</div>
<div className="self-end flex justify-between w-full gap-8 bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={handlePrevious}
disabled={currentTipIndex == 0}
className="max-w-[200px] self-end w-full">
Previous
</Button>
<Button
color="purple"
disabled={currentTipIndex == (trainingTips.length - 1)}
onClick={handleNext}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</div>
</div>
</div>
))}
</Layout>
</>
);
}
<div className="flex flex-grow bg-[#FBFBFB] border rounded-xl p-4">
<div className="flex flex-col">
<div className="flex flex-row items-center gap-1 mb-4">
<div className="flex items-center justify-center w-[48px] h-[48px]">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_112_445" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_112_445)">
<path
d="M6 17H11V15H6V17ZM16 17H18V15H16V17ZM6 13H11V11H6V13ZM16 13H18V7H16V13ZM6 9H11V7H6V9ZM4 21C3.45 21 2.97917 20.8042 2.5875 20.4125C2.19583 20.0208 2 19.55 2 19V5C2 4.45 2.19583 3.97917 2.5875 3.5875C2.97917 3.19583 3.45 3 4 3H20C20.55 3 21.0208 3.19583 21.4125 3.5875C21.8042 3.97917 22 4.45 22 5V19C22 19.55 21.8042 20.0208 21.4125 20.4125C21.0208 20.8042 20.55 21 20 21H4ZM4 19H20V5H4V19Z"
fill="#1C1B1F"
/>
</g>
</svg>
</div>
<h3 className="text-lg font-semibold">Detailed Breakdown</h3>
</div>
<ul className="flex flex-col flex-grow space-y-4 pb-2 overflow-y-auto scrollbar-hide">
{trainingContent.exams.map((exam, index) => (
<li key={index} className="border rounded-lg bg-white">
<Dropdown
title={
<div className="flex flex-row items-center">
<span className="mr-1">Exam</span>
<span className="font-semibold bg-gray-200 text-gray-800 px-2 rounded-full text-sm mt-0.5">
{index + 1}
</span>
</div>
}
open={index == 0}>
<span>{exam.detailed_summary}</span>
</Dropdown>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
<div className="flex -md:hidden">
<div className="rounded-3xl p-6 shadow-training-inset w-full">
<div className="flex flex-col p-10">
<Exercise key={currentTipIndex} {...trainingTips[currentTipIndex]} />
</div>
<div className="self-end flex justify-between w-full gap-8 bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={handlePrevious}
disabled={currentTipIndex == 0}
className="max-w-[200px] self-end w-full">
Previous
</Button>
<Button
color="purple"
disabled={currentTipIndex == trainingTips.length - 1}
onClick={handleNext}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</div>
</div>
</div>
)
)}
</Layout>
</>
);
};
export default TrainingContent;

View File

@@ -1,14 +1,14 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Stat, User } from "@/interfaces/user";
import { ToastContainer } from "react-toastify";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Stat, User} from "@/interfaces/user";
import {ToastContainer} from "react-toastify";
import Layout from "@/components/High/Layout";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { use, useEffect, useState } from "react";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {use, useEffect, useState} from "react";
import clsx from "clsx";
import { FaPlus } from "react-icons/fa";
import {FaPlus} from "react-icons/fa";
import useRecordStore from "@/stores/recordStore";
import router from "next/router";
import useTrainingContentStore from "@/stores/trainingContentStore";
@@ -16,390 +16,390 @@ import axios from "axios";
import Select from "@/components/Low/Select";
import useUsers from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups";
import { ITrainingContent } from "@/training/TrainingInterfaces";
import {ITrainingContent} from "@/training/TrainingInterfaces";
import moment from "moment";
import { uuidv4 } from "@firebase/util";
import {uuidv4} from "@firebase/util";
import TrainingScore from "@/training/TrainingScore";
import ModuleBadge from "@/components/ModuleBadge";
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user;
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
if (!user || !user.isVerified) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (!user || !user.isVerified) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: { user: req.session.user },
};
return {
props: {user: req.session.user},
};
}, sessionOptions);
const defaultSelectableCorporate = {
value: "",
label: "All",
value: "",
label: "All",
};
const Training: React.FC<{ user: User }> = ({ user }) => {
// Record stuff
const { users } = useUsers();
const [selectedCorporate, setSelectedCorporate] = useState<string>(defaultSelectableCorporate.value);
const [statsUserId, setStatsUserId, setRecordTraining] = useRecordStore((state) => [state.selectedUser, state.setSelectedUser, state.setTraining]);
const { groups: allGroups } = useGroups();
const groups = allGroups.filter((x) => x.admin === user.id);
const [filter, setFilter] = useState<"months" | "weeks" | "days">();
const Training: React.FC<{user: User}> = ({user}) => {
// Record stuff
const {users} = useUsers();
const [selectedCorporate, setSelectedCorporate] = useState<string>(defaultSelectableCorporate.value);
const [statsUserId, setStatsUserId, setRecordTraining] = useRecordStore((state) => [
state.selectedUser,
state.setSelectedUser,
state.setTraining,
]);
const {groups: allGroups} = useGroups({});
const groups = allGroups.filter((x) => x.admin === user.id);
const [filter, setFilter] = useState<"months" | "weeks" | "days">();
const toggleFilter = (value: "months" | "weeks" | "days") => {
setFilter((prev) => (prev === value ? undefined : value));
};
const toggleFilter = (value: "months" | "weeks" | "days") => {
setFilter((prev) => (prev === value ? undefined : value));
};
const [stats, setTrainingStats] = useTrainingContentStore((state) => [state.stats, state.setStats]);
const [trainingContent, setTrainingContent] = useState<ITrainingContent[]>([]);
const [isNewContentLoading, setIsNewContentLoading] = useState(stats.length != 0);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{ [key: string]: ITrainingContent }>();
const [stats, setTrainingStats] = useTrainingContentStore((state) => [state.stats, state.setStats]);
const [trainingContent, setTrainingContent] = useState<ITrainingContent[]>([]);
const [isNewContentLoading, setIsNewContentLoading] = useState(stats.length != 0);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{[key: string]: ITrainingContent}>();
useEffect(() => {
const handleRouteChange = (url: string) => {
setTrainingStats([])
}
router.events.on('routeChangeStart', handleRouteChange)
return () => {
router.events.off('routeChangeStart', handleRouteChange)
}
}, [router.events, setTrainingStats])
useEffect(() => {
const handleRouteChange = (url: string) => {
setTrainingStats([]);
};
router.events.on("routeChangeStart", handleRouteChange);
return () => {
router.events.off("routeChangeStart", handleRouteChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.events, setTrainingStats]);
useEffect(() => {
const postStats = async () => {
try {
const response = await axios.post<{ id: string }>(`/api/training`, stats);
return response.data.id;
} catch (error) {
setIsNewContentLoading(false);
}
};
useEffect(() => {
const postStats = async () => {
try {
const response = await axios.post<{id: string}>(`/api/training`, stats);
return response.data.id;
} catch (error) {
setIsNewContentLoading(false);
}
};
if (isNewContentLoading) {
postStats().then(id => {
setTrainingStats([]);
if (id) {
router.push(`/training/${id}`)
}
});
}
}, [isNewContentLoading])
if (isNewContentLoading) {
postStats().then((id) => {
setTrainingStats([]);
if (id) {
router.push(`/training/${id}`);
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewContentLoading]);
useEffect(() => {
const loadTrainingContent = async () => {
try {
const response = await axios.get<ITrainingContent[]>('/api/training');
setTrainingContent(response.data);
setIsLoading(false);
} catch (error) {
setTrainingContent([]);
setIsLoading(false);
}
};
loadTrainingContent();
}, []);
useEffect(() => {
const loadTrainingContent = async () => {
try {
const response = await axios.get<ITrainingContent[]>("/api/training");
setTrainingContent(response.data);
setIsLoading(false);
} catch (error) {
setTrainingContent([]);
setIsLoading(false);
}
};
loadTrainingContent();
}, []);
const handleNewTrainingContent = () => {
setRecordTraining(true);
router.push('/record')
}
const handleNewTrainingContent = () => {
setRecordTraining(true);
router.push("/record");
};
const filterTrainingContentByDate = (trainingContent: {[key: string]: ITrainingContent}) => {
if (filter) {
const filterDate = moment()
.subtract({[filter as string]: 1})
.format("x");
const filteredTrainingContent: {[key: string]: ITrainingContent} = {};
const filterTrainingContentByDate = (trainingContent: { [key: string]: ITrainingContent }) => {
if (filter) {
const filterDate = moment()
.subtract({ [filter as string]: 1 })
.format("x");
const filteredTrainingContent: { [key: string]: ITrainingContent } = {};
Object.keys(trainingContent).forEach((timestamp) => {
if (timestamp >= filterDate) filteredTrainingContent[timestamp] = trainingContent[timestamp];
});
return filteredTrainingContent;
}
return trainingContent;
};
Object.keys(trainingContent).forEach((timestamp) => {
if (timestamp >= filterDate) filteredTrainingContent[timestamp] = trainingContent[timestamp];
});
return filteredTrainingContent;
}
return trainingContent;
};
useEffect(() => {
if (trainingContent.length > 0) {
const grouped = trainingContent.reduce((acc, content) => {
acc[content.created_at] = content;
return acc;
}, {} as {[key: number]: ITrainingContent});
useEffect(() => {
if (trainingContent.length > 0) {
const grouped = trainingContent.reduce((acc, content) => {
acc[content.created_at] = content;
return acc;
}, {} as { [key: number]: ITrainingContent });
setGroupedByTrainingContent(grouped);
}
}, [trainingContent]);
setGroupedByTrainingContent(grouped);
}
}, [trainingContent])
// Record Stuff
const selectableCorporates = [
defaultSelectableCorporate,
...users
.filter((x) => x.type === "corporate")
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
})),
];
const getUsersList = (): User[] => {
if (selectedCorporate) {
// get groups for that corporate
const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate);
// Record Stuff
const selectableCorporates = [
defaultSelectableCorporate,
...users
.filter((x) => x.type === "corporate")
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
})),
];
// get the teacher ids for that group
const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants);
const getUsersList = (): User[] => {
if (selectedCorporate) {
// get groups for that corporate
const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate);
// // search for groups for these teachers
// const teacherGroups = allGroups.filter((x) => {
// return selectedCorporateGroupsParticipants.includes(x.admin);
// });
// get the teacher ids for that group
const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants);
// const usersList = [
// ...selectedCorporateGroupsParticipants,
// ...teacherGroups.flatMap((x) => x.participants),
// ];
const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[];
return userListWithUsers.filter((x) => x);
}
// // search for groups for these teachers
// const teacherGroups = allGroups.filter((x) => {
// return selectedCorporateGroupsParticipants.includes(x.admin);
// });
return users || [];
};
// const usersList = [
// ...selectedCorporateGroupsParticipants,
// ...teacherGroups.flatMap((x) => x.participants),
// ];
const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[];
return userListWithUsers.filter((x) => x);
}
const corporateFilteredUserList = getUsersList();
const getSelectedUser = () => {
if (selectedCorporate) {
const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId);
return userInCorporate || corporateFilteredUserList[0];
}
return users || [];
};
return users.find((x) => x.id === statsUserId) || user;
};
const corporateFilteredUserList = getUsersList();
const getSelectedUser = () => {
if (selectedCorporate) {
const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId);
return userInCorporate || corporateFilteredUserList[0];
}
const selectedUser = getSelectedUser();
const selectedUserSelectValue = selectedUser
? {
value: selectedUser.id,
label: `${selectedUser.name} - ${selectedUser.email}`,
}
: {
value: "",
label: "",
};
return users.find((x) => x.id === statsUserId) || user;
};
const formatTimestamp = (timestamp: string) => {
const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm";
const selectedUser = getSelectedUser();
const selectedUserSelectValue = selectedUser
? {
value: selectedUser.id,
label: `${selectedUser.name} - ${selectedUser.email}`,
}
: {
value: "",
label: "",
};
return date.format(formatter);
};
const formatTimestamp = (timestamp: string) => {
const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm";
const selectTrainingContent = (trainingContent: ITrainingContent) => {
router.push(`/training/${trainingContent.id}`);
};
return date.format(formatter);
};
const trainingContentContainer = (timestamp: string) => {
if (!groupedByTrainingContent) return <></>;
const trainingContent: ITrainingContent = groupedByTrainingContent[timestamp];
const uniqueModules = [...new Set(trainingContent.exams.map((exam) => exam.module))];
const selectTrainingContent = (trainingContent: ITrainingContent) => {
router.push(`/training/${trainingContent.id}`)
};
return (
<>
<div
key={uuidv4()}
className={clsx(
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
)}
onClick={() => selectTrainingContent(trainingContent)}
role="button">
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
<span className="font-medium">{formatTimestamp(timestamp)}</span>
</div>
<div className="flex flex-col gap-2">
<div className="w-full flex flex-row gap-1">
{uniqueModules.map((module) => (
<ModuleBadge key={module} module={module} />
))}
</div>
</div>
</div>
<TrainingScore trainingContent={trainingContent} gridView={true} />
</div>
</>
);
};
return (
<>
<Head>
<title>Training | 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 />
const trainingContentContainer = (timestamp: string) => {
if (!groupedByTrainingContent) return <></>;
const trainingContent: ITrainingContent = groupedByTrainingContent[timestamp];
const uniqueModules = [...new Set(trainingContent.exams.map(exam => exam.module))];
<Layout user={user}>
{isNewContentLoading || isLoading ? (
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
<span className="loading loading-infinity w-32 bg-mti-green-light" />
{isNewContentLoading && (
<span className="text-center text-2xl font-bold text-mti-green-light">Assessing your exams, please be patient...</span>
)}
</div>
) : (
<>
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
<div className="xl:w-3/4">
{(user.type === "developer" || user.type === "admin") && (
<>
<label className="font-normal text-base text-mti-gray-dim">Corporate</label>
return (
<>
<div
key={uuidv4()}
className={clsx(
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden"
)}
onClick={() => selectTrainingContent(trainingContent)}
role="button">
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
<span className="font-medium">{formatTimestamp(timestamp)}</span>
</div>
<div className="flex flex-col gap-2">
<div className="w-full flex flex-row gap-1">
{uniqueModules.map((module) => (
<ModuleBadge key={module} module={module} />
))}
</div>
</div>
</div>
<TrainingScore
trainingContent={trainingContent}
gridView={true}
/>
</div>
</>
);
};
<Select
options={selectableCorporates}
value={selectableCorporates.find((x) => x.value === selectedCorporate)}
onChange={(value) => setSelectedCorporate(value?.value || "")}
styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}></Select>
<label className="font-normal text-base text-mti-gray-dim">User</label>
return (
<>
<Head>
<title>Training | 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 />
<Select
options={corporateFilteredUserList.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
value={selectedUserSelectValue}
onChange={(value) => setStatsUserId(value?.value!)}
styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</>
)}
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && (
<>
<label className="font-normal text-base text-mti-gray-dim">User</label>
<Layout user={user}>
{(isNewContentLoading || isLoading ? (
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
<span className="loading loading-infinity w-32 bg-mti-green-light" />
{isNewContentLoading && (<span className="text-center text-2xl font-bold text-mti-green-light">
Assessing your exams, please be patient...
</span>)}
</div>
) : (
<>
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
<div className="xl:w-3/4">
{(user.type === "developer" || user.type === "admin") && (
<>
<label className="font-normal text-base text-mti-gray-dim">Corporate</label>
<Select
options={selectableCorporates}
value={selectableCorporates.find((x) => x.value === selectedCorporate)}
onChange={(value) => setSelectedCorporate(value?.value || "")}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}></Select>
<label className="font-normal text-base text-mti-gray-dim">User</label>
<Select
options={corporateFilteredUserList.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
value={selectedUserSelectValue}
onChange={(value) => setStatsUserId(value?.value!)}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</>
)}
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && (
<>
<label className="font-normal text-base text-mti-gray-dim">User</label>
<Select
options={users
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
value={selectedUserSelectValue}
onChange={(value) => setStatsUserId(value?.value!)}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</>
)}
{(user.type === "student" && (
<>
<div className="flex items-center">
<div className="font-semibold text-2xl">Generate New Training Material</div>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
"transition duration-300 ease-in-out",
)}
onClick={handleNewTrainingContent}>
<FaPlus />
</button>
</div>
</>
))}
</div>
<div className="flex gap-4 w-full justify-center xl:justify-end">
<button
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",
filter === "months" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("months")}>
Last month
</button>
<button
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",
filter === "weeks" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("weeks")}>
Last week
</button>
<button
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",
filter === "days" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("days")}>
Last day
</button>
</div>
</div>
{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>
)}
{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>
)}
</>
))}
</Layout>
</>
);
}
<Select
options={users
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
value={selectedUserSelectValue}
onChange={(value) => setStatsUserId(value?.value!)}
styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</>
)}
{user.type === "student" && (
<>
<div className="flex items-center">
<div className="font-semibold text-2xl">Generate New Training Material</div>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
"transition duration-300 ease-in-out",
)}
onClick={handleNewTrainingContent}>
<FaPlus />
</button>
</div>
</>
)}
</div>
<div className="flex gap-4 w-full justify-center xl:justify-end">
<button
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",
filter === "months" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("months")}>
Last month
</button>
<button
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",
filter === "weeks" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("weeks")}>
Last week
</button>
<button
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",
filter === "days" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("days")}>
Last day
</button>
</div>
</div>
{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>
)}
{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>
)}
</>
)}
</Layout>
</>
);
};
export default Training;

View File

@@ -5,17 +5,17 @@ export const USER_TYPE_LABELS: {[key in Type]: string} = {
teacher: "Teacher",
corporate: "Corporate",
agent: "Country Manager",
admin: "Admin",
admin: "Super Admin",
developer: "Developer",
mastercorporate: "Master Corporate"
mastercorporate: "Master Corporate",
};
export function isCorporateUser(user: User): user is CorporateUser {
return (user as CorporateUser).corporateInformation !== undefined;
return (user as CorporateUser)?.corporateInformation !== undefined;
}
export function isAgentUser(user: User): user is AgentUser {
return (user as AgentUser).agentInformation !== undefined;
return (user as AgentUser)?.agentInformation !== undefined;
}
export function getUserCompanyName(user: User, users: User[], groups: Group[]) {
@@ -30,3 +30,15 @@ export function getUserCompanyName(user: User, users: User[], groups: Group[]) {
const admin = belongingGroupsAdmins[0] as CorporateUser;
return admin.corporateInformation?.companyInformation.name || admin.name;
}
export function getCorporateUser(user: User, users: User[], groups: Group[]) {
if (isCorporateUser(user)) return user;
const belongingGroups = groups.filter((x) => x.participants.includes(user.id));
const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x));
if (belongingGroupsAdmins.length === 0) return undefined;
const admin = belongingGroupsAdmins[0] as CorporateUser;
return admin;
}

View File

@@ -1,5 +1,5 @@
import {Module} from "@/interfaces";
import {Exam, UserSolution} from "@/interfaces/exam";
import {Exam, ShuffleMap, UserSolution} from "@/interfaces/exam";
import {Assignment} from "@/interfaces/results";
import {create} from "zustand";
@@ -18,6 +18,8 @@ export interface ExamState {
exerciseIndex: number;
questionIndex: number;
inactivity: number;
shuffleMaps: ShuffleMap[];
bgColor: string;
}
export interface ExamFunctions {
@@ -35,6 +37,8 @@ export interface ExamFunctions {
setExerciseIndex: (exerciseIndex: number) => void;
setQuestionIndex: (questionIndex: number) => void;
setInactivity: (inactivity: number) => void;
setShuffleMaps: (shuffleMaps: ShuffleMap[]) => void;
setBgColor: (bgColor: string) => void;
reset: () => void;
}
@@ -53,6 +57,8 @@ export const initialState: ExamState = {
exerciseIndex: -1,
questionIndex: 0,
inactivity: 0,
shuffleMaps: [],
bgColor: "bg-white"
};
const useExamStore = create<ExamState & ExamFunctions>((set) => ({
@@ -72,6 +78,8 @@ const useExamStore = create<ExamState & ExamFunctions>((set) => ({
setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})),
setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})),
setInactivity: (inactivity: number) => set(() => ({inactivity})),
setShuffleMaps: (shuffleMaps) => set(() => ({shuffleMaps})),
setBgColor: (bgColor) => set(()=> ({bgColor})),
reset: () => set(() => initialState),
}));

View File

@@ -87,4 +87,4 @@ body {
a {
color: inherit;
text-decoration: none;
}
}

View File

@@ -0,0 +1,19 @@
import {app} from "@/firebase";
import {Assignment} from "@/interfaces/results";
import {collection, getDocs, getFirestore, query, where} from "firebase/firestore";
const db = getFirestore(app);
export const getAssignmentsByAssigner = async (id: string) => {
const {docs} = await getDocs(query(collection(db, "assignments"), where("assigner", "==", id)));
return docs.map((x) => ({...x.data(), id: x.id})) as Assignment[];
};
export const getAssignmentsByAssignerBetweenDates = async (id: string, startDate: Date, endDate: Date) => {
const {docs} = await getDocs(query(collection(db, "assignments"), where("assigner", "==", id), ));
return docs.map((x) => ({...x.data(), id: x.id})) as Assignment[];
};
export const getAssignmentsByAssigners = async (ids: string[]) => {
return (await Promise.all(ids.map(getAssignmentsByAssigner))).flat();
};

View File

@@ -1,13 +1,28 @@
import { app } from "@/firebase";
import { CorporateUser, StudentUser, TeacherUser } from "@/interfaces/user";
import { doc, getDoc, getFirestore, setDoc } from "firebase/firestore";
import {
CorporateUser,
Group,
StudentUser,
TeacherUser,
} from "@/interfaces/user";
import {
collection,
doc,
getDoc,
getDocs,
getFirestore,
query,
setDoc,
where,
} from "firebase/firestore";
import moment from "moment";
import { getUser } from "./users.be";
import { getSpecificUsers } from "./users.be";
const db = getFirestore(app);
export const updateExpiryDateOnGroup = async (
participantID: string,
corporateID: string,
corporateID: string
) => {
const corporateRef = await getDoc(doc(db, "users", corporateID));
const participantRef = await getDoc(doc(db, "users", participantID));
@@ -35,7 +50,7 @@ export const updateExpiryDateOnGroup = async (
return await setDoc(
doc(db, "users", participant.id),
{ subscriptionExpirationDate: null },
{ merge: true },
{ merge: true }
);
}
@@ -46,8 +61,113 @@ export const updateExpiryDateOnGroup = async (
return await setDoc(
doc(db, "users", participant.id),
{ subscriptionExpirationDate: corporateDate.toISOString() },
{ merge: true },
{ merge: true }
);
return;
};
export const getGroups = async () => {
const groupDocs = await getDocs(collection(db, "groups"));
return groupDocs.docs.map((x) => ({ ...x.data(), id: x.id })) as Group[];
};
export const getUserGroups = async (id: string): Promise<Group[]> => {
const groupDocs = await getDocs(
query(collection(db, "groups"), where("admin", "==", id))
);
return groupDocs.docs.map((x) => ({ ...x.data(), id })) as Group[];
};
export const getAllAssignersByCorporate = async (
corporateID: string
): Promise<string[]> => {
const groups = await getUserGroups(corporateID);
const groupUsers = (
await Promise.all(
groups.map(async (g) => await Promise.all(g.participants.map(getUser)))
)
).flat();
const teacherPromises = await Promise.all(
groupUsers.map(async (u) =>
u.type === "teacher"
? u.id
: u.type === "corporate"
? [...(await getAllAssignersByCorporate(u.id)), u.id]
: undefined
)
);
return teacherPromises.filter((x) => !!x).flat() as string[];
};
export const getGroupsForUser = async (admin: string, participant: string) => {
try {
const queryConstraints = [
...(admin ? [where("admin", "==", admin)] : []),
...(participant
? [where("participants", "array-contains", participant)]
: []),
];
const snapshot = await getDocs(
queryConstraints.length > 0
? query(collection(db, "groups"), ...queryConstraints)
: collection(db, "groups")
);
const groups = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
return groups;
} catch (e) {
console.error(e);
return [];
}
};
export const getStudentGroupsForUsersWithoutAdmin = async (
admin: string,
participants: string[]
) => {
try {
const queryConstraints = [
...(admin ? [where("admin", "!=", admin)] : []),
...(participants
? [where("participants", "array-contains-any", participants)]
: []),
where("name", "==", "Students"),
];
const snapshot = await getDocs(
queryConstraints.length > 0
? query(collection(db, "groups"), ...queryConstraints)
: collection(db, "groups")
);
const groups = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
return groups;
} catch (e) {
console.error(e);
return [];
}
};
export const getCorporateNameForStudent = async (studentID: string) => {
const groups = await getStudentGroupsForUsersWithoutAdmin("", [studentID]);
if (groups.length === 0) return '';
const adminUserIds = [...new Set(groups.map((g) => g.admin))];
const adminUsersData = await getSpecificUsers(adminUserIds);
if(adminUsersData.length === 0) return '';
const admins = adminUsersData.filter((x) => x.type === 'corporate');
if(admins.length > 0) {
return (admins[0] as CorporateUser).corporateInformation.companyInformation.name;
}
return '';
};

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