Compare commits
227 Commits
faeture/pa
...
feature/99
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74dcccf089 | ||
|
|
63d2baf35f | ||
|
|
c02a6a01f4 | ||
|
|
a646955493 | ||
|
|
7a577a7ca2 | ||
|
|
c26ff48b60 | ||
|
|
9ee09c8fda | ||
|
|
d4867fd9a2 | ||
|
|
13e52bfce6 | ||
|
|
5540e4a3e6 | ||
|
|
a18ee93909 | ||
|
|
0641d4250c | ||
|
|
f85a1f5601 | ||
|
|
6bcc303b74 | ||
|
|
8002c71b91 | ||
|
|
31d3232f19 | ||
|
|
4448c2019e | ||
|
|
01a9da3a5b | ||
|
|
d0b0dfb16f | ||
|
|
c5007a316f | ||
|
|
c68e206aae | ||
|
|
2bad3ad09f | ||
|
|
f9e037bd7b | ||
|
|
ccde1c84b7 | ||
|
|
367553eb44 | ||
|
|
576d2ac29d | ||
|
|
e13af65d88 | ||
|
|
294d319ab3 | ||
|
|
7572909b13 | ||
|
|
46b9fe50ef | ||
|
|
1335c14acc | ||
|
|
e47607597c | ||
|
|
b7b2dca2dd | ||
|
|
a14c9f8b3c | ||
|
|
e59d36e892 | ||
|
|
f5bdedee2f | ||
|
|
3f0821eb33 | ||
|
|
31e09c94c7 | ||
|
|
404e5a8a0c | ||
|
|
b7a3778f01 | ||
|
|
24ec336dca | ||
|
|
e324b37942 | ||
|
|
066baa9492 | ||
|
|
08aec9b54c | ||
|
|
10a480aa81 | ||
|
|
360e6f8f60 | ||
|
|
eadddbf505 | ||
|
|
be03760cb9 | ||
|
|
99758d860d | ||
|
|
51dcb69b81 | ||
|
|
580ddfd9e6 | ||
|
|
9e6dc4b4c2 | ||
|
|
72b9e1f11d | ||
|
|
ad1dbaef27 | ||
|
|
6cdee9b268 | ||
|
|
7f4d82072f | ||
|
|
e365640620 | ||
|
|
27a4014f63 | ||
|
|
cb91acdded | ||
|
|
7714854338 | ||
|
|
5379cdb0d2 | ||
|
|
39ea11bc9b | ||
|
|
bb1a2e477a | ||
|
|
34c1041182 | ||
|
|
b2690f748b | ||
|
|
edbf405c30 | ||
|
|
84c42ccf3e | ||
|
|
5e283e358b | ||
|
|
c9ed3b5a72 | ||
|
|
3dfd65e161 | ||
|
|
040102c835 | ||
|
|
c781c10fe9 | ||
|
|
a91539ec61 | ||
|
|
f79857fabe | ||
|
|
14d8c1e294 | ||
|
|
fd1af3efee | ||
|
|
0c9f0b3dbd | ||
|
|
93d5015c99 | ||
|
|
356d7e6a9d | ||
|
|
2a4b7ed82d | ||
|
|
2ec7e85ace | ||
|
|
174398b4f7 | ||
|
|
b00bf19620 | ||
|
|
744aa1e788 | ||
|
|
cc0f9712d6 | ||
|
|
418221427a | ||
|
|
6c741f944d | ||
|
|
1aadc4647c | ||
|
|
4e378f0c71 | ||
|
|
f8bf58e57c | ||
|
|
271364a939 | ||
|
|
f8f8ee5e13 | ||
|
|
3b35a899e0 | ||
|
|
59d1a12439 | ||
|
|
e100c401e9 | ||
|
|
7b0f8c1c20 | ||
|
|
db2f5f2c0b | ||
|
|
0ed843125a | ||
|
|
14d19257df | ||
|
|
bdf65a7215 | ||
|
|
2540398ab0 | ||
|
|
cd8860f6ac | ||
|
|
2cd18376f2 | ||
|
|
0694950bba | ||
|
|
c6b15eaca1 | ||
|
|
647807a07c | ||
|
|
094fd05df7 | ||
|
|
1ea9d8e60f | ||
|
|
63998b50d6 | ||
|
|
0f029a21f7 | ||
|
|
7328f5c57f | ||
|
|
12d608879d | ||
|
|
9ceb71ae2f | ||
|
|
e6c82412bf | ||
|
|
5e8e46ff09 | ||
|
|
957400cb82 | ||
|
|
e687a2b3e5 | ||
|
|
7a297a6f6c | ||
|
|
432f4a735f | ||
|
|
a4f79d236d | ||
|
|
a4771d5d29 | ||
|
|
227de4ffc4 | ||
|
|
42fe650ae6 | ||
|
|
026730c077 | ||
|
|
35d1157b0c | ||
|
|
06dc92fdaa | ||
|
|
c9cac3539c | ||
|
|
d2276eba1d | ||
|
|
1c2c3fe402 | ||
|
|
d4b90b5fa4 | ||
|
|
383ddde7b5 | ||
|
|
e56636ca1f | ||
|
|
0b6a66b12d | ||
|
|
e0be2fd222 | ||
|
|
9e23e3e608 | ||
|
|
47ecc2be27 | ||
|
|
3ca0ad353e | ||
|
|
5447c89da4 | ||
|
|
c88757c869 | ||
|
|
8831729470 | ||
|
|
b3bb5a2337 | ||
|
|
b7ddee1db2 | ||
|
|
d85b9db535 | ||
|
|
d03d790327 | ||
|
|
79b159f948 | ||
|
|
3a0a9e1e99 | ||
|
|
cc2d0bf1b0 | ||
|
|
03a199983b | ||
|
|
a07e5a7312 | ||
|
|
fe5833b061 | ||
|
|
0c2200f49f | ||
|
|
cb73196503 | ||
|
|
c5fe405389 | ||
|
|
fddc3ff2f3 | ||
|
|
9dbe876d65 | ||
|
|
fd402bbd32 | ||
|
|
f2aa377cfe | ||
|
|
0f0223725e | ||
|
|
3ef29e43f5 | ||
|
|
60a7835040 | ||
|
|
1c645fcba2 | ||
|
|
938a5e9c7c | ||
|
|
cc655fed6c | ||
|
|
7f9692a3d9 | ||
|
|
cf90cae4eb | ||
|
|
fea8e0672e | ||
|
|
359748841f | ||
|
|
438778a03c | ||
|
|
c37bb2691b | ||
|
|
6c49409de8 | ||
|
|
2a335026de | ||
|
|
7712e5c71d | ||
|
|
861d97222a | ||
|
|
de862f635c | ||
|
|
ae058422aa | ||
|
|
44454d1e05 | ||
|
|
a2b9ba17a7 | ||
|
|
6f61fe1564 | ||
|
|
73d7ddc4af | ||
|
|
263f4afa82 | ||
|
|
45cf2dc279 | ||
|
|
786a425d85 | ||
|
|
d57223bd01 | ||
|
|
fbc2cff3f1 | ||
|
|
9ad4f077d1 | ||
|
|
e2b6061310 | ||
|
|
b77e97a9d2 | ||
|
|
67925c8a9e | ||
|
|
020ecff29c | ||
|
|
964660ed5d | ||
|
|
1390af62ab | ||
|
|
15947f942c | ||
|
|
7b3c3d15db | ||
|
|
1cff6fe242 | ||
|
|
4cbd045502 | ||
|
|
21b612eaa4 | ||
|
|
ef18e304a1 | ||
|
|
8e4223a9e7 | ||
|
|
7d696735ba | ||
|
|
e0ecc5be05 | ||
|
|
77af0b3495 | ||
|
|
e2e38284a7 | ||
|
|
ccd2560451 | ||
|
|
390658f2b0 | ||
|
|
450a4e9fe3 | ||
|
|
dfbbf0456d | ||
|
|
d46f92edb2 | ||
|
|
26c4368f31 | ||
|
|
ec56a5426b | ||
|
|
fe32584ff9 | ||
|
|
db7762c6e2 | ||
|
|
e70e26f84c | ||
|
|
7dc9d568d1 | ||
|
|
0049ab272b | ||
|
|
f48885bba6 | ||
|
|
5eaa0ac269 | ||
|
|
f7af21878e | ||
|
|
9d4071d4cd | ||
|
|
6f5dd86cd1 | ||
|
|
8b9537b272 | ||
|
|
a526e76c70 | ||
|
|
62b2f477f4 | ||
|
|
f36384fdb4 | ||
|
|
9c8d7988c5 | ||
|
|
18f163768c | ||
|
|
72083439af | ||
|
|
523149327b |
28
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev",
|
||||
"serverReadyAction": {
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,25 @@
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/api/packages",
|
||||
headers: [
|
||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||
{key: "Access-Control-Allow-Origin", value: "https://encoach.com"},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "GET",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@next/font": "13.1.6",
|
||||
"@paypal/paypal-js": "^7.1.0",
|
||||
"@paypal/react-paypal-js": "^8.1.3",
|
||||
"@react-pdf/renderer": "^3.1.14",
|
||||
"@tanstack/react-table": "^8.10.1",
|
||||
"@types/node": "18.13.0",
|
||||
"@types/react": "18.0.27",
|
||||
@@ -41,16 +42,20 @@
|
||||
"iron-session": "^6.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.44",
|
||||
"next": "13.1.6",
|
||||
"nodemailer": "^6.9.5",
|
||||
"nodemailer-express-handlebars": "^6.1.0",
|
||||
"primeicons": "^6.0.1",
|
||||
"primereact": "^9.2.3",
|
||||
"qrcode": "^1.5.3",
|
||||
"random-words": "^2.0.0",
|
||||
"react": "18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-csv": "^2.2.2",
|
||||
"react-currency-input-field": "^3.6.12",
|
||||
"react-datepicker": "^4.18.0",
|
||||
"react-diff-viewer": "^3.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-firebase-hooks": "^5.1.1",
|
||||
"react-icons": "^4.8.0",
|
||||
@@ -62,6 +67,7 @@
|
||||
"react-string-replace": "^1.1.0",
|
||||
"react-toastify": "^9.1.2",
|
||||
"react-xarrows": "^2.0.2",
|
||||
"read-excel-file": "^5.7.1",
|
||||
"short-unique-id": "^5.0.2",
|
||||
"stripe": "^13.10.0",
|
||||
"swr": "^2.1.3",
|
||||
@@ -73,11 +79,14 @@
|
||||
"zustand": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/blob-stream": "^0.1.33",
|
||||
"@types/formidable": "^3.4.0",
|
||||
"@types/howler": "^2.2.11",
|
||||
"@types/lodash": "^4.14.191",
|
||||
"@types/nodemailer": "^6.4.11",
|
||||
"@types/nodemailer-express-handlebars": "^4.0.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react-csv": "^1.1.10",
|
||||
"@types/react-datepicker": "^4.15.1",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@types/wavesurfer.js": "^6.0.6",
|
||||
|
||||
BIN
public/radial_progress/azul_0.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/radial_progress/azul_10.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/radial_progress/azul_100.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/radial_progress/azul_20.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/radial_progress/azul_30.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/radial_progress/azul_40.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/radial_progress/azul_50.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/radial_progress/azul_60.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/radial_progress/azul_70.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/radial_progress/azul_80.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/radial_progress/azul_90.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/radial_progress/laranja_0.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/radial_progress/laranja_10.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/radial_progress/laranja_100.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/radial_progress/laranja_20.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/radial_progress/laranja_30.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/radial_progress/laranja_40.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/radial_progress/laranja_50.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/radial_progress/laranja_60.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/radial_progress/laranja_70.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/radial_progress/laranja_80.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/radial_progress/laranja_90.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
@@ -10,6 +10,8 @@ import axios from "axios";
|
||||
import {toast} from "react-toastify";
|
||||
import {KeyedMutator} from "swr";
|
||||
import CountrySelect from "./Low/CountrySelect";
|
||||
import GenderInput from "@/components/High/GenderInput";
|
||||
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -19,6 +21,7 @@ interface Props {
|
||||
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
const [country, setCountry] = useState<string>();
|
||||
const [phone, setPhone] = useState<string>();
|
||||
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
||||
const [gender, setGender] = useState<Gender>();
|
||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
||||
const [position, setPosition] = useState<string>();
|
||||
@@ -39,6 +42,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
gender,
|
||||
employment: user.type === "corporate" ? undefined : employment,
|
||||
position: user.type === "corporate" ? position : undefined,
|
||||
passport_id,
|
||||
},
|
||||
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
||||
})
|
||||
@@ -62,7 +66,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
<form className="flex flex-col items-center justify-items-center gap-6 w-full h-full -md:px-4 lg:w-1/2 mb-32" onSubmit={save}>
|
||||
{user.type === "agent" && (
|
||||
<div className="w-full flex gap-8">
|
||||
<Input type="text" onChange={setCompanyName} name="companyName" label="Company Name" required />
|
||||
<Input type="text" onChange={setCompanyName} name="companyName" label="Corporate Name" required />
|
||||
<Input
|
||||
type="text"
|
||||
onChange={setCommercialRegistration}
|
||||
@@ -72,78 +76,29 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||
<CountrySelect value={country} onChange={setCountry} />
|
||||
</div>
|
||||
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
|
||||
<RadioGroup value={gender} onChange={setGender} className="flex flex-row justify-between">
|
||||
<RadioGroup.Option value="male">
|
||||
{({checked}) => (
|
||||
<span
|
||||
className={clsx(
|
||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||
)}>
|
||||
Male
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
<RadioGroup.Option value="female">
|
||||
{({checked}) => (
|
||||
<span
|
||||
className={clsx(
|
||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||
)}>
|
||||
Female
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
<RadioGroup.Option value="other">
|
||||
{({checked}) => (
|
||||
<span
|
||||
className={clsx(
|
||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||
)}>
|
||||
Other
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
</RadioGroup>
|
||||
<div className="w-full grid grid-cols-2 gap-6">
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||
<CountrySelect value={country} onChange={setCountry} />
|
||||
</div>
|
||||
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
|
||||
</div>
|
||||
{user.type === "student" && (
|
||||
<Input
|
||||
type="text"
|
||||
name="passport_id"
|
||||
label="Passport/National ID"
|
||||
onChange={(e) => setPassportID(e)}
|
||||
value={passport_id}
|
||||
placeholder="Enter National ID or Passport number"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<GenderInput value={gender} onChange={setGender} />
|
||||
{user.type === "corporate" && (
|
||||
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
||||
)}
|
||||
{user.type !== "corporate" && (
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
|
||||
<RadioGroup value={employment} onChange={setEmployment} className="grid grid-cols-2 items-center gap-4 place-items-center">
|
||||
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
||||
<RadioGroup.Option value={status} key={status}>
|
||||
{({checked}) => (
|
||||
<span
|
||||
className={clsx(
|
||||
"px-6 py-4 w-44 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||
)}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
{user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />}
|
||||
</form>
|
||||
|
||||
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {BAND_SCORES} from "@/constants/ielts";
|
||||
import {Module} from "@/interfaces";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
@@ -23,8 +22,8 @@ interface Props {
|
||||
|
||||
export default function Diagnostic({onFinish}: Props) {
|
||||
const [focus, setFocus] = useState<"academic" | "general">();
|
||||
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1});
|
||||
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9});
|
||||
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1, level: 0});
|
||||
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9, level: 9});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -52,7 +51,7 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
axios
|
||||
.patch("/api/users/update", {
|
||||
focus,
|
||||
levels: Object.values(levels).includes(-1) ? {reading: 0, listening: 0, writing: 0, speaking: 0} : levels,
|
||||
levels: Object.values(levels).includes(-1) ? {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0} : levels,
|
||||
desiredLevels,
|
||||
isFirstLogin: false,
|
||||
})
|
||||
|
||||
@@ -53,7 +53,7 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
|
||||
</div>
|
||||
<div className="flex justify-between w-full">
|
||||
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
||||
Back
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
||||
Confirm
|
||||
|
||||
@@ -17,7 +17,11 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
||||
const calculateScore = () => {
|
||||
const total = questions.length || 0;
|
||||
const correct = answers.filter(
|
||||
(x) => questions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
|
||||
(x) =>
|
||||
questions
|
||||
.find((y) => x.id.toString() === y.id.toString())
|
||||
?.solution?.toString()
|
||||
.toLowerCase() === x.solution.toLowerCase() || false,
|
||||
).length;
|
||||
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||
|
||||
@@ -62,41 +66,37 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
||||
</div>
|
||||
<span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span>
|
||||
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8">
|
||||
{questions.map((question, index) => (
|
||||
<div key={question.id.toString()} className="flex flex-col gap-4">
|
||||
<span>
|
||||
{index + 1}. {question.prompt}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant={
|
||||
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "true" ? "solid" : "outline"
|
||||
}
|
||||
onClick={() => toggleAnswer("true", question.id.toString())}
|
||||
className="!py-2">
|
||||
True
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "false" ? "solid" : "outline"
|
||||
}
|
||||
onClick={() => toggleAnswer("false", question.id.toString())}
|
||||
className="!py-2">
|
||||
False
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "not_given"
|
||||
? "solid"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => toggleAnswer("not_given", question.id.toString())}
|
||||
className="!py-2">
|
||||
Not Given
|
||||
</Button>
|
||||
{questions.map((question, index) => {
|
||||
const id = question.id.toString();
|
||||
|
||||
return (
|
||||
<div key={question.id.toString()} className="flex flex-col gap-4">
|
||||
<span>
|
||||
{index + 1}. {question.prompt}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant={answers.find((x) => x.id.toString() === id)?.solution === "true" ? "solid" : "outline"}
|
||||
onClick={() => toggleAnswer("true", id)}
|
||||
className="!py-2">
|
||||
True
|
||||
</Button>
|
||||
<Button
|
||||
variant={answers.find((x) => x.id.toString() === id)?.solution === "false" ? "solid" : "outline"}
|
||||
onClick={() => toggleAnswer("false", id)}
|
||||
className="!py-2">
|
||||
False
|
||||
</Button>
|
||||
<Button
|
||||
variant={answers.find((x) => x.id.toString() === id)?.solution === "not_given" ? "solid" : "outline"}
|
||||
onClick={() => toggleAnswer("not_given", id)}
|
||||
className="!py-2">
|
||||
Not Given
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ function Blank({
|
||||
const [userInput, setUserInput] = useState(userSolution || "");
|
||||
|
||||
useEffect(() => {
|
||||
const words = userInput.split(" ").filter((x) => x !== "");
|
||||
if (words.length >= maxWords) {
|
||||
const words = userInput.split(" ");
|
||||
if (words.length > maxWords) {
|
||||
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
|
||||
setUserInput(words.join(" ").trim());
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export default function Writing({
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem("enable_paste")) return;
|
||||
|
||||
const listener = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
|
||||
e.preventDefault();
|
||||
@@ -93,22 +95,8 @@ export default function Writing({
|
||||
)}
|
||||
<div className="flex flex-col h-full w-full gap-9 mb-20">
|
||||
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||
<span>
|
||||
{prefix.split("\\n").map((line, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<p>{line}</p>
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap">{prefix.replaceAll("\\n", "\n")}</span>
|
||||
<span className="font-semibold whitespace-pre-wrap">{prompt.replaceAll("\\n", "\n")}</span>
|
||||
{attachment && (
|
||||
<img
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
@@ -120,14 +108,7 @@ export default function Writing({
|
||||
</div>
|
||||
|
||||
<div className="w-full h-full flex flex-col gap-4">
|
||||
<span>
|
||||
{suffix.split("\\n").map((line, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{line}
|
||||
<br />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap">{suffix}</span>
|
||||
<textarea
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="w-full h-full min-h-[300px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||
|
||||
32
src/components/High/EmploymentStatusInput.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import {EmploymentStatus, EMPLOYMENT_STATUS} from "@/interfaces/user";
|
||||
import {RadioGroup} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface Props {
|
||||
value?: EmploymentStatus;
|
||||
onChange: (value?: EmploymentStatus) => void;
|
||||
}
|
||||
|
||||
export default function EmploymentStatusInput({value, onChange}: Props) {
|
||||
return (
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
|
||||
<RadioGroup value={value} onChange={onChange} className="grid grid-cols-2 items-center gap-4 place-items-center">
|
||||
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
||||
<RadioGroup.Option value={status} key={status}>
|
||||
{({checked}) => (
|
||||
<span
|
||||
className={clsx(
|
||||
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||
)}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/High/GenderInput.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import {Gender} from "@/interfaces/user";
|
||||
import {RadioGroup} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface Props {
|
||||
value?: Gender;
|
||||
onChange: (value?: Gender) => void;
|
||||
}
|
||||
|
||||
export default function GenderInput({value, onChange}: Props) {
|
||||
return (
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
|
||||
<RadioGroup value={value} onChange={onChange} className="flex flex-row gap-4 justify-between">
|
||||
<RadioGroup.Option value="male">
|
||||
{({checked}) => (
|
||||
<span
|
||||
className={clsx(
|
||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||
)}>
|
||||
Male
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
<RadioGroup.Option value="female">
|
||||
{({checked}) => (
|
||||
<span
|
||||
className={clsx(
|
||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||
)}>
|
||||
Female
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
<RadioGroup.Option value="other">
|
||||
{({checked}) => (
|
||||
<span
|
||||
className={clsx(
|
||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"transition duration-300 ease-in-out",
|
||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||
)}>
|
||||
Other
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/components/Low/Badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
module: Module;
|
||||
children: string;
|
||||
}
|
||||
|
||||
export default function Badge({module, children}: Props) {
|
||||
return (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||
<span className="text-sm">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {BsArrowRepeat} from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
color?: "rose" | "purple" | "red" | "green";
|
||||
color?: "rose" | "purple" | "red" | "green" | "gray";
|
||||
variant?: "outline" | "solid";
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
@@ -39,6 +39,11 @@ export default function Button({
|
||||
outline:
|
||||
"bg-transparent text-mti-red-light border border-mti-red-light hover:bg-mti-red-light disabled:text-mti-red disabled:bg-mti-red-ultralight disabled:border-none selection:bg-mti-red-dark hover:text-white selection:text-white",
|
||||
},
|
||||
gray: {
|
||||
solid: "bg-mti-gray-davy text-white border border-mti-gray-davy hover:bg-mti-gray-davy disabled:text-mti-gray-davy disabled:bg-mti-gray-davy selection:bg-mti-gray-davy",
|
||||
outline:
|
||||
"bg-transparent text-mti-gray-davy border border-mti-gray-davy hover:bg-mti-gray-davy disabled:text-mti-gray-davy disabled:bg-mti-gray-davy disabled:border-none selection:bg-mti-gray-davy hover:text-white selection:text-white",
|
||||
},
|
||||
rose: {
|
||||
solid: "bg-mti-rose-light text-white border border-mti-rose-light hover:bg-mti-rose disabled:text-mti-rose disabled:bg-mti-rose-ultralight selection:bg-mti-rose-dark",
|
||||
outline:
|
||||
|
||||
@@ -6,11 +6,15 @@ interface Props {
|
||||
isChecked: boolean;
|
||||
onChange: (isChecked: boolean) => void;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Checkbox({isChecked, onChange, children}: Props) {
|
||||
export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
|
||||
return (
|
||||
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => onChange(!isChecked)}>
|
||||
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => {
|
||||
if(disabled) return;
|
||||
onChange(!isChecked);
|
||||
}}>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
64
src/components/Low/TImezoneSelect.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { Combobox, Transition } from "@headlessui/react";
|
||||
import { BsChevronExpand } from "react-icons/bs";
|
||||
import moment from "moment-timezone";
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function TimezoneSelect({
|
||||
value,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const timezones = moment.tz.names();
|
||||
|
||||
const filteredTimezones = query === "" ? timezones : timezones.filter((x) => x.toLowerCase().includes(query.toLowerCase()));
|
||||
return (
|
||||
<>
|
||||
<Combobox value={value} onChange={onChange} disabled={disabled}>
|
||||
<div className="relative mt-1">
|
||||
<div className="relative w-full cursor-default overflow-hidden ">
|
||||
<Combobox.Input
|
||||
className="py-6 w-full px-8 text-sm font-normal placeholder:text-mti-gray-cool bg-white disabled:bg-mti-gray-platinum/40 rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
|
||||
<BsChevronExpand />
|
||||
</Combobox.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
afterLeave={() => setQuery("")}
|
||||
>
|
||||
<Combobox.Options className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-xl bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||
{filteredTimezones.map((timezone: string) => (
|
||||
<Combobox.Option
|
||||
key={timezone}
|
||||
value={timezone}
|
||||
className={({ active }) =>
|
||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||
active
|
||||
? "bg-mti-purple-light text-white"
|
||||
: "text-gray-900"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{timezone}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Combobox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
|
||||
)}>
|
||||
Record
|
||||
</Link>
|
||||
{["admin", "developer", "agent"].includes(user.type) && (
|
||||
{["admin", "developer", "agent", "corporate"].includes(user.type) && (
|
||||
<Link
|
||||
href="/payment-record"
|
||||
className={clsx(
|
||||
|
||||
@@ -7,7 +7,11 @@ import {BsList} from "react-icons/bs";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import MobileMenu from "./MobileMenu";
|
||||
import {useState} from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Type} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import {isUserFromCorporate} from "@/utils/groups";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -20,6 +24,7 @@ interface Props {
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
const router = useRouter();
|
||||
@@ -42,6 +47,11 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
||||
return today.add(7, "days").isAfter(momentDate);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user.type !== "student" && user.type !== "teacher") setDisablePaymentPage(false);
|
||||
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
|
||||
@@ -53,7 +63,7 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
||||
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8">
|
||||
{showExpirationDate() && (
|
||||
<Link
|
||||
href="/payment"
|
||||
href={disablePaymentPage ? "/payment" : ""}
|
||||
data-tip="Expiry date"
|
||||
className={clsx(
|
||||
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
@@ -69,7 +79,10 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
||||
)}
|
||||
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden">
|
||||
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
|
||||
<span className="text-right -md:hidden">{user.name}</span>
|
||||
<span className="text-right -md:hidden">
|
||||
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
|
||||
{USER_TYPE_LABELS[user.type]}
|
||||
</span>
|
||||
</Link>
|
||||
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
||||
<BsList className="text-mti-purple-light w-8 h-8" />
|
||||
|
||||
145
src/components/PaymentAssetManager.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, {ChangeEvent} from "react";
|
||||
import {BsUpload, BsDownload, BsTrash, BsArrowRepeat, BsXCircleFill} from "react-icons/bs";
|
||||
import {FilesStorage} from "@/interfaces/storage.files";
|
||||
import axios from "axios";
|
||||
|
||||
interface Asset {
|
||||
file: string | File;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
const PaymentAssetManager = (props: {
|
||||
asset: string | undefined;
|
||||
permissions: "read" | "write";
|
||||
type: FilesStorage;
|
||||
reload: () => void;
|
||||
paymentId: string;
|
||||
}) => {
|
||||
const {asset, permissions, type, paymentId} = props;
|
||||
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const fileInputReplaceRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const [managingAsset, setManagingAsset] = React.useState<Asset>({
|
||||
file: asset || "",
|
||||
complete: asset ? true : false,
|
||||
});
|
||||
|
||||
const {file, complete} = managingAsset;
|
||||
|
||||
const deleteAsset = () => {
|
||||
if (confirm("Are you sure you want to delete this document?")) {
|
||||
axios
|
||||
.delete(`/api/payments/files/${type}/${paymentId}`)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
console.log("File deleted successfully!");
|
||||
setManagingAsset({
|
||||
file: "",
|
||||
complete: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("File deletion failed");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error occurred during file deletion:", error);
|
||||
})
|
||||
.finally(props.reload);
|
||||
}
|
||||
};
|
||||
|
||||
const renderFileInput = (onChange: any, ref: React.RefObject<HTMLInputElement>) => (
|
||||
<input type="file" ref={ref} style={{display: "none"}} onChange={onChange} multiple={false} accept="application/pdf" />
|
||||
);
|
||||
|
||||
const handleFileChange = async (e: Event, method: "post" | "patch") => {
|
||||
const newFile = (e.target as HTMLInputElement).files?.[0];
|
||||
if (newFile) {
|
||||
setManagingAsset({
|
||||
file: newFile,
|
||||
complete: false,
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", newFile);
|
||||
|
||||
axios[method](`/api/payments/files/${type}/${paymentId}`, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
console.log("File uploaded successfully!");
|
||||
console.log("Uploaded File URL:", response.data.ref);
|
||||
// Further actions upon successful upload
|
||||
setManagingAsset({
|
||||
file: response.data.ref,
|
||||
complete: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("File upload failed");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error occurred during file upload:", error);
|
||||
})
|
||||
.finally(props.reload);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAsset = () => {
|
||||
axios
|
||||
.get(`/api/payments/files/${type}/${paymentId}`)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
console.log("Uploaded File URL:", response.data.url);
|
||||
const link = document.createElement("a");
|
||||
link.download = response.data.filename;
|
||||
link.href = response.data.url;
|
||||
link.click();
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Failed to download file");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error occurred during file upload:", error);
|
||||
});
|
||||
};
|
||||
|
||||
if (permissions === "read") {
|
||||
if (file) return <BsDownload onClick={downloadAsset} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (file) {
|
||||
if (complete) {
|
||||
return (
|
||||
<>
|
||||
<BsDownload onClick={downloadAsset} />
|
||||
<BsArrowRepeat onClick={() => fileInputReplaceRef.current?.click()} />
|
||||
<BsTrash onClick={deleteAsset} />
|
||||
{renderFileInput((e: Event) => handleFileChange(e, "patch"), fileInputReplaceRef)}
|
||||
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="loading loading-infinity w-8" />;
|
||||
}
|
||||
|
||||
return permissions === "write" ? (
|
||||
<>
|
||||
<BsUpload onClick={() => fileInputRef.current?.click()} />
|
||||
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
|
||||
</>
|
||||
) : (
|
||||
<BsXCircleFill />
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentAssetManager;
|
||||
@@ -99,7 +99,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
)}
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
||||
{["admin", "developer", "agent"].includes(userType || "") && (
|
||||
{["admin", "developer", "agent", "corporate"].includes(userType || "") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
@@ -144,7 +144,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0 absolute bottom-12">
|
||||
<div className="flex flex-col gap-0 bottom-12 fixed">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
|
||||
@@ -28,9 +28,9 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"rounded-full hover:text-white hover:bg-mti-red transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-red-light",
|
||||
"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}
|
||||
{solution?.solution}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
||||
Correct
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
||||
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
|
||||
@@ -6,6 +6,8 @@ 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 clsx from "clsx";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
|
||||
@@ -47,7 +49,7 @@ export default function InteractiveSpeaking({
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-bold">You should talk about the following things:</span>
|
||||
<div className="grid grid-cols-3 gap-6 text-center">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 text-center">
|
||||
{prompts.map((x, index) => (
|
||||
<div className="italic flex flex-col gap-2 text-sm" key={index}>
|
||||
<video key={index} controls className="">
|
||||
@@ -61,11 +63,11 @@ export default function InteractiveSpeaking({
|
||||
</div>
|
||||
|
||||
<div className="w-full h-full flex flex-col gap-8">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{solutionsURL.map((x, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-full min-w-[460px] p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||
className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
</div>
|
||||
@@ -73,7 +75,7 @@ export default function InteractiveSpeaking({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{userSolutions && userSolutions.length > 0 && (
|
||||
{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) => (
|
||||
@@ -82,9 +84,81 @@ export default function InteractiveSpeaking({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
|
||||
{userSolutions[0].evaluation!.comment}
|
||||
</div>
|
||||
{userSolutions[0].evaluation &&
|
||||
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? (
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||
<Tab
|
||||
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",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Evaluation
|
||||
</Tab>
|
||||
<Tab
|
||||
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",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Recommended Answer (Prompt 1)
|
||||
</Tab>
|
||||
<Tab
|
||||
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",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Recommended Answer (Prompt 2)
|
||||
</Tab>
|
||||
<Tab
|
||||
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",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Recommended Answer (Prompt 3)
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||
{userSolutions[0].evaluation!.perfect_answer_1!.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||
{userSolutions[0].evaluation!.perfect_answer_2!.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||
{userSolutions[0].evaluation!.perfect_answer_3!.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
) : (
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-speaking/10 rounded-3xl">
|
||||
{userSolutions[0].evaluation!.comment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function MatchSentencesSolutions({
|
||||
className={clsx(
|
||||
"w-8 h-8 rounded-full z-10 text-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
!userSolutions.find((x) => x.question.toString() === id.toString()) && "!bg-mti-red",
|
||||
!userSolutions.find((x) => x.question.toString() === id.toString()) && "!bg-mti-gray-davy",
|
||||
userSolutions.find((x) => x.question.toString() === id.toString())?.option === solution && "bg-mti-purple",
|
||||
userSolutions.find((x) => x.question.toString() === id.toString())?.option !== solution && "bg-mti-rose",
|
||||
)}>
|
||||
@@ -96,7 +96,7 @@ export default function MatchSentencesSolutions({
|
||||
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-red" /> Unanswered
|
||||
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" /> Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong
|
||||
|
||||
@@ -14,7 +14,7 @@ function Question({
|
||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||
const optionColor = (option: string) => {
|
||||
if (option === solution && !userSolution) {
|
||||
return "!border-mti-red-light !text-mti-red-light";
|
||||
return "!border-mti-gray-davy !text-mti-gray-davy";
|
||||
}
|
||||
|
||||
if (option === solution) {
|
||||
@@ -114,7 +114,7 @@ export default function MultipleChoice({
|
||||
Correct
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
||||
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
|
||||
@@ -6,6 +6,8 @@ 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 clsx from "clsx";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
|
||||
@@ -69,7 +71,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
|
||||
</div>
|
||||
</div>
|
||||
{userSolutions && userSolutions.length > 0 && (
|
||||
{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) => (
|
||||
@@ -78,9 +80,52 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
|
||||
{userSolutions[0].evaluation!.comment}
|
||||
</div>
|
||||
{userSolutions[0].evaluation &&
|
||||
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||
<Tab
|
||||
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",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Evaluation
|
||||
</Tab>
|
||||
<Tab
|
||||
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",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Recommended Answer
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||
{userSolutions[0].evaluation!.perfect_answer &&
|
||||
userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
{userSolutions[0].evaluation!.perfect_answer_1 &&
|
||||
userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
) : (
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-speaking/10 rounded-3xl">
|
||||
{userSolutions[0].evaluation!.comment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
return "rose";
|
||||
}
|
||||
|
||||
return "red";
|
||||
return "gray";
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -67,6 +67,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
{userSolutions &&
|
||||
questions.map((question, index) => {
|
||||
const userSolution = userSolutions.find((x) => x.id === question.id.toString());
|
||||
const solution = question.solution.toString().toLowerCase() as Solution;
|
||||
|
||||
return (
|
||||
<div key={question.id.toString()} className="flex flex-col gap-4">
|
||||
@@ -75,23 +76,23 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant={question.solution === "true" || userSolution?.solution === "true" ? "solid" : "outline"}
|
||||
variant={solution === "true" || userSolution?.solution.toLowerCase() === "true" ? "solid" : "outline"}
|
||||
className="!py-2"
|
||||
color={getButtonColor("true", question.solution, userSolution?.solution)}>
|
||||
color={getButtonColor("true", solution, userSolution?.solution.toLowerCase() as Solution)}>
|
||||
True
|
||||
</Button>
|
||||
<Button
|
||||
variant={question.solution === "false" || userSolution?.solution === "false" ? "solid" : "outline"}
|
||||
variant={solution === "false" || userSolution?.solution.toLowerCase() === "false" ? "solid" : "outline"}
|
||||
className="!py-2"
|
||||
color={getButtonColor("false", question.solution, userSolution?.solution)}>
|
||||
color={getButtonColor("false", solution, userSolution?.solution.toLowerCase() as Solution)}>
|
||||
False
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
question.solution === "not_given" || userSolution?.solution === "not_given" ? "solid" : "outline"
|
||||
solution === "not_given" || userSolution?.solution.toLowerCase() === "not_given" ? "solid" : "outline"
|
||||
}
|
||||
className="!py-2"
|
||||
color={getButtonColor("not_given", question.solution, userSolution?.solution)}>
|
||||
color={getButtonColor("not_given", solution, userSolution?.solution.toLowerCase() as Solution)}>
|
||||
Not Given
|
||||
</Button>
|
||||
</div>
|
||||
@@ -105,7 +106,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
Correct
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
||||
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
|
||||
@@ -38,7 +38,7 @@ function Blank({
|
||||
|
||||
const getSolutionStyling = () => {
|
||||
if (!userSolution) {
|
||||
return "bg-mti-red-ultralight text-mti-red-light";
|
||||
return "bg-mti-gray-davy text-white";
|
||||
}
|
||||
|
||||
return "bg-mti-purple-ultralight text-mti-purple-light";
|
||||
@@ -131,7 +131,7 @@ export default function WriteBlanksSolutions({
|
||||
Correct
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-4 h-4 rounded-full bg-mti-red" />
|
||||
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
|
||||
Unanswered
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {WritingExercise} from "@/interfaces/exam";
|
||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
import {CommonProps} from ".";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "../Low/Button";
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import {Dialog, Tab, Transition} from "@headlessui/react";
|
||||
import {writingReverseMarking} from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||
|
||||
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -68,18 +66,55 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
</div>
|
||||
|
||||
<div className="w-full h-full flex flex-col gap-8">
|
||||
{userSolutions && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<span>Your answer:</span>
|
||||
<textarea
|
||||
className="w-full h-full min-h-[320px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||
contentEditable={false}
|
||||
readOnly
|
||||
value={userSolutions[0]!.solution}
|
||||
/>
|
||||
{userSolutions && userSolutions.length > 0 && (
|
||||
<div className="flex flex-col gap-4 w-full relative">
|
||||
{!showDiff && (
|
||||
<>
|
||||
<span>Your answer:</span>
|
||||
<div className="w-full h-full min-h-[320px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl whitespace-pre-wrap">
|
||||
{userSolutions[0]!.solution.replaceAll("\\n", "\n")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showDiff && (
|
||||
<>
|
||||
<span>Correction:</span>
|
||||
<div className="w-full h-full max-h-[320px] overflow-y-scroll scrollbar-hide cursor-text border-2 overflow-x-hidden border-mti-gray-platinum bg-white rounded-3xl">
|
||||
<ReactDiffViewer
|
||||
styles={{
|
||||
contentText: {
|
||||
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"},
|
||||
|
||||
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")}
|
||||
splitView
|
||||
hideLineNumbers
|
||||
showDiffOnly={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{userSolutions[0].solution && userSolutions[0].evaluation?.fixed_text && (
|
||||
<Button
|
||||
color="green"
|
||||
variant="outline"
|
||||
className="w-full max-w-[200px] self-end absolute -top-4 right-0 !py-2"
|
||||
onClick={() => setShowDiff((prev) => !prev)}>
|
||||
{showDiff ? "View answer" : "View correction"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{userSolutions && userSolutions.length > 0 && (
|
||||
{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) => (
|
||||
@@ -88,9 +123,48 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
|
||||
{userSolutions[0].evaluation!.comment}
|
||||
</div>
|
||||
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
||||
<Tab
|
||||
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",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||
)
|
||||
}>
|
||||
Evaluation
|
||||
</Tab>
|
||||
<Tab
|
||||
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",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||
)
|
||||
}>
|
||||
Recommended Answer
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
) : (
|
||||
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-writing/10 rounded-3xl">
|
||||
{userSolutions[0].evaluation!.comment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -30,20 +30,28 @@ export interface CommonProps {
|
||||
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void) => {
|
||||
switch (exercise.type) {
|
||||
case "fillBlanks":
|
||||
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "trueFalse":
|
||||
return <TrueFalseSolution {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <TrueFalseSolution key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "matchSentences":
|
||||
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "multipleChoice":
|
||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} updateIndex={updateIndex} onNext={onNext} onBack={onBack} />;
|
||||
return (
|
||||
<MultipleChoice
|
||||
key={exercise.id}
|
||||
{...(exercise as MultipleChoiceExercise)}
|
||||
updateIndex={updateIndex}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
case "writeBlanks":
|
||||
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "writing":
|
||||
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "speaking":
|
||||
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "interactiveSpeaking":
|
||||
return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <InteractiveSpeaking key={exercise.id} {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -36,31 +36,55 @@ interface Props {
|
||||
onViewStudents?: () => void;
|
||||
onViewTeachers?: () => void;
|
||||
onViewCorporate?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate}: Props) => {
|
||||
const USER_STATUS_OPTIONS = [
|
||||
{
|
||||
value: "active",
|
||||
label: "Active",
|
||||
},
|
||||
{
|
||||
value: "disabled",
|
||||
label: "Disabled",
|
||||
},
|
||||
{
|
||||
value: "paymentDue",
|
||||
label: "Payment Due",
|
||||
},
|
||||
];
|
||||
|
||||
const USER_TYPE_OPTIONS = Object.keys(USER_TYPE_LABELS).map((type) => ({
|
||||
value: type,
|
||||
label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS],
|
||||
}));
|
||||
|
||||
const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({value: currency, label}));
|
||||
|
||||
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false}: Props) => {
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
||||
const [type, setType] = useState(user.type);
|
||||
const [status, setStatus] = useState(user.status);
|
||||
const [referralAgentLabel, setReferralAgentLabel] = useState<string>();
|
||||
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
||||
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
||||
|
||||
const [referralAgent, setReferralAgent] = useState(user.type === "corporate" ? user.corporateInformation?.referralAgent : undefined);
|
||||
const [companyName, setCompanyName] = useState(
|
||||
user.type === "corporate"
|
||||
? user.corporateInformation?.companyInformation.name
|
||||
: user.type === "agent"
|
||||
? user.agentInformation.companyName
|
||||
? user.agentInformation?.companyName
|
||||
: undefined,
|
||||
);
|
||||
const [commercialRegistration, setCommercialRegistration] = useState(
|
||||
user.type === "agent" ? user.agentInformation.commercialRegistration : undefined,
|
||||
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
||||
);
|
||||
const [userAmount, setUserAmount] = useState(user.type === "corporate" ? user.corporateInformation?.companyInformation.userAmount : undefined);
|
||||
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
|
||||
const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : undefined);
|
||||
const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : "EUR");
|
||||
const [monthlyDuration, setMonthlyDuration] = useState(user.type === "corporate" ? user.corporateInformation?.monthlyDuration : undefined);
|
||||
|
||||
const [commissionValue, setCommission] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.commission : undefined);
|
||||
const {stats} = useStats(user.id);
|
||||
const {users} = useUsers();
|
||||
|
||||
@@ -90,7 +114,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
agentInformation:
|
||||
type === "agent"
|
||||
? {
|
||||
companyName,
|
||||
name: companyName,
|
||||
commercialRegistration,
|
||||
}
|
||||
: undefined,
|
||||
@@ -100,12 +124,13 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
referralAgent,
|
||||
monthlyDuration,
|
||||
companyInformation: {
|
||||
companyName,
|
||||
name: companyName,
|
||||
userAmount,
|
||||
},
|
||||
payment: {
|
||||
value: paymentValue,
|
||||
currency: paymentCurrency,
|
||||
...(referralAgent === "" ? {} : {commission: commissionValue}),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
@@ -146,22 +171,24 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
||||
<Input
|
||||
label="Company Name"
|
||||
label="Corporate Name"
|
||||
type="text"
|
||||
name="companyName"
|
||||
onChange={setCompanyName}
|
||||
placeholder="Enter company name"
|
||||
placeholder="Enter corporate name"
|
||||
defaultValue={companyName}
|
||||
required
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
label="Commercial Registration"
|
||||
type="text"
|
||||
name="commercialRegistration"
|
||||
onChange={setCommercialRegistration}
|
||||
placeholder="Enter company name"
|
||||
placeholder="Enter commercial registration"
|
||||
defaultValue={commercialRegistration}
|
||||
required
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Divider className="w-full !m-0" />
|
||||
@@ -171,12 +198,13 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
|
||||
<Input
|
||||
label="Company Name"
|
||||
label="Corporate Name"
|
||||
type="text"
|
||||
name="companyName"
|
||||
onChange={setCompanyName}
|
||||
placeholder="Enter company name"
|
||||
placeholder="Enter corporate name"
|
||||
defaultValue={companyName}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
label="Number of Users"
|
||||
@@ -185,6 +213,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
onChange={(e) => setUserAmount(e ? parseInt(e) : undefined)}
|
||||
placeholder="Enter number of users"
|
||||
defaultValue={userAmount}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
label="Monthly Duration"
|
||||
@@ -193,13 +222,58 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
onChange={(e) => setMonthlyDuration(e ? parseInt(e) : undefined)}
|
||||
placeholder="Enter monthly duration"
|
||||
defaultValue={monthlyDuration}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<div className="flex flex-col gap-3 w-full lg:col-span-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
|
||||
<div className="w-full grid grid-cols-5 gap-2">
|
||||
<Input
|
||||
name="paymentValue"
|
||||
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
|
||||
type="number"
|
||||
defaultValue={paymentValue || 0}
|
||||
className="col-span-3"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
className={clsx(
|
||||
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
||||
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||
)}
|
||||
options={CURRENCIES_OPTIONS}
|
||||
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
|
||||
onChange={(value) => setPaymentCurrency(value?.value)}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 w-full">
|
||||
<div className="flex flex-col gap-3 w-8/12">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
|
||||
{referralAgentLabel && (
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
className={clsx(
|
||||
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
||||
!["developer", "admin"].includes(loggedInUser.type) &&
|
||||
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||
)}
|
||||
options={[
|
||||
{value: "", label: "No referral"},
|
||||
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
|
||||
@@ -225,31 +299,27 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
// editing country manager should only be available for dev/admin
|
||||
isDisabled={!["developer", "admin"].includes(loggedInUser.type)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full lg:col-span-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
|
||||
<div className="w-full grid grid-cols-5 gap-2">
|
||||
<Input
|
||||
name="paymentValue"
|
||||
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
|
||||
type="number"
|
||||
defaultValue={paymentValue || 0}
|
||||
className="col-span-3"
|
||||
/>
|
||||
<select
|
||||
defaultValue={paymentCurrency}
|
||||
onChange={(e) => setPaymentCurrency(e.target.value)}
|
||||
className="p-6 col-span-2 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||
{CURRENCIES.map(({label, currency}) => (
|
||||
<option value={currency} key={currency}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-4/12">
|
||||
{referralAgent !== "" && loggedInUser.type !== "corporate" ? (
|
||||
<>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Commission</label>
|
||||
<Input
|
||||
name="commissionValue"
|
||||
onChange={(e) => setCommission(e ? parseInt(e) : undefined)}
|
||||
type="number"
|
||||
defaultValue={commissionValue || 0}
|
||||
className="col-span-3"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="w-full !m-0" />
|
||||
@@ -293,13 +363,27 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
/>
|
||||
</div>
|
||||
|
||||
{user.type === "student" && (
|
||||
<Input
|
||||
type="text"
|
||||
name="passport_id"
|
||||
label="Passport/National ID"
|
||||
onChange={() => null}
|
||||
placeholder="Enter National ID or Passport number"
|
||||
value={user.type === "student" ? user.demographicInformation?.passport_id : undefined}
|
||||
disabled
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||
{user.type !== "corporate" && (
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
|
||||
<RadioGroup
|
||||
value={user.demographicInformation?.employment}
|
||||
className="grid grid-cols-2 items-center gap-4 place-items-center">
|
||||
className="grid grid-cols-2 items-center gap-4 place-items-center"
|
||||
disabled={disabled}>
|
||||
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
||||
<RadioGroup.Option value={status} key={status}>
|
||||
{({checked}) => (
|
||||
@@ -334,7 +418,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
|
||||
<RadioGroup value={user.demographicInformation?.gender} className="flex flex-row gap-4 justify-between">
|
||||
<RadioGroup
|
||||
value={user.demographicInformation?.gender}
|
||||
className="flex flex-row gap-4 justify-between"
|
||||
disabled={disabled}>
|
||||
<RadioGroup.Option value="male">
|
||||
{({checked}) => (
|
||||
<span
|
||||
@@ -384,7 +471,8 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
||||
<Checkbox
|
||||
isChecked={!!expiryDate}
|
||||
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}>
|
||||
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
|
||||
disabled={disabled}>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
@@ -417,6 +505,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={moment(expiryDate).toDate()}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -428,27 +517,55 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Status</label>
|
||||
<select
|
||||
defaultValue={user.status}
|
||||
onChange={(e) => setStatus(e.target.value as typeof user.status)}
|
||||
className="p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||
<option value="active">Active</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="paymentDue">Payment Due</option>
|
||||
</select>
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={USER_STATUS_OPTIONS}
|
||||
value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
|
||||
onChange={(value) => setStatus(value?.value as typeof user.status)}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
||||
<select
|
||||
defaultValue={user.type}
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="p-6 w-full 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).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={USER_TYPE_OPTIONS}
|
||||
value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
|
||||
onChange={(value) => setType(value?.value as typeof user.type)}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -477,7 +594,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
||||
<Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={updateUser} className="w-full max-w-[200px]">
|
||||
<Button disabled={disabled} onClick={updateUser} className="w-full max-w-[200px]">
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,96 +2,119 @@ import {Module} from "@/interfaces";
|
||||
|
||||
export const MODULES: Module[] = ["reading", "listening", "writing", "speaking"];
|
||||
|
||||
export const BAND_SCORES: {[key in Module]: number[]} = {
|
||||
reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||
listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||
writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
level: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
};
|
||||
// BAND SCORES is not in use anymore and level scoring is made based on thresholds
|
||||
// export const BAND_SCORES: {[key in Module]: number[]} = {
|
||||
// reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||
// listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
|
||||
// writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
// speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
// level: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
// };
|
||||
|
||||
export const moduleResultText = (level: number) => {
|
||||
if (level === 9) {
|
||||
return (
|
||||
<>
|
||||
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating
|
||||
excellent mastery of the assessed knowledge.
|
||||
<br />
|
||||
<br />
|
||||
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
|
||||
the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
|
||||
academic journey.
|
||||
</>
|
||||
);
|
||||
}
|
||||
export type LevelScore = "Advanced" | "Upper-Intermediate" | "Intermediate" | "Pre-Intermediate" | "Elementary" | "Beginner";
|
||||
|
||||
if (level >= 6) {
|
||||
return (
|
||||
<>
|
||||
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
|
||||
good understanding of the assessed knowledge.
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
||||
journey.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (level >= 3) {
|
||||
return (
|
||||
<>
|
||||
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a
|
||||
satisfactory understanding of the assessed knowledge.
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
||||
journey.
|
||||
</>
|
||||
);
|
||||
}
|
||||
const generateHighestScoreText = () : React.ReactNode => (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
|
||||
the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
|
||||
academic journey.
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the
|
||||
required standards.
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
|
||||
endeavors.
|
||||
</>
|
||||
);
|
||||
};
|
||||
const generateAverageScoreText = () : React.ReactNode => (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
||||
journey.
|
||||
</>
|
||||
);
|
||||
|
||||
const generateLowestScoreText = () : React.ReactNode => (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
|
||||
endeavors.
|
||||
</>
|
||||
)
|
||||
|
||||
export const levelResultText = (level: number) => {
|
||||
if(level === 9) {
|
||||
return (
|
||||
<>
|
||||
{"Outstanding! Your command of English is excellent. Focus on fine-tuning subtle language nuances and exploring sophisticated vocabulary. Keep up the excellent work!"}
|
||||
{generateHighestScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if(level >= 8) {
|
||||
return (
|
||||
<>
|
||||
{"Impressive! You're approaching fluency. Continue refining nuances in grammar and expanding your vocabulary to express ideas more precisely."}
|
||||
{generateAverageScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if(level >= 6) {
|
||||
return (
|
||||
<>
|
||||
{"Great job! You're navigating the complexities of English. Keep honing your grammar skills and exploring more advanced vocabulary."}
|
||||
{generateAverageScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if(level >= 4) {
|
||||
return (
|
||||
<>
|
||||
{"Well done! You're moving beyond the basics. Work on expanding your vocabulary and refining your understanding of grammar structures."}
|
||||
{generateAverageScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if(level >= 2) {
|
||||
return (
|
||||
<>
|
||||
{"Good effort! You're making progress. Continue studying and pay attention to common vocabulary and fundamental grammar rules."}
|
||||
{generateAverageScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if(level >= 0) {
|
||||
return (
|
||||
<>
|
||||
{"Keep practicing! You're just starting, and improvement takes time. Focus on building your vocabulary and basic grammar skills."}
|
||||
{generateLowestScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
export const moduleResultText = (module: Module, level: number) => {
|
||||
if(module === 'level') return levelResultText(level);
|
||||
if (level === 9) {
|
||||
return (
|
||||
<>
|
||||
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating
|
||||
excellent mastery of the assessed knowledge.
|
||||
<br />
|
||||
<br />
|
||||
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
|
||||
the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
|
||||
academic journey.
|
||||
{generateHighestScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -101,14 +124,7 @@ export const levelResultText = (level: number) => {
|
||||
<>
|
||||
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
|
||||
good understanding of the assessed knowledge.
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
||||
journey.
|
||||
{generateAverageScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -118,14 +134,7 @@ export const levelResultText = (level: number) => {
|
||||
<>
|
||||
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a
|
||||
satisfactory understanding of the assessed knowledge.
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
|
||||
journey.
|
||||
{generateAverageScoreText()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -134,14 +143,7 @@ export const levelResultText = (level: number) => {
|
||||
<>
|
||||
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the
|
||||
required standards.
|
||||
<br />
|
||||
<br />
|
||||
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
|
||||
transparency of the results.
|
||||
<br />
|
||||
<br />
|
||||
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
|
||||
endeavors.
|
||||
{generateLowestScoreText()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -7,21 +7,13 @@ import UserList from "@/pages/(admin)/Lists/UserList";
|
||||
import {dateSorter} from "@/utils";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import {
|
||||
BsArrowLeft,
|
||||
BsBriefcaseFill,
|
||||
BsGlobeCentralSouthAsia,
|
||||
BsPerson,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPersonLinesFill,
|
||||
} from "react-icons/bs";
|
||||
import {BsArrowLeft, BsBriefcaseFill, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPencilSquare, BsBank, BsCurrencyDollar} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import IconCard from "./IconCard";
|
||||
import useFilterStore from "@/stores/listFilterStore";
|
||||
import {useRouter} from "next/router";
|
||||
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -35,6 +27,7 @@ export default function AdminDashboard({user}: Props) {
|
||||
const {stats} = useStats(user.id);
|
||||
const {users, reload} = useUsers();
|
||||
const {groups} = useGroups();
|
||||
const { pending, done } = usePaymentStatusUsers();
|
||||
|
||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||
const router = useRouter();
|
||||
@@ -43,6 +36,12 @@ export default function AdminDashboard({user}: Props) {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(reload, [page]);
|
||||
|
||||
const inactiveCountryManagerFilter = (x: User) =>
|
||||
x.type === "agent" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||
|
||||
const UserDisplay = (displayUser: User) => (
|
||||
<div
|
||||
onClick={() => setSelectedUser(displayUser)}
|
||||
@@ -149,6 +148,44 @@ export default function AdminDashboard({user}: Props) {
|
||||
</>
|
||||
);
|
||||
|
||||
const CorporatePaidStatusList = ({ paid }: {paid: Boolean}) => {
|
||||
const list = paid ? done : pending;
|
||||
const filter = (x: User) => x.type === "corporate" && list.includes(x.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">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2>
|
||||
</div>
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InactiveCountryManagerList = () => {
|
||||
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">Inactive Country Managers ({users.filter(inactiveCountryManagerFilter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[inactiveCountryManagerFilter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InactiveStudentsList = () => {
|
||||
const filter = (x: User) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||
|
||||
@@ -191,7 +228,7 @@ export default function AdminDashboard({user}: Props) {
|
||||
|
||||
const DefaultDashboard = () => (
|
||||
<>
|
||||
<section className="w-full flex flex-wrap gap-4 items-center justify-between">
|
||||
<section className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 place-items-center items-center justify-between">
|
||||
<IconCard
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
@@ -200,14 +237,14 @@ export default function AdminDashboard({user}: Props) {
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPersonLinesFill}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={users.filter((x) => x.type === "teacher").length}
|
||||
onClick={() => setPage("teachers")}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPersonLinesFill}
|
||||
Icon={BsBank}
|
||||
label="Corporate"
|
||||
value={users.filter((x) => x.type === "corporate").length}
|
||||
onClick={() => setPage("corporate")}
|
||||
@@ -228,7 +265,7 @@ export default function AdminDashboard({user}: Props) {
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("inactiveStudents")}
|
||||
Icon={BsPerson}
|
||||
Icon={BsPersonFill}
|
||||
label="Inactive Students"
|
||||
value={
|
||||
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
||||
@@ -236,9 +273,16 @@ export default function AdminDashboard({user}: Props) {
|
||||
}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("inactiveCountryManagers")}
|
||||
Icon={BsBriefcaseFill}
|
||||
label="Inactive Country Managers"
|
||||
value={users.filter(inactiveCountryManagerFilter).length}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("inactiveCorporate")}
|
||||
Icon={BsPerson}
|
||||
Icon={BsBank}
|
||||
label="Inactive Corporate"
|
||||
value={
|
||||
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
||||
@@ -246,6 +290,20 @@ export default function AdminDashboard({user}: Props) {
|
||||
}
|
||||
color="rose"
|
||||
/>
|
||||
<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 lg:grid-cols-3 gap-4 w-full justify-between">
|
||||
@@ -298,7 +356,7 @@ export default function AdminDashboard({user}: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Teachers expiring in 1 month</span>
|
||||
<span className="p-4">Country Manager expiring in 1 month</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
@@ -342,7 +400,7 @@ export default function AdminDashboard({user}: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Expired Teachers</span>
|
||||
<span className="p-4">Expired Country Manager</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
@@ -454,6 +512,9 @@ export default function AdminDashboard({user}: Props) {
|
||||
{page === "agents" && <AgentsList />}
|
||||
{page === "inactiveStudents" && <InactiveStudentsList />}
|
||||
{page === "inactiveCorporate" && <InactiveCorporateList />}
|
||||
{page === "inactiveCountryManagers" && <InactiveCountryManagerList />}
|
||||
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
|
||||
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
|
||||
{page === "" && <DefaultDashboard />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,33 +2,17 @@
|
||||
import Modal from "@/components/Modal";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Group, Stat, User} from "@/interfaces/user";
|
||||
import { User} from "@/interfaces/user";
|
||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||
import {dateSorter} from "@/utils";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import {
|
||||
BsArrowLeft,
|
||||
BsClipboard2Data,
|
||||
BsClipboard2DataFill,
|
||||
BsClock,
|
||||
BsGlobeCentralSouthAsia,
|
||||
BsPaperclip,
|
||||
BsPerson,
|
||||
BsPersonAdd,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPersonLinesFill,
|
||||
} from "react-icons/bs";
|
||||
import {BsArrowLeft, BsPersonFill, BsBank, BsCurrencyDollar} 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 IconCard from "./IconCard";
|
||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -42,6 +26,7 @@ export default function AgentDashboard({user}: Props) {
|
||||
const {stats} = useStats();
|
||||
const {users, reload} = useUsers();
|
||||
const {groups} = useGroups(user.id);
|
||||
const { pending, done } = usePaymentStatusUsers();
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
@@ -50,10 +35,12 @@ export default function AgentDashboard({user}: Props) {
|
||||
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: User) => (
|
||||
const UserDisplay = ({ displayUser, allowClick = true }: {displayUser: User, allowClick?: boolean}) => (
|
||||
<div
|
||||
onClick={() => setSelectedUser(displayUser)}
|
||||
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">
|
||||
@@ -68,8 +55,6 @@ export default function AgentDashboard({user}: Props) {
|
||||
);
|
||||
|
||||
const ReferredCorporateList = () => {
|
||||
const filter = (x: User) => x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -79,10 +64,28 @@ export default function AgentDashboard({user}: Props) {
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(filter).length})</h2>
|
||||
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(referredCorporateFilter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[filter]} />
|
||||
<UserList user={user} filters={[referredCorporateFilter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InactiveReferredCorporateList = () => {
|
||||
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">Inactive Referred Corporate ({users.filter(inactiveReferredCorporateFilter).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={[inactiveReferredCorporateFilter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -99,9 +102,28 @@ export default function AgentDashboard({user}: Props) {
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(filter).length})</h2>
|
||||
<h2 className="text-2xl font-semibold">Corporate ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CorporatePaidStatusList = ({ paid }: {paid: Boolean}) => {
|
||||
const list = paid ? done : pending;
|
||||
const filter = (x: User) => x.type === "corporate" && list.includes(x.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">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2>
|
||||
</div>
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
@@ -112,18 +134,39 @@ export default function AgentDashboard({user}: Props) {
|
||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => setPage("referredCorporate")}
|
||||
Icon={BsPersonFill}
|
||||
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={BsPersonFill}
|
||||
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">
|
||||
@@ -134,7 +177,7 @@ export default function AgentDashboard({user}: Props) {
|
||||
.filter(referredCorporateFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
<UserDisplay key={x.id} displayUser={x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,7 +188,22 @@ export default function AgentDashboard({user}: Props) {
|
||||
.filter(corporateFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...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>
|
||||
@@ -177,6 +235,9 @@ export default function AgentDashboard({user}: Props) {
|
||||
</Modal>
|
||||
{page === "referredCorporate" && <ReferredCorporateList />}
|
||||
{page === "corporate" && <CorporateList />}
|
||||
{page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
|
||||
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
|
||||
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
|
||||
{page === "" && <DefaultDashboard />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,19 +2,20 @@ import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import {Stat} from "@/interfaces/user";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import {useState} from "react";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
||||
|
||||
interface Props {
|
||||
onClick?: () => void;
|
||||
allowDownload?: boolean;
|
||||
}
|
||||
|
||||
export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick}: Assignment & Props) {
|
||||
export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick, allowDownload}: Assignment & Props) {
|
||||
const {users} = useUsers();
|
||||
const renderPdfIcon = usePDFDownload("assignments");
|
||||
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
const resultModuleBandScores = results.map((r) => {
|
||||
@@ -33,7 +34,10 @@ export default function AssignmentCard({id, name, assigner, startDate, endDate,
|
||||
onClick={onClick}
|
||||
className="w-[350px] h-fit flex flex-col gap-6 bg-white border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="font-semibold text-xl">{name}</h3>
|
||||
<div className="flex flex-row justify-between">
|
||||
<h3 className="font-semibold text-xl">{name}</h3>
|
||||
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
</div>
|
||||
<ProgressBar
|
||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||
percentage={(results.length / assignees.length) * 100}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {getExam} from "@/utils/exams";
|
||||
import {toast} from "react-toastify";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
|
||||
interface Props {
|
||||
isCreating: boolean;
|
||||
@@ -33,8 +34,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
|
||||
const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize}));
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "day").toDate());
|
||||
const [endDate, setEndDate] = useState<Date | null>(assignment ? moment(assignment.endDate).toDate() : moment().add(8, "day").toDate());
|
||||
const [startDate, setStartDate] = useState<Date | null>(
|
||||
assignment ? moment(assignment.startDate).toDate() : moment().hours(0).minutes(0).add(1, "day").toDate(),
|
||||
);
|
||||
const [endDate, setEndDate] = useState<Date | null>(
|
||||
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
|
||||
);
|
||||
// creates a new exam for each assignee or just one exam for all assignees
|
||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||
|
||||
const toggleModule = (module: Module) => {
|
||||
const modules = selectedModules.filter((x) => x !== module);
|
||||
@@ -48,33 +55,23 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
const createAssignment = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const examPromises = selectedModules.map(async (module) => getExam(module, false));
|
||||
Promise.all(examPromises)
|
||||
.then((exams) => {
|
||||
(assignment ? axios.patch : axios.post)(`/api/assignments${assignment ? `/${assignment.id}` : ""}`, {
|
||||
assigner,
|
||||
assignees,
|
||||
name,
|
||||
startDate,
|
||||
endDate,
|
||||
results: [],
|
||||
exams: exams.map((e) => ({module: e?.module, id: e?.id})),
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
|
||||
cancelCreation();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
(assignment ? axios.patch : axios.post)(`/api/assignments${assignment ? `/${assignment.id}` : ""}`, {
|
||||
assignees,
|
||||
name,
|
||||
startDate,
|
||||
endDate,
|
||||
selectedModules,
|
||||
generateMultiple,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
|
||||
cancelCreation();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
setIsLoading(false);
|
||||
});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const deleteAssignment = () => {
|
||||
@@ -284,6 +281,11 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex gap-4 w-full justify-end">
|
||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
||||
Generate different exams
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className="flex gap-4 w-full justify-end">
|
||||
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
|
||||
Cancel
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPersonLinesFill,
|
||||
BsPencilSquare,
|
||||
} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
@@ -170,7 +170,7 @@ export default function CorporateDashboard({user}: Props) {
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("teachers")}
|
||||
Icon={BsPersonLinesFill}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={users.filter(teacherFilter).length}
|
||||
color="purple"
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function IconCard({Icon, label, value, color, onClick}: Props) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||
<Icon className={clsx("text-6xl", colorClasses[color])} />
|
||||
<span className="flex flex-col gap-1 items-center text-xl">
|
||||
<span className="text-lg">{label}</span>
|
||||
|
||||
@@ -5,9 +5,10 @@ import ProfileSummary from "@/components/ProfileSummary";
|
||||
import useAssignments from "@/hooks/useAssignments";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {CorporateUser, User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {getUserCorporate} from "@/utils/groups";
|
||||
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
||||
import {averageScore, groupBySession} from "@/utils/stats";
|
||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
||||
@@ -18,6 +19,7 @@ import {capitalize} from "lodash";
|
||||
import moment from "moment";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
@@ -26,6 +28,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function StudentDashboard({user}: Props) {
|
||||
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
||||
|
||||
const {stats} = useStats(user.id);
|
||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
||||
|
||||
@@ -37,8 +41,12 @@ export default function StudentDashboard({user}: Props) {
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
const setAssignment = useExamStore((state) => state.setAssignment);
|
||||
|
||||
useEffect(() => {
|
||||
getUserCorporate("IXdh9EQziAVXXh0jOiC5cPVlgS82").then(setCorporateUserToShow);
|
||||
}, [user]);
|
||||
|
||||
const startAssignment = (assignment: Assignment) => {
|
||||
const examPromises = assignment.exams.map((e) => getExamById(e.module, e.id));
|
||||
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
@@ -60,6 +68,11 @@ export default function StudentDashboard({user}: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
@@ -121,6 +134,7 @@ export default function StudentDashboard({user}: Props) {
|
||||
<div className="flex justify-between w-full items-center">
|
||||
<div className="grid grid-cols-2 gap-2 place-items-center justify-center w-fit min-w-[104px] -md:mt-2">
|
||||
{assignment.exams
|
||||
.filter((e) => e.assignee === user.id)
|
||||
.map((e) => e.module)
|
||||
.sort(sortByModuleName)
|
||||
.map((module) => (
|
||||
@@ -190,11 +204,12 @@ export default function StudentDashboard({user}: Props) {
|
||||
{module === "listening" && <BsHeadphones className="text-ielts-listening w-4 h-4 md:w-5 md:h-5" />}
|
||||
{module === "writing" && <BsPen className="text-ielts-writing w-4 h-4 md:w-5 md:h-5" />}
|
||||
{module === "speaking" && <BsMegaphone className="text-ielts-speaking w-4 h-4 md:w-5 md:h-5" />}
|
||||
{module === "level" && <BsClipboard className="text-ielts-level w-4 h-4 md:w-5 md:h-5" />}
|
||||
</div>
|
||||
<div className="flex justify-between w-full">
|
||||
<span className="font-bold md:font-extrabold text-sm">{capitalize(module)}</span>
|
||||
<span className="text-sm font-normal text-mti-gray-dim">
|
||||
Level {user.levels[module]} / Level {user.desiredLevels[module]}
|
||||
Level {user.levels[module] || 0} / Level {user.desiredLevels[module] || 9}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Modal from "@/components/Modal";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {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 moment from "moment";
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPersonLinesFill,
|
||||
BsPlus,
|
||||
BsRepeat,
|
||||
BsRepeat1,
|
||||
@@ -45,6 +44,7 @@ import clsx from "clsx";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import AssignmentCreator from "./AssignmentCreator";
|
||||
import AssignmentView from "./AssignmentView";
|
||||
import {getUserCorporate} from "@/utils/groups";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -56,6 +56,7 @@ export default function TeacherDashboard({user}: Props) {
|
||||
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();
|
||||
@@ -66,6 +67,10 @@ export default function TeacherDashboard({user}: Props) {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
|
||||
useEffect(() => {
|
||||
getUserCorporate(user.id).then(setCorporateUserToShow);
|
||||
}, [user]);
|
||||
|
||||
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);
|
||||
@@ -227,7 +232,7 @@ export default function TeacherDashboard({user}: Props) {
|
||||
<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} />
|
||||
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -237,7 +242,16 @@ export default function TeacherDashboard({user}: Props) {
|
||||
|
||||
const DefaultDashboard = () => (
|
||||
<>
|
||||
<section className="flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center">
|
||||
{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}
|
||||
|
||||
@@ -40,3 +40,15 @@ export function prepareMailOptions(context: object, to: string[], subject: strin
|
||||
context,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendEmail(template: string, context: object, to: string[], subject: string): Promise<boolean> {
|
||||
try {
|
||||
const transport = prepareMailer(template);
|
||||
const mailOptions = prepareMailOptions(context, to, subject, template);
|
||||
|
||||
await transport.sendMail(mailOptions);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
28
src/email/templates/assignment.handlebars
Normal file
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<div>
|
||||
<p>Hello {{user.name}},</p>
|
||||
<br />
|
||||
<p>You have just been given the assignment <b>"{{assignment.name}}"</b> by your teacher {{assignment.assigner}}!</p>
|
||||
<br />
|
||||
<p>It's start date will be on <b>{{assignment.startDate}}</b> and will only last until <b>{{assignment.endDate}}</b>
|
||||
</p>
|
||||
<br />
|
||||
<p>For this assignment, you've been tasked with completing exams of the following modules:
|
||||
<b>{{assignment.modules}}</b>.
|
||||
</p>
|
||||
<br />
|
||||
<p>Don't forget to do it before its end date!</p>
|
||||
<p>Click <b><a href="https://platform.encoach.com">here</a></b> to open the EnCoach Platform!</p>
|
||||
<br />
|
||||
<p>Thanks,</p>
|
||||
<p>Your EnCoach team</p>
|
||||
</div>
|
||||
|
||||
</html>
|
||||
13
src/email/templates/assignment.handlebars.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"user": {
|
||||
"name": "Tiago Ribeiro"
|
||||
},
|
||||
"assignment": {
|
||||
"name": "Final Exam",
|
||||
"assigner": "Teacher",
|
||||
"assignees": [],
|
||||
"modules": "Reading and Writing",
|
||||
"startDate": "24/12/2023",
|
||||
"endDate": "27/01/2024"
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
|
||||
import {LevelScore} from "@/constants/ielts";
|
||||
import {getLevelScore} from "@/utils/score";
|
||||
|
||||
interface Score {
|
||||
module: Module;
|
||||
@@ -66,6 +68,22 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
return exam.exercises.length;
|
||||
};
|
||||
|
||||
const bandScore: number = calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus);
|
||||
|
||||
const showLevel = (level: number) => {
|
||||
if (selectedModule === "level") {
|
||||
const [levelStr, grade] = getLevelScore(level);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-1">
|
||||
<span className="text-xl font-bold">{levelStr}</span>
|
||||
<span className="text-xl">{grade}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-3xl font-bold">{level}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8">
|
||||
@@ -136,14 +154,16 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
{isLoading && (
|
||||
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-12 items-center">
|
||||
<span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
|
||||
<span className={clsx("font-bold text-2xl", moduleColors[selectedModule].progress)}>Evaluating your answers...</span>
|
||||
<span className={clsx("font-bold text-2xl text-center", moduleColors[selectedModule].progress)}>
|
||||
Evaluating your answers, please be patient...
|
||||
<br />
|
||||
You can also check it later on your records page!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<div className="w-full flex gap-9 mt-32 items-center justify-between mb-20">
|
||||
<span className="max-w-3xl">
|
||||
{moduleResultText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))}
|
||||
</span>
|
||||
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
|
||||
<div className="flex gap-9 px-16">
|
||||
<div
|
||||
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
|
||||
@@ -156,9 +176,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
moduleColors[selectedModule].inner,
|
||||
)}>
|
||||
<span className="text-xl">Level</span>
|
||||
<span className="text-3xl font-bold">
|
||||
{calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus)}
|
||||
</span>
|
||||
{showLevel(bandScore)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{exerciseIndex === -1 && (
|
||||
{exerciseIndex === -1 && partIndex === 0 && (
|
||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
||||
Start now
|
||||
</Button>
|
||||
|
||||
169
src/exams/pdf/details/level.exam.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet } from "@react-pdf/renderer";
|
||||
import { ModuleScore } from "@/interfaces/module.scores";
|
||||
import { styles } from "../styles";
|
||||
import { RadialResult } from "./radial.result";
|
||||
interface Props {
|
||||
detail: ModuleScore;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const thresholds = [
|
||||
{
|
||||
level: "Low A1",
|
||||
label: "Begginner",
|
||||
minValue: 0,
|
||||
maxValue: 3,
|
||||
},
|
||||
{
|
||||
level: "High A1/Low A2",
|
||||
label: "Elementary",
|
||||
minValue: 4,
|
||||
maxValue: 7,
|
||||
},
|
||||
{
|
||||
level: "High A2/Low B1",
|
||||
label: "Pre-Intermediate",
|
||||
minValue: 8,
|
||||
maxValue: 12,
|
||||
},
|
||||
{
|
||||
level: "High B2/Low C1",
|
||||
label: "Upper-Intermediate",
|
||||
minValue: 16,
|
||||
maxValue: 21,
|
||||
},
|
||||
{
|
||||
level: "C1",
|
||||
label: "Advanced",
|
||||
minValue: 22,
|
||||
maxValue: 25,
|
||||
},
|
||||
];
|
||||
|
||||
const customStyles = StyleSheet.create({
|
||||
container: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: 30,
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
tableContainer: {
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
},
|
||||
tableLabel: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
tableBody: { display: "flex", flex: 1, flexDirection: "row" },
|
||||
tableRow: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
});
|
||||
|
||||
export const LevelExamDetails = ({ detail, title }: Props) => {
|
||||
const updatedThresholds = thresholds.map((t) => ({
|
||||
...t,
|
||||
match: detail.score >= t.minValue && detail.score <= t.maxValue,
|
||||
}));
|
||||
|
||||
const getBackgroundColor = (match: boolean, base: boolean) => {
|
||||
if (match) return "#c2bfdd";
|
||||
return base ? "#553b25" : "#ea7c7b";
|
||||
};
|
||||
|
||||
const getTextColor = (match: boolean, base: boolean) => {
|
||||
if (match) return "#9e7936";
|
||||
return base ? "white" : "#553b25";
|
||||
};
|
||||
return (
|
||||
<View style={[styles.textFont, customStyles.container]}>
|
||||
<RadialResult {...detail} />
|
||||
<View style={customStyles.tableContainer}>
|
||||
<View style={customStyles.tableLabel}>
|
||||
<Text
|
||||
style={[styles.textBold, styles.textColor, { fontSize: "10px" }]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={customStyles.tableBody}>
|
||||
{updatedThresholds.map(
|
||||
({ level, label, minValue, maxValue, match }, index, arr) => (
|
||||
<View
|
||||
key={label}
|
||||
style={[
|
||||
customStyles.tableRow,
|
||||
{
|
||||
width: `calc(100% / ${arr.length})`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: getBackgroundColor(match, true),
|
||||
paddingVertical: "8px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.textBold,
|
||||
{
|
||||
color: getTextColor(match, true),
|
||||
fontSize: "6px",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{level}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: getBackgroundColor(match, false),
|
||||
paddingVertical: "8px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.textBold,
|
||||
{
|
||||
color: getTextColor(match, false),
|
||||
fontSize: "6px",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: getBackgroundColor(match, true),
|
||||
paddingVertical: "24px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.textBold,
|
||||
{
|
||||
color: getTextColor(match, true),
|
||||
fontSize: "10px",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{minValue}-{maxValue}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
24
src/exams/pdf/details/radial.result.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/* eslint-disable jsx-a11y/alt-text */
|
||||
import React from "react";
|
||||
|
||||
import { View, Text, Image } from "@react-pdf/renderer";
|
||||
import { styles } from "../styles";
|
||||
import { ModuleScore } from "@/interfaces/module.scores";
|
||||
|
||||
export const RadialResult = ({
|
||||
module,
|
||||
score,
|
||||
total,
|
||||
png,
|
||||
}: ModuleScore) => (
|
||||
<View style={[styles.textFont, styles.radialContainer]}>
|
||||
<Text style={[styles.textColor, styles.textBold, { fontSize: 10 }]}>
|
||||
{module}
|
||||
</Text>
|
||||
<Image src={png} style={styles.image64}></Image>
|
||||
<View style={[styles.textColor, styles.radialResultContainer]}>
|
||||
<Text style={styles.textBold}>{score}</Text>
|
||||
<Text style={{ fontSize: 8 }}>out of {total}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
20
src/exams/pdf/details/skill.exam.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
import { View, StyleSheet } from "@react-pdf/renderer";
|
||||
import { ModuleScore } from "@/interfaces/module.scores";
|
||||
import { RadialResult } from "./radial.result";
|
||||
interface Props {
|
||||
testDetails: ModuleScore[];
|
||||
}
|
||||
|
||||
const customStyles = StyleSheet.create({
|
||||
container: { display: "flex", flexDirection: "row", gap: 30 },
|
||||
});
|
||||
|
||||
export const SkillExamDetails = ({ testDetails }: Props) => (
|
||||
<View style={customStyles.container}>
|
||||
{testDetails.map((detail) => {
|
||||
const { module } = detail;
|
||||
return <RadialResult key={module} {...detail} />;
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
301
src/exams/pdf/group.test.report.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
/* eslint-disable jsx-a11y/alt-text */
|
||||
import React from "react";
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
StyleSheet,
|
||||
Font,
|
||||
} from "@react-pdf/renderer";
|
||||
import { styles } from "./styles";
|
||||
import TestReportFooter from "./test.report.footer";
|
||||
import { StudentData } from "@/interfaces/module.scores";
|
||||
import ProgressBar from "./progress.bar";
|
||||
Font.registerHyphenationCallback((word) => [word]);
|
||||
|
||||
interface Props {
|
||||
date: string;
|
||||
name: string;
|
||||
email: string;
|
||||
id: string;
|
||||
gender?: string;
|
||||
summary: string;
|
||||
logo: string;
|
||||
qrcode: string;
|
||||
renderDetails: React.ReactNode;
|
||||
title: string;
|
||||
numberOfStudents: number;
|
||||
institution: string;
|
||||
studentsData: StudentData[];
|
||||
showLevel: boolean;
|
||||
summaryPNG: string;
|
||||
summaryScore: string;
|
||||
groupScoreSummary: any[];
|
||||
passportId: string;
|
||||
}
|
||||
|
||||
const customStyles = StyleSheet.create({
|
||||
tableCellHighlight: {
|
||||
backgroundColor: "#4f4969",
|
||||
color: "#bc9970",
|
||||
},
|
||||
table: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
// maxWidth: "600px",
|
||||
// margin: "0 auto",
|
||||
// borderCollapse: 'collapse',
|
||||
},
|
||||
tableRow: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
},
|
||||
tableHeader: {
|
||||
fontWeight: "bold",
|
||||
backgroundColor: "#f2f2f2",
|
||||
},
|
||||
tableCell: {
|
||||
flex: 1,
|
||||
padding: "8px",
|
||||
textAlign: "left",
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
});
|
||||
|
||||
const GroupTestReport = ({
|
||||
title,
|
||||
date,
|
||||
name,
|
||||
email,
|
||||
id,
|
||||
gender,
|
||||
summary,
|
||||
logo,
|
||||
qrcode,
|
||||
renderDetails,
|
||||
numberOfStudents,
|
||||
institution,
|
||||
studentsData,
|
||||
showLevel,
|
||||
summaryPNG,
|
||||
summaryScore,
|
||||
groupScoreSummary,
|
||||
passportId,
|
||||
}: Props) => {
|
||||
const defaultTextStyle = [styles.textFont, { fontSize: 8 }];
|
||||
return (
|
||||
<Document>
|
||||
<Page style={styles.body}>
|
||||
<View style={styles.alignRightRow}>
|
||||
<Image src={logo} fixed style={styles.image64} />
|
||||
</View>
|
||||
<View style={styles.titleView}>
|
||||
<Text
|
||||
style={[
|
||||
styles.textFont,
|
||||
styles.textBold,
|
||||
styles.textColor,
|
||||
styles.textUnderline,
|
||||
styles.title,
|
||||
{ fontSize: 14 },
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.textMargin}>
|
||||
<Text style={defaultTextStyle}>Date of Test: {date}</Text>
|
||||
</View>
|
||||
<Text style={[styles.textFont, styles.textBold, { fontSize: 11 }]}>
|
||||
Candidate Information:
|
||||
</Text>
|
||||
<View style={styles.textMargin}>
|
||||
<Text style={defaultTextStyle}>Name: {name}</Text>
|
||||
<Text style={defaultTextStyle}>ID: {id}</Text>
|
||||
<Text style={defaultTextStyle}>Email: {email}</Text>
|
||||
<Text style={defaultTextStyle}>Gender: {gender}</Text>
|
||||
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
|
||||
<Text style={defaultTextStyle}>
|
||||
Total Number of Students: {numberOfStudents}
|
||||
</Text>
|
||||
<Text style={defaultTextStyle}>Institution: {institution}</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={[
|
||||
styles.textFont,
|
||||
styles.textBold,
|
||||
styles.textColor,
|
||||
{ fontSize: 12 },
|
||||
]}
|
||||
>
|
||||
Group Test Details:
|
||||
</Text>
|
||||
<View>{renderDetails}</View>
|
||||
</View>
|
||||
<View>
|
||||
<Text
|
||||
style={[
|
||||
styles.textFont,
|
||||
styles.textBold,
|
||||
styles.textColor,
|
||||
{ fontSize: 12 },
|
||||
]}
|
||||
>
|
||||
Group Overall Performance Summary
|
||||
</Text>
|
||||
<View style={{ display: "flex", flexDirection: "row", gap: 16 }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.textFont, { fontSize: 8 }]}>{summary}</Text>
|
||||
</View>
|
||||
<View style={[styles.textFont, styles.radialContainer]}>
|
||||
<Image src={summaryPNG} style={styles.image64}></Image>
|
||||
<View style={[styles.textColor, styles.radialResultContainer]}>
|
||||
<Text style={styles.textBold}>{summaryScore}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[{ paddingTop: 30 }, styles.separator]}></View>
|
||||
<View>
|
||||
<Text
|
||||
style={[
|
||||
styles.textFont,
|
||||
styles.textBold,
|
||||
styles.textColor,
|
||||
styles.textUnderline,
|
||||
{ fontSize: 12, paddingTop: 10 },
|
||||
]}
|
||||
>
|
||||
Group Score Summary
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
paddingTop: 10,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
customStyles.table,
|
||||
styles.textFont,
|
||||
{ width: "100%", fontSize: "8px" },
|
||||
]}
|
||||
>
|
||||
{groupScoreSummary.map(({ label, percent, description }) => (
|
||||
<View
|
||||
style={[
|
||||
customStyles.tableRow,
|
||||
{
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
]}
|
||||
key={label}
|
||||
>
|
||||
<Text style={customStyles.tableCell}>{label}</Text>
|
||||
<View style={[customStyles.tableCell, { flex: 2 }]}>
|
||||
<ProgressBar
|
||||
width={200}
|
||||
height={18}
|
||||
backgroundColor="#fab7b0"
|
||||
progressColor="#cc5b55"
|
||||
percentage={percent}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
|
||||
{percent}%
|
||||
</Text>
|
||||
<Text style={customStyles.tableCell}>{description}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.alignRightRow}>
|
||||
<Image src={qrcode} style={styles.qrcode} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={[{ paddingBottom: 30 }, styles.separator]}></View>
|
||||
<View style={{ flexGrow: 1 }}></View>
|
||||
<TestReportFooter />
|
||||
</Page>
|
||||
<Page style={styles.body}>
|
||||
<View
|
||||
style={[
|
||||
customStyles.table,
|
||||
styles.textFont,
|
||||
{ border: "1px solid #ccc", width: "100%", fontSize: "8px" },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
customStyles.tableRow,
|
||||
customStyles.tableHeader,
|
||||
customStyles.tableCellHighlight,
|
||||
{ borderBottom: "1px solid #ccc" },
|
||||
]}
|
||||
>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "24px" }]}>
|
||||
Sr
|
||||
</Text>
|
||||
<Text style={customStyles.tableCell}>Candidate Name</Text>
|
||||
<Text style={customStyles.tableCell}>Email ID</Text>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
|
||||
Gender
|
||||
</Text>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
|
||||
Date of test
|
||||
</Text>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
|
||||
Result
|
||||
</Text>
|
||||
{showLevel && <Text style={customStyles.tableCell}>Level</Text>}
|
||||
</View>
|
||||
{studentsData.map(
|
||||
({ id, name, email, gender, date, result, level }, index) => (
|
||||
<View
|
||||
style={[
|
||||
customStyles.tableRow,
|
||||
{ borderBottom: "1px solid #ccc" },
|
||||
]}
|
||||
key={id}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
customStyles.tableCell,
|
||||
customStyles.tableCellHighlight,
|
||||
{ maxWidth: "24px" },
|
||||
]}
|
||||
>
|
||||
{index + 1}
|
||||
</Text>
|
||||
<Text style={customStyles.tableCell}>{name}</Text>
|
||||
<Text style={customStyles.tableCell}>{email}</Text>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
|
||||
{gender}
|
||||
</Text>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
|
||||
{date}
|
||||
</Text>
|
||||
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
|
||||
{result}
|
||||
</Text>
|
||||
{showLevel && (
|
||||
<Text style={customStyles.tableCell}>{level}</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ flexGrow: 1 }}></View>
|
||||
<TestReportFooter />
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupTestReport;
|
||||
51
src/exams/pdf/progress.bar.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import { View, StyleSheet } from "@react-pdf/renderer";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
progressBar: {
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
},
|
||||
progressBarPerc: {
|
||||
height: "100%",
|
||||
zIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
interface Props {
|
||||
width: number;
|
||||
height: number;
|
||||
backgroundColor: string;
|
||||
progressColor: string;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
const ProgressBar = ({
|
||||
width,
|
||||
height,
|
||||
backgroundColor,
|
||||
progressColor,
|
||||
percentage,
|
||||
}: Props) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
width,
|
||||
height,
|
||||
backgroundColor,
|
||||
},
|
||||
styles.progressBar,
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
{ width: `${percentage}%`, backgroundColor: progressColor },
|
||||
styles.progressBarPerc,
|
||||
]}
|
||||
></View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressBar;
|
||||
75
src/exams/pdf/styles.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { StyleSheet } from "@react-pdf/renderer";
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
body: {
|
||||
paddingTop: 10,
|
||||
paddingBottom: 20,
|
||||
paddingHorizontal: 35,
|
||||
},
|
||||
titleView: {
|
||||
display: "flex",
|
||||
// flex: 1,
|
||||
alignItems: "center",
|
||||
},
|
||||
title: {
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
textMargin: {
|
||||
marginVertical: "8px",
|
||||
},
|
||||
separator: {
|
||||
width: "100%",
|
||||
borderBottom: "1px solid #89b0c2",
|
||||
},
|
||||
textFont: {
|
||||
fontFamily: "Helvetica",
|
||||
},
|
||||
textBold: {
|
||||
fontFamily: "Helvetica-Bold",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
textNormal: {
|
||||
fontWeight: "normal",
|
||||
},
|
||||
textColor: {
|
||||
color: "#4e4969",
|
||||
},
|
||||
textUnderline: {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
spacedRow: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
alignRightRow: {
|
||||
display: "flex",
|
||||
flexDirection: "row-reverse",
|
||||
},
|
||||
image64: {
|
||||
height: "64px",
|
||||
width: "64px",
|
||||
},
|
||||
qrcode: {
|
||||
width: "80px",
|
||||
height: "80px",
|
||||
},
|
||||
radialContainer: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
position: "relative",
|
||||
},
|
||||
radialResultContainer: {
|
||||
display: "flex",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 10,
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
60
src/exams/pdf/test.report.footer.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import { styles } from "./styles";
|
||||
import { View, Text } from "@react-pdf/renderer";
|
||||
|
||||
const TestReportFooter = () => (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingTop: 30,
|
||||
fontSize: 5,
|
||||
position: "absolute",
|
||||
bottom: 30,
|
||||
left: 35,
|
||||
right: 35,
|
||||
},
|
||||
styles.textFont,
|
||||
]}
|
||||
>
|
||||
<View style={[styles.spacedRow, styles.textMargin]}>
|
||||
<View>
|
||||
<Text style={styles.textBold}>Validity</Text>
|
||||
<Text>
|
||||
This report remains valid for a duration of three months from the test
|
||||
date.
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.textBold}>Confidential – <Text style={[styles.textFont, styles.textNormal]}>circulated for concern people</Text></Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={{ paddingTop: 10 }}>
|
||||
<Text style={styles.textBold}>Declaration</Text>
|
||||
<Text style={{ paddingTop: 5 }}>
|
||||
We hereby declare that exam results on our platform, assessed by AI, are
|
||||
not the sole determinants of candidates' English proficiency
|
||||
levels. While EnCoach provides feedback based on assessments, we
|
||||
recognize that language proficiency encompasses practical application,
|
||||
cultural understanding, and real-life communication. We urge users to
|
||||
consider exam results as a measure of progress and improvement, and we
|
||||
continuously enhance our system to ensure accuracy and reliability.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.textColor, { paddingTop: 5 }]}>
|
||||
<Text style={styles.textUnderline}>info@encoach.com</Text>
|
||||
<Text>https://encoach.com</Text>
|
||||
<View style={styles.spacedRow}>
|
||||
<Text>Group ID: TRI64BNBOIU5043</Text>
|
||||
<Text
|
||||
// style={styles.pageNumber}
|
||||
render={({ pageNumber, totalPages }) =>
|
||||
`${pageNumber} / ${totalPages}`
|
||||
}
|
||||
fixed
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
export default TestReportFooter;
|
||||
174
src/exams/pdf/test.report.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/* 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 { StyleSheet } from "@react-pdf/renderer";
|
||||
import TestReportFooter from "./test.report.footer";
|
||||
const customStyles = StyleSheet.create({
|
||||
testDetails: {
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
},
|
||||
});
|
||||
|
||||
interface Props {
|
||||
date: string;
|
||||
name: string;
|
||||
email: string;
|
||||
id: string;
|
||||
gender?: string;
|
||||
testDetails: ModuleScore[];
|
||||
summary: string;
|
||||
logo: string;
|
||||
qrcode: string;
|
||||
renderDetails: React.ReactNode;
|
||||
title: string;
|
||||
summaryPNG: string;
|
||||
summaryScore: string;
|
||||
passportId: string;
|
||||
}
|
||||
|
||||
const TestReport = ({
|
||||
title,
|
||||
date,
|
||||
name,
|
||||
email,
|
||||
id,
|
||||
gender,
|
||||
testDetails,
|
||||
summary,
|
||||
logo,
|
||||
qrcode,
|
||||
renderDetails,
|
||||
summaryPNG,
|
||||
summaryScore,
|
||||
passportId,
|
||||
}: Props) => {
|
||||
const defaultTextStyle = [styles.textFont, { fontSize: 8 }];
|
||||
const defaultSkillsTextStyle = [styles.textFont, { fontSize: 8 }];
|
||||
const defaultSkillsTitleStyle = [
|
||||
styles.textFont,
|
||||
styles.textColor,
|
||||
styles.textBold,
|
||||
{ fontSize: 7 },
|
||||
];
|
||||
return (
|
||||
<Document>
|
||||
<Page style={styles.body}>
|
||||
<View style={styles.alignRightRow}>
|
||||
<Image src={logo} fixed style={styles.image64} />
|
||||
</View>
|
||||
<View style={styles.titleView}>
|
||||
<Text
|
||||
style={[
|
||||
styles.textFont,
|
||||
styles.textBold,
|
||||
styles.textColor,
|
||||
styles.textUnderline,
|
||||
styles.title,
|
||||
{ fontSize: 14 },
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.textMargin}>
|
||||
<Text style={defaultTextStyle}>Date of Test: {date}</Text>
|
||||
</View>
|
||||
<Text style={[styles.textFont, styles.textBold, { fontSize: 11 }]}>
|
||||
Candidate Information:
|
||||
</Text>
|
||||
<View style={styles.textMargin}>
|
||||
<Text style={defaultTextStyle}>Name: {name}</Text>
|
||||
<Text style={defaultTextStyle}>ID: {id}</Text>
|
||||
<Text style={defaultTextStyle}>Email: {email}</Text>
|
||||
<Text style={defaultTextStyle}>Gender: {gender}</Text>
|
||||
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
|
||||
</View>
|
||||
<View style={{ height: "120px" }}>
|
||||
<Text
|
||||
style={[
|
||||
styles.textFont,
|
||||
styles.textBold,
|
||||
styles.textColor,
|
||||
{ fontSize: 12 },
|
||||
]}
|
||||
>
|
||||
Test Details:
|
||||
</Text>
|
||||
<View>{renderDetails}</View>
|
||||
</View>
|
||||
<View>
|
||||
<Text
|
||||
style={[
|
||||
styles.textFont,
|
||||
styles.textBold,
|
||||
styles.textColor,
|
||||
{ fontSize: 12 },
|
||||
]}
|
||||
>
|
||||
Performance Summary
|
||||
</Text>
|
||||
<View style={{ display: "flex", flexDirection: "row", gap: 16 }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.textFont, { fontSize: 8 }]}>{summary}</Text>
|
||||
</View>
|
||||
<View style={[styles.textFont, styles.radialContainer]}>
|
||||
<Image src={summaryPNG} style={styles.image64}></Image>
|
||||
<View style={[styles.textColor, styles.radialResultContainer]}>
|
||||
<Text style={styles.textBold}>{summaryScore}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[{ paddingTop: 30 }, styles.separator]}></View>
|
||||
<TestReportFooter />
|
||||
</Page>
|
||||
<Page style={styles.body}>
|
||||
<View>
|
||||
<Text
|
||||
style={[
|
||||
styles.textFont,
|
||||
styles.textBold,
|
||||
styles.textColor,
|
||||
styles.textUnderline,
|
||||
{ fontSize: 12, paddingTop: 10 },
|
||||
]}
|
||||
>
|
||||
Skills Feedback
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
paddingTop: 10,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{testDetails
|
||||
.filter(
|
||||
({ suggestions, evaluation }) => suggestions || evaluation
|
||||
)
|
||||
.map(({ module, suggestions, evaluation }) => (
|
||||
<View key={module} style={customStyles.testDetails}>
|
||||
<Text style={[...defaultSkillsTitleStyle, styles.textBold]}>
|
||||
{module}
|
||||
</Text>
|
||||
<Text style={defaultSkillsTextStyle}>{evaluation}</Text>
|
||||
<Text style={defaultSkillsTextStyle}>{suggestions}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.alignRightRow}>
|
||||
<Image src={qrcode} style={styles.qrcode} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={[{ paddingBottom: 30 }, styles.separator]}></View>
|
||||
<View style={{ flexGrow: 1 }}></View>
|
||||
<TestReportFooter />
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestReport;
|
||||
@@ -1,5 +1,6 @@
|
||||
import {initializeApp} from "firebase/app";
|
||||
import * as admin from "firebase-admin/app";
|
||||
import { getStorage } from "firebase/storage";
|
||||
|
||||
const serviceAccount = require("@/constants/serviceAccountKey.json");
|
||||
|
||||
@@ -19,3 +20,4 @@ export const adminApp = admin.initializeApp(
|
||||
},
|
||||
Math.random().toString(),
|
||||
);
|
||||
export const storage = getStorage(app);
|
||||
48
src/hooks/useListSearch.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import {useState, useMemo} from 'react';
|
||||
import Input from "@/components/Low/Input";
|
||||
|
||||
/*fields example = [
|
||||
['id'],
|
||||
['companyInformation', 'companyInformation', 'name']
|
||||
]*/
|
||||
|
||||
|
||||
const getFieldValue = (fields: string[], data: any): string => {
|
||||
if(fields.length === 0) return data;
|
||||
const [key, ...otherFields] = fields;
|
||||
|
||||
if(data[key]) return getFieldValue(otherFields, data[key]);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const useListSearch = (fields: string[][], rows: any[]) => {
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const renderSearch = () => (
|
||||
<Input
|
||||
label="Search"
|
||||
type="text"
|
||||
name="search"
|
||||
onChange={setText}
|
||||
placeholder="Enter search text"
|
||||
value={text}
|
||||
/>
|
||||
)
|
||||
|
||||
const updatedRows = useMemo(() => {
|
||||
const searchText = text.toLowerCase();
|
||||
return rows.filter((row) => {
|
||||
return fields.some((fieldsKeys) => {
|
||||
const value = getFieldValue(fieldsKeys, row);
|
||||
if(typeof value === 'string') {
|
||||
return value.toLowerCase().includes(searchText);
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [fields, rows, text])
|
||||
|
||||
return {
|
||||
rows: updatedRows,
|
||||
renderSearch,
|
||||
}
|
||||
}
|
||||
60
src/hooks/usePDFDownload.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
import { BsFilePdf } from "react-icons/bs";
|
||||
|
||||
type DownloadingPdf = {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
|
||||
type PdfEndpoint = "stats" | "assignments";
|
||||
|
||||
export const usePDFDownload = (endpoint: PdfEndpoint) => {
|
||||
const [downloadingPdf, setDownloadingPdf] = React.useState<DownloadingPdf>(
|
||||
{}
|
||||
);
|
||||
|
||||
const triggerDownload = async (id: string) => {
|
||||
try {
|
||||
setDownloadingPdf((prev) => ({ ...prev, [id]: true }));
|
||||
const res = await axios.post(`/api/${endpoint}/${id}/export`);
|
||||
toast.success("Report ready!");
|
||||
const link = document.createElement("a");
|
||||
link.href = res.data;
|
||||
// download should have worked but there are some CORS issues
|
||||
// https://firebase.google.com/docs/storage/web/download-files#cors_configuration
|
||||
// link.download="report.pdf";
|
||||
link.target = "_blank";
|
||||
link.rel = "noreferrer";
|
||||
link.click();
|
||||
setDownloadingPdf((prev) => ({ ...prev, [id]: false }));
|
||||
} catch (err) {
|
||||
toast.error("Failed to display the report!");
|
||||
console.error(err);
|
||||
setDownloadingPdf((prev) => ({ ...prev, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const renderIcon = (
|
||||
id: string,
|
||||
downloadClasses: string,
|
||||
loadingClasses: string
|
||||
) => {
|
||||
if (downloadingPdf[id]) {
|
||||
return (
|
||||
<span className={`${loadingClasses} loading loading-infinity w-6`} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<BsFilePdf
|
||||
className={`${downloadClasses} text-2xl cursor-pointer`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
triggerDownload(id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return renderIcon;
|
||||
};
|
||||
20
src/hooks/usePaymentStatusUsers.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import axios from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { PaymentsStatus } from "@/interfaces/user.payments";
|
||||
|
||||
export default function usePaymentStatusUsers() {
|
||||
const [{ pending, done }, setStatus] = useState<PaymentsStatus>({
|
||||
pending: [],
|
||||
done: [],
|
||||
});
|
||||
|
||||
const getData = () => {
|
||||
axios.get<PaymentsStatus>("/api/payments/assigned").then((response) => {
|
||||
setStatus(response.data);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(getData, []);
|
||||
|
||||
return { pending, done };
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export default function useStats(id?: string) {
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Stat[]>(!id ? "/api/stats" : `/api/stats/${id}`)
|
||||
.get<Stat[]>(!id ? "/api/stats" : `/api/stats/user/${id}`)
|
||||
.then((response) => setStats(response.data))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
@@ -15,10 +15,10 @@ export default function useUser({redirectTo = "", redirectIfFound = false} = {})
|
||||
if (!redirectTo || !user) return;
|
||||
|
||||
if (
|
||||
// If redirectTo is set, redirect if the user was not found.
|
||||
(redirectTo && !redirectIfFound && (!user || (user && !user.isVerified))) ||
|
||||
// If redirectIfFound is also set, redirect if the user was found
|
||||
(redirectIfFound && user && user.isVerified)
|
||||
(redirectIfFound && user && user.isVerified) ||
|
||||
// If redirectTo is set, redirect if the user was not found.
|
||||
(redirectTo && !redirectIfFound && (!user || (user && !user.isVerified)))
|
||||
) {
|
||||
Router.push(redirectTo);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Module} from ".";
|
||||
|
||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||
export type Variant = "diagnostic" | "partial";
|
||||
|
||||
export interface ReadingExam {
|
||||
parts: ReadingPart[];
|
||||
@@ -9,6 +10,7 @@ export interface ReadingExam {
|
||||
minTimer: number;
|
||||
type: "academic" | "general";
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
}
|
||||
|
||||
export interface ReadingPart {
|
||||
@@ -25,6 +27,7 @@ export interface LevelExam {
|
||||
exercises: Exercise[];
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
}
|
||||
|
||||
export interface ListeningExam {
|
||||
@@ -33,6 +36,7 @@ export interface ListeningExam {
|
||||
module: "listening";
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
}
|
||||
|
||||
export interface ListeningPart {
|
||||
@@ -44,6 +48,7 @@ export interface ListeningPart {
|
||||
}
|
||||
|
||||
export interface UserSolution {
|
||||
id?: string;
|
||||
solutions: any[];
|
||||
module?: Module;
|
||||
exam?: string;
|
||||
@@ -62,6 +67,7 @@ export interface WritingExam {
|
||||
exercises: Exercise[];
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
}
|
||||
|
||||
interface WordCounter {
|
||||
@@ -75,6 +81,7 @@ export interface SpeakingExam {
|
||||
exercises: Exercise[];
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
}
|
||||
|
||||
export type Exercise =
|
||||
@@ -91,7 +98,21 @@ export interface Evaluation {
|
||||
comment: string;
|
||||
overall: number;
|
||||
task_response: {[key: string]: number};
|
||||
misspelled_pairs?: {correction: string | null; misspelled: string}[];
|
||||
}
|
||||
|
||||
interface InteractiveSpeakingEvaluation extends Evaluation {
|
||||
perfect_answer_1?: string;
|
||||
perfect_answer_2?: string;
|
||||
perfect_answer_3?: string;
|
||||
}
|
||||
|
||||
interface CommonEvaluation extends Evaluation {
|
||||
perfect_answer?: string;
|
||||
perfect_answer_1?: string;
|
||||
fixed_text?: string;
|
||||
}
|
||||
|
||||
export interface WritingExercise {
|
||||
id: string;
|
||||
type: "writing";
|
||||
@@ -106,7 +127,7 @@ export interface WritingExercise {
|
||||
userSolutions: {
|
||||
id: string;
|
||||
solution: string;
|
||||
evaluation?: Evaluation;
|
||||
evaluation?: CommonEvaluation;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -120,7 +141,7 @@ export interface SpeakingExercise {
|
||||
userSolutions: {
|
||||
id: string;
|
||||
solution: string;
|
||||
evaluation?: Evaluation;
|
||||
evaluation?: CommonEvaluation;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -133,7 +154,7 @@ export interface InteractiveSpeakingExercise {
|
||||
userSolutions: {
|
||||
id: string;
|
||||
solution: {question: string; answer: string}[];
|
||||
evaluation?: Evaluation;
|
||||
evaluation?: InteractiveSpeakingEvaluation;
|
||||
}[];
|
||||
}
|
||||
|
||||
|
||||
22
src/interfaces/module.scores.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {Module} from "@/interfaces";
|
||||
|
||||
export interface ModuleScore {
|
||||
score: number;
|
||||
total: number;
|
||||
code: Module;
|
||||
module: Module | 'Overall';
|
||||
png?: string,
|
||||
evaluation?: string,
|
||||
suggestions?: string,
|
||||
}
|
||||
|
||||
export interface StudentData {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
gender: string;
|
||||
date: string;
|
||||
result: string;
|
||||
level?: string;
|
||||
bandScore: number;
|
||||
}
|
||||
@@ -31,5 +31,7 @@ export interface Payment {
|
||||
currency: string;
|
||||
value: number;
|
||||
isPaid: boolean;
|
||||
date: Date;
|
||||
date: Date | string;
|
||||
corporateTransfer?: string;
|
||||
commissionTransfer?: string;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface Assignment {
|
||||
type: "academic" | "general";
|
||||
stats: Stat[];
|
||||
}[];
|
||||
exams: {id: string; module: Module}[];
|
||||
exams: {id: string; module: Module, assignee: string}[];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
1
src/interfaces/storage.files.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type FilesStorage = "commission" | "corporate";
|
||||
5
src/interfaces/user.payments.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// these arrays contain the ids of the corporates that have paid or not paid
|
||||
export interface PaymentsStatus {
|
||||
pending: string[];
|
||||
done: string[];
|
||||
}
|
||||
@@ -57,6 +57,7 @@ export interface CorporateInformation {
|
||||
payment?: {
|
||||
value: number;
|
||||
currency: string;
|
||||
commission: number;
|
||||
};
|
||||
referralAgent?: string;
|
||||
}
|
||||
@@ -76,6 +77,8 @@ export interface DemographicInformation {
|
||||
phone: string;
|
||||
gender: Gender;
|
||||
employment: EmploymentStatus;
|
||||
passport_id?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface DemographicCorporateInformation {
|
||||
@@ -83,6 +86,7 @@ export interface DemographicCorporateInformation {
|
||||
phone: string;
|
||||
gender: Gender;
|
||||
position: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export type Gender = "male" | "female" | "other";
|
||||
@@ -97,6 +101,7 @@ export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
|
||||
];
|
||||
|
||||
export interface Stat {
|
||||
id: string;
|
||||
user: string;
|
||||
exam: string;
|
||||
exercise: string;
|
||||
|
||||
@@ -6,26 +6,42 @@ 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, uniqBy} from "lodash";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
import {toast} from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import {useFilePicker} from "use-file-picker";
|
||||
import readXlsxFile from "read-excel-file";
|
||||
import Modal from "@/components/Modal";
|
||||
import {BsQuestionCircleFill} from "react-icons/bs";
|
||||
|
||||
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]: Type[]} = {
|
||||
student: [],
|
||||
teacher: [],
|
||||
agent: [],
|
||||
corporate: ["student", "teacher"],
|
||||
admin: ["student", "teacher", "agent", "corporate", "admin"],
|
||||
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
|
||||
};
|
||||
|
||||
export default function BatchCodeGenerator({user}: {user: User}) {
|
||||
const [emails, setEmails] = useState<string[]>([]);
|
||||
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
const {users} = useUsers();
|
||||
|
||||
const {openFilePicker, filesContent} = useFilePicker({
|
||||
accept: ".txt",
|
||||
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,29 +57,43 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
||||
useEffect(() => {
|
||||
if (filesContent.length > 0) {
|
||||
const file = filesContent[0];
|
||||
const emails = file.content
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter((x) => new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/).test(x))
|
||||
.filter((x) => !users.map((u) => u.email).includes(x));
|
||||
readXlsxFile(file.content).then((rows) => {
|
||||
const information = uniqBy(
|
||||
rows
|
||||
.map((row) => {
|
||||
const [firstName, lastName, country, passport_id, email, phone] = row as string[];
|
||||
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email)
|
||||
? {
|
||||
email: email.toString(),
|
||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||
passport_id: passport_id.toString(),
|
||||
}
|
||||
: undefined;
|
||||
})
|
||||
.filter((x) => !!x) as typeof infos,
|
||||
(x) => x.email,
|
||||
);
|
||||
|
||||
if (emails.length === 0) {
|
||||
toast.error("Please upload a .txt file containing e-mails, one per line! All already registered e-mails have also been ignored!");
|
||||
return;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
setEmails([...new Set(emails)]);
|
||||
setInfos(information);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent]);
|
||||
|
||||
const generateCode = (type: Type) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const codes = emails.map(() => uid.randomUUID(6));
|
||||
const codes = infos.map(() => uid.randomUUID(6));
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.post("/api/code", {type, codes, emails, expiryDate})
|
||||
.post("/api/code", {type, codes, infos: infos, expiryDate})
|
||||
.then(({data, status}) => {
|
||||
if (data.ok) {
|
||||
toast.success(`Successfully generated ${capitalize(type)} codes and they have been notified by e-mail!`, {toastId: "success"});
|
||||
@@ -86,50 +116,85 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
||||
};
|
||||
|
||||
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">Choose a .txt file containing e-mails</label>
|
||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||
</Button>
|
||||
{user && (user.type === "developer" || user.type === "admin") && (
|
||||
<>
|
||||
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
<>
|
||||
<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="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||
<div className="flex justify-between items-end">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Choose an Excel file</label>
|
||||
<div className="cursor-pointer tooltip" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
|
||||
<BsQuestionCircleFill />
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<label className="font-normal text-base text-mti-gray-dim">Select the type of user they should be</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).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button onClick={() => generateCode(type)} disabled={emails.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
||||
Generate & Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||
</Button>
|
||||
{user && (user.type === "developer" || user.type === "admin") && (
|
||||
<>
|
||||
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{isExpiryDateEnabled && (
|
||||
<ReactDatePicker
|
||||
className={clsx(
|
||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<label className="font-normal text-base text-mti-gray-dim">Select the type of user they should be</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) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type))
|
||||
.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button onClick={() => generateCode(type)} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
||||
Generate & Send
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,15 @@ import ReactDatePicker from "react-datepicker";
|
||||
import {toast} from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
|
||||
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = {
|
||||
student: [],
|
||||
teacher: [],
|
||||
agent: [],
|
||||
corporate: ["student", "teacher"],
|
||||
admin: ["student", "teacher", "agent", "corporate", "admin"],
|
||||
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
|
||||
};
|
||||
|
||||
export default function CodeGenerator({user}: {user: User}) {
|
||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||
@@ -63,11 +72,13 @@ export default function CodeGenerator({user}: {user: User}) {
|
||||
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).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
{Object.keys(USER_TYPE_LABELS)
|
||||
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type))
|
||||
.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{user && (user.type === "developer" || user.type === "admin") && (
|
||||
|
||||
@@ -9,69 +9,104 @@ import {Disclosure, Transition} from "@headlessui/react";
|
||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import {capitalize, uniq, uniqBy} from "lodash";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {BsCheck, BsDash, BsPencil, BsPlus, BsTrash} from "react-icons/bs";
|
||||
import {BsCheck, BsDash, BsPencil, BsPlus, BsQuestionCircleFill, BsTrash} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import Select from "react-select";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
import {useFilePicker} from "use-file-picker";
|
||||
import Modal from "@/components/Modal";
|
||||
import readXlsxFile from "read-excel-file";
|
||||
|
||||
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]+)*$/);
|
||||
|
||||
interface CreateDialogProps {
|
||||
user: User;
|
||||
users: User[];
|
||||
group?: Group;
|
||||
onCreate: (group: Group) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CreatePanel = ({user, users, group, onCreate}: CreateDialogProps) => {
|
||||
const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
||||
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
||||
const {openFilePicker, filesContent} = useFilePicker({
|
||||
accept: ".txt",
|
||||
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (filesContent.length > 0) {
|
||||
const file = filesContent[0];
|
||||
const emails = file.content
|
||||
.toLowerCase()
|
||||
.split("\n")
|
||||
.filter((x) => new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/).test(x));
|
||||
readXlsxFile(file.content).then((rows) => {
|
||||
const emails = uniq(
|
||||
rows
|
||||
.map((row) => {
|
||||
const [email] = row as string[];
|
||||
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
|
||||
})
|
||||
.filter((x) => !!x),
|
||||
);
|
||||
|
||||
if (emails.length === 0) {
|
||||
toast.error("Please upload a .txt file containing e-mails, one per line!");
|
||||
return;
|
||||
}
|
||||
if (emails.length === 0) {
|
||||
toast.error("Please upload an Excel file containing e-mails!");
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
||||
const filteredUsers = emailUsers.filter(
|
||||
(x) =>
|
||||
((user.type === "developer" || user.type === "admin" || user.type === "corporate") &&
|
||||
(x?.type === "student" || x?.type === "teacher")) ||
|
||||
(user.type === "teacher" && x?.type === "student"),
|
||||
);
|
||||
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
||||
const filteredUsers = emailUsers.filter(
|
||||
(x) =>
|
||||
((user.type === "developer" || user.type === "admin" || user.type === "corporate") &&
|
||||
(x?.type === "student" || x?.type === "teacher")) ||
|
||||
(user.type === "teacher" && x?.type === "student"),
|
||||
);
|
||||
|
||||
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
||||
toast.success(
|
||||
user.type !== "teacher"
|
||||
? "Added all teachers and students found in the file you've provided!"
|
||||
: "Added all students found in the file you've provided!",
|
||||
{toastId: "upload-success"},
|
||||
);
|
||||
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
||||
toast.success(
|
||||
user.type !== "teacher"
|
||||
? "Added all teachers and students found in the file you've provided!"
|
||||
: "Added all students found in the file you've provided!",
|
||||
{toastId: "upload-success"},
|
||||
);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesContent, user.type, users]);
|
||||
|
||||
const submit = () => {
|
||||
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
||||
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
||||
return;
|
||||
}
|
||||
|
||||
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", {name, admin, participants})
|
||||
.then(() => {
|
||||
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
return false;
|
||||
})
|
||||
.finally(onClose);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-12 mt-4 w-full px-4 py-2">
|
||||
<div className="flex flex-col gap-8">
|
||||
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Participants</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Participants</label>
|
||||
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
|
||||
<BsQuestionCircleFill />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-8 w-full">
|
||||
<Select
|
||||
className="w-full"
|
||||
@@ -100,24 +135,22 @@ const CreatePanel = ({user, users, group, onCreate}: CreateDialogProps) => {
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<Button className="w-full max-w-[300px]" onClick={openFilePicker} variant="outline">
|
||||
{filesContent.length === 0 ? "Upload participants .txt file" : filesContent[0].name}
|
||||
</Button>
|
||||
{user.type !== "teacher" && (
|
||||
<Button className="w-full max-w-[300px]" onClick={openFilePicker} variant="outline">
|
||||
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full max-w-[200px] self-end"
|
||||
disabled={!name}
|
||||
onClick={() => {
|
||||
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
||||
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
||||
return;
|
||||
}
|
||||
onCreate({name: name!, admin, participants, id: group?.id || uuidv4()});
|
||||
}}>
|
||||
{!group ? "Create" : "Update"}
|
||||
</Button>
|
||||
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!name}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -125,56 +158,19 @@ const CreatePanel = ({user, users, group, onCreate}: CreateDialogProps) => {
|
||||
const filterTypes = ["corporate", "teacher"];
|
||||
|
||||
export default function GroupList({user}: {user: User}) {
|
||||
const [editingID, setEditingID] = useState<string>();
|
||||
const [showDisclosure, setShowDisclosure] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||
const [filterByUser, setFilterByUser] = useState(false);
|
||||
|
||||
const {users} = useUsers();
|
||||
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingID) setShowDisclosure(true);
|
||||
}, [editingID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showDisclosure) document.getElementById("disclosure")?.scrollTo();
|
||||
if (!showDisclosure) setEditingID(undefined);
|
||||
}, [showDisclosure]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
||||
setFilterByUser(true);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const createGroup = (group: Group) => {
|
||||
return axios
|
||||
.post<{ok: boolean}>("/api/groups", group)
|
||||
.then(() => {
|
||||
toast.success(`Group "${group.name}" created successfully`);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
return false;
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const updateGroup = (group: Group) => {
|
||||
return axios
|
||||
.patch<{ok: boolean}>(`/api/groups/${group.id}`, group)
|
||||
.then(() => {
|
||||
toast.success(`Group "${group.name}" created successfully`);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
return false;
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const deleteGroup = (group: Group) => {
|
||||
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
||||
|
||||
@@ -216,10 +212,10 @@ export default function GroupList({user}: {user: User}) {
|
||||
cell: ({row}: {row: {original: Group}}) => {
|
||||
return (
|
||||
<>
|
||||
{(user?.type === "developer" || user?.type === "admin" || user.id === row.original.admin) && (
|
||||
{user && (user.type === "developer" || user.type === "admin" || user.id === row.original.admin) && (
|
||||
<div className="flex gap-2">
|
||||
{editingID !== row.original.id && (
|
||||
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingID(row.original.id)}>
|
||||
{!row.original.disableEditing && (
|
||||
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingGroup(row.original)}>
|
||||
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
@@ -242,8 +238,32 @@ export default function GroupList({user}: {user: User}) {
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCreating(false);
|
||||
setEditingGroup(undefined);
|
||||
reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full rounded-xl">
|
||||
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
|
||||
<CreatePanel
|
||||
group={editingGroup}
|
||||
user={user}
|
||||
onClose={closeModal}
|
||||
users={
|
||||
user?.type === "corporate" || user?.type === "teacher"
|
||||
? 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),
|
||||
)
|
||||
: users
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
@@ -268,54 +288,12 @@ export default function GroupList({user}: {user: User}) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
"w-full px-4 py-2 bg-mti-purple-ultralight/40 flex gap-2 items-center justify-center rounded-lg",
|
||||
"transition duration-300 ease-in-out",
|
||||
"hover:bg-mti-purple-ultralight cursor-pointer",
|
||||
)}
|
||||
onClick={() => setShowDisclosure((prev) => !prev)}>
|
||||
{!showDisclosure ? <BsPlus className="w-6 h-6" /> : <BsDash className="w-6 h-6" />}
|
||||
|
||||
<span>{!showDisclosure ? "Create group" : "Cancel"}</span>
|
||||
</div>
|
||||
<Transition
|
||||
show={showDisclosure}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0">
|
||||
<div id="#disclosure">
|
||||
<CreatePanel
|
||||
group={editingID ? groups.find((x) => x.id === editingID) : undefined}
|
||||
user={user}
|
||||
users={
|
||||
user?.type === "corporate" || user?.type === "teacher"
|
||||
? 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),
|
||||
)
|
||||
: users
|
||||
}
|
||||
onCreate={(group) => {
|
||||
(!editingID ? createGroup : updateGroup)(group).then((result) => {
|
||||
if (result) {
|
||||
setShowDisclosure(false);
|
||||
setEditingID(undefined);
|
||||
reload();
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</>
|
||||
</>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
|
||||
New Group
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
261
src/pages/(admin)/Lists/PackageList.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
import Modal from "@/components/Modal";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import useExams from "@/hooks/useExams";
|
||||
import usePackages from "@/hooks/usePackages";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {Package} from "@/interfaces/paypal";
|
||||
import {Type, User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import {BsCheck, BsPencil, BsTrash, BsUpload} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import Select from "react-select";
|
||||
import {CURRENCIES} from "@/resources/paypal";
|
||||
import Button from "@/components/Low/Button";
|
||||
|
||||
const CLASSES: {[key in Module]: string} = {
|
||||
reading: "text-ielts-reading",
|
||||
listening: "text-ielts-listening",
|
||||
speaking: "text-ielts-speaking",
|
||||
writing: "text-ielts-writing",
|
||||
level: "text-ielts-level",
|
||||
};
|
||||
|
||||
const columnHelper = createColumnHelper<Package>();
|
||||
|
||||
type DurationUnit = "days" | "weeks" | "months" | "years";
|
||||
|
||||
function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void}) {
|
||||
const [duration, setDuration] = useState(pack?.duration || 1);
|
||||
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months");
|
||||
|
||||
const [price, setPrice] = useState(pack?.price || 0);
|
||||
const [currency, setCurrency] = useState<string>(pack?.currency || "EUR");
|
||||
|
||||
const submit = () => {
|
||||
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", {
|
||||
duration,
|
||||
duration_unit: unit,
|
||||
price,
|
||||
currency,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("New payment has been created successfully!");
|
||||
onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 py-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Price *</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input defaultValue={price} name="price" type="number" onChange={(e) => setPrice(parseInt(e))} />
|
||||
|
||||
<Select
|
||||
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={CURRENCIES.map(({label, currency}) => ({value: currency, label}))}
|
||||
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
||||
onChange={(value) => setCurrency(value?.value || "EUR")}
|
||||
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Duration *</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input defaultValue={duration} name="duration" type="number" onChange={(e) => setDuration(parseInt(e))} />
|
||||
<Select
|
||||
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={[
|
||||
{value: "days", label: "Days"},
|
||||
{value: "weeks", label: "Weeks"},
|
||||
{value: "months", label: "Months"},
|
||||
{value: "years", label: "Years"},
|
||||
]}
|
||||
defaultValue={{value: "months", label: "Months"}}
|
||||
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")}
|
||||
value={{value: unit, label: capitalize(unit)}}
|
||||
styles={{
|
||||
control: (styles) => ({
|
||||
...styles,
|
||||
paddingLeft: "4px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!duration || !price}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PackageList({user}: {user: User}) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingPackage, setEditingPackage] = useState<Package>();
|
||||
|
||||
const {packages, reload} = usePackages();
|
||||
|
||||
const deletePackage = async (pack: Package) => {
|
||||
if (!confirm(`Are you sure you want to delete this package?`)) return;
|
||||
|
||||
axios
|
||||
.delete(`/api/packages/${pack.id}`)
|
||||
.then(() => toast.success(`Deleted the "${pack.id}" exam`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Package not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this exam!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("duration", {
|
||||
header: "Duration",
|
||||
cell: (info) => (
|
||||
<span>
|
||||
{info.getValue()} {info.row.original.duration_unit}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("price", {
|
||||
header: "Price",
|
||||
cell: (info) => (
|
||||
<span>
|
||||
{info.getValue()} {info.row.original.currency}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({row}: {row: {original: Package}}) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{["developer", "admin"].includes(user.type) && (
|
||||
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingPackage(row.original)}>
|
||||
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
{["developer", "admin"].includes(user.type) && (
|
||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deletePackage(row.original)}>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: packages,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCreating(false);
|
||||
setEditingPackage(undefined);
|
||||
reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full rounded-xl">
|
||||
<Modal
|
||||
isOpen={isCreating || !!editingPackage}
|
||||
onClose={closeModal}
|
||||
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}>
|
||||
<PackageCreator onClose={closeModal} pack={editingPackage} />
|
||||
</Modal>
|
||||
<table className="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>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
|
||||
New Package
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import Button from "@/components/Low/Button";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Type, User, userTypes} from "@/interfaces/user";
|
||||
import {Type, User, userTypes, CorporateUser} from "@/interfaces/user";
|
||||
import {Popover, Transition} from "@headlessui/react";
|
||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
@@ -19,9 +19,15 @@ import UserCard from "@/components/UserCard";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import useFilterStore from "@/stores/listFilterStore";
|
||||
import {useRouter} from "next/router";
|
||||
import {isCorporateUser} from '@/resources/user';
|
||||
import { useListSearch } from "@/hooks/useListSearch";
|
||||
|
||||
const columnHelper = createColumnHelper<User>();
|
||||
|
||||
const searchFields = [
|
||||
['name'],
|
||||
['email'],
|
||||
['corporateInformation', 'companyInformation', 'name'],
|
||||
];
|
||||
export default function UserList({user, filters = []}: {user: User; filters?: ((user: User) => boolean)[]}) {
|
||||
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
|
||||
const [sorter, setSorter] = useState<string>();
|
||||
@@ -325,6 +331,15 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
||||
) as any,
|
||||
cell: (info) => USER_TYPE_LABELS[info.getValue()],
|
||||
}),
|
||||
columnHelper.accessor('corporateInformation.companyInformation.name', {
|
||||
header: (
|
||||
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "companyName"))}>
|
||||
<span>Company Name</span>
|
||||
<SorterArrow name="companyName" />
|
||||
</button>
|
||||
) as any,
|
||||
cell: (info) => getCorporateName(info.row.original),
|
||||
}),
|
||||
columnHelper.accessor("subscriptionExpirationDate", {
|
||||
header: (
|
||||
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "expiryDate"))}>
|
||||
@@ -378,6 +393,14 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getCorporateName = (user: User) => {
|
||||
if(isCorporateUser(user)) {
|
||||
return user.corporateInformation?.companyInformation?.name
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
const sortFunction = (a: User, b: User) => {
|
||||
if (sorter === "name" || sorter === reverseString("name"))
|
||||
return sorter === "name" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
|
||||
@@ -445,11 +468,28 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
||||
: b.demographicInformation!.gender.localeCompare(a.demographicInformation!.gender);
|
||||
}
|
||||
|
||||
if(sorter === 'companyName' || sorter === reverseString('companyName')) {
|
||||
const aCorporateName = getCorporateName(a);
|
||||
const bCorporateName = getCorporateName(b);
|
||||
if (!aCorporateName && bCorporateName) return sorter === "companyName" ? -1 : 1;
|
||||
if (aCorporateName && !bCorporateName) return sorter === "companyName" ? 1 : -1;
|
||||
if (!aCorporateName && !bCorporateName) return 0;
|
||||
|
||||
return sorter === "companyName"
|
||||
? aCorporateName.localeCompare(bCorporateName)
|
||||
: bCorporateName.localeCompare(aCorporateName);
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
};
|
||||
|
||||
const { rows: filteredRows, renderSearch } = useListSearch(
|
||||
searchFields,
|
||||
displayUsers,
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: displayUsers,
|
||||
data: filteredRows,
|
||||
columns: (!showDemographicInformation ? defaultColumns : demographicColumns) as any,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
@@ -532,30 +572,33 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="py-4 px-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 items-center w-fit" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
{renderSearch()}
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="py-4 px-4 text-left" key={header.id}>
|
||||
{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 items-center w-fit" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {Tab} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import ExamList from "./ExamList";
|
||||
import GroupList from "./GroupList";
|
||||
import PackageList from "./PackageList";
|
||||
import UserList from "./UserList";
|
||||
|
||||
export default function Lists({user}: {user: User}) {
|
||||
@@ -44,6 +45,19 @@ export default function Lists({user}: {user: User}) {
|
||||
}>
|
||||
Group 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>
|
||||
)}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="mt-2">
|
||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||
@@ -57,6 +71,11 @@ export default function Lists({user}: {user: User}) {
|
||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||
<GroupList 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>
|
||||
)}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function ExamPage({page}: Props) {
|
||||
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
||||
const [avoidRepeated, setAvoidRepeated] = useState(false);
|
||||
const [timeSpent, setTimeSpent] = useState(0);
|
||||
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
|
||||
|
||||
const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]);
|
||||
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
|
||||
@@ -48,6 +49,9 @@ export default function ExamPage({page}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => setSessionId(uuidv4()), []);
|
||||
useEffect(() => {
|
||||
if (user?.type === "developer") console.log(exam);
|
||||
}, [exam, user]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedModules.length > 0 && timeSpent === 0 && !showSolutions;
|
||||
@@ -63,6 +67,10 @@ export default function ExamPage({page}: Props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedModules.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showSolutions) setModuleIndex(-1);
|
||||
}, [showSolutions]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
|
||||
@@ -94,6 +102,7 @@ export default function ExamPage({page}: Props) {
|
||||
if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded && !showSolutions) {
|
||||
const newStats: Stat[] = userSolutions.map((solution) => ({
|
||||
...solution,
|
||||
id: solution.id || uuidv4(),
|
||||
timeSpent,
|
||||
session: sessionId,
|
||||
exam: solution.exam!,
|
||||
@@ -111,6 +120,41 @@ export default function ExamPage({page}: Props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedModules, moduleIndex, hasBeenUploaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (statsAwaitingEvaluation.length === 0) return setIsEvaluationLoading(false);
|
||||
return setIsEvaluationLoading(true);
|
||||
}, [statsAwaitingEvaluation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (statsAwaitingEvaluation.length > 0) {
|
||||
statsAwaitingEvaluation.forEach(checkIfStatHasBeenEvaluated);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [statsAwaitingEvaluation]);
|
||||
|
||||
const checkIfStatHasBeenEvaluated = (id: string) => {
|
||||
setTimeout(async () => {
|
||||
const statRequest = await axios.get<Stat>(`/api/stats/${id}`);
|
||||
const stat = statRequest.data;
|
||||
if (stat.solutions.every((x) => x.evaluation !== null)) {
|
||||
const userSolution: UserSolution = {
|
||||
id,
|
||||
exercise: stat.exercise,
|
||||
score: stat.score,
|
||||
solutions: stat.solutions,
|
||||
type: stat.type,
|
||||
exam: stat.exam,
|
||||
module: stat.module,
|
||||
};
|
||||
|
||||
setUserSolutions(userSolutions.map((x) => (x.exercise === userSolution.exercise ? userSolution : x)));
|
||||
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => x !== id));
|
||||
}
|
||||
|
||||
return checkIfStatHasBeenEvaluated(id);
|
||||
}, 5 * 1000);
|
||||
};
|
||||
|
||||
const updateExamWithUserSolutions = (exam: Exam): Exam => {
|
||||
if (exam.module === "reading" || exam.module === "listening") {
|
||||
const parts = exam.parts.map((p) =>
|
||||
@@ -137,20 +181,19 @@ export default function ExamPage({page}: Props) {
|
||||
|
||||
Promise.all(
|
||||
exam.exercises.map(async (exercise) => {
|
||||
if (exercise.type === "writing") {
|
||||
return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!);
|
||||
}
|
||||
const evaluationID = uuidv4();
|
||||
if (exercise.type === "writing")
|
||||
return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
|
||||
|
||||
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") {
|
||||
return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!);
|
||||
}
|
||||
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking")
|
||||
return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
|
||||
}),
|
||||
)
|
||||
.then((responses) => {
|
||||
setStatsAwaitingEvaluation((prev) => [...prev, ...responses.filter((x) => !!x).map((r) => (r as any).id)]);
|
||||
setUserSolutions([...userSolutions, ...responses.filter((x) => !!x)] as any);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsEvaluationLoading(false);
|
||||
setHasBeenUploaded(false);
|
||||
});
|
||||
}
|
||||
@@ -211,14 +254,15 @@ export default function ExamPage({page}: Props) {
|
||||
user={user!}
|
||||
disableSelection={page === "exams"}
|
||||
onStart={(modules, avoid) => {
|
||||
setSelectedModules(modules);
|
||||
setModuleIndex(0);
|
||||
setAvoidRepeated(avoid);
|
||||
setSelectedModules(modules);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (moduleIndex >= selectedModules.length) {
|
||||
if (moduleIndex >= selectedModules.length || moduleIndex === -1) {
|
||||
return (
|
||||
<Finish
|
||||
isLoading={isEvaluationLoading}
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
||||
|
||||
<div className="w-full flex gap-4">
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Referral</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Referral *</label>
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={[
|
||||
@@ -171,7 +171,7 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Subscription Duration</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Subscription Duration *</label>
|
||||
<Select
|
||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||
options={Object.keys(availableDurations).map((value) => ({
|
||||
|
||||
@@ -4,21 +4,26 @@ import Input from "@/components/Low/Input";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {sendEmailVerification} from "@/utils/email";
|
||||
import axios from "axios";
|
||||
import {useState} from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import {KeyedMutator} from "swr";
|
||||
|
||||
interface Props {
|
||||
queryCode?: string;
|
||||
defaultInformation?: {
|
||||
email: string;
|
||||
name: string;
|
||||
passport_id?: string;
|
||||
};
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
mutateUser: KeyedMutator<User>;
|
||||
sendEmailVerification: typeof sendEmailVerification;
|
||||
}
|
||||
|
||||
export default function RegisterIndividual({queryCode, isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
export default function RegisterIndividual({queryCode, defaultInformation, isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
|
||||
const [name, setName] = useState(defaultInformation?.name || "");
|
||||
const [email, setEmail] = useState(defaultInformation?.email || "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [code, setCode] = useState(queryCode || "");
|
||||
@@ -47,6 +52,7 @@ export default function RegisterIndividual({queryCode, isLoading, setIsLoading,
|
||||
password,
|
||||
type: "individual",
|
||||
code,
|
||||
passport_id: defaultInformation?.passport_id,
|
||||
profilePicture: "/defaultAvatar.png",
|
||||
})
|
||||
.then((response) => {
|
||||
@@ -72,8 +78,16 @@ export default function RegisterIndividual({queryCode, isLoading, setIsLoading,
|
||||
|
||||
return (
|
||||
<form className="flex flex-col items-center gap-6 w-full" onSubmit={register}>
|
||||
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" defaultValue={name} required />
|
||||
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" defaultValue={email} required />
|
||||
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" value={name} required />
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
onChange={(e) => setEmail(e)}
|
||||
placeholder="Enter email address"
|
||||
value={email}
|
||||
disabled={!!defaultInformation?.email}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
@@ -91,7 +105,6 @@ export default function RegisterIndividual({queryCode, isLoading, setIsLoading,
|
||||
required
|
||||
/>
|
||||
|
||||
{/** TODO: Add a checkbox to disable code */}
|
||||
<div className="flex flex-col gap-4 w-full items-start">
|
||||
<Checkbox isChecked={hasCode} onChange={setHasCode}>
|
||||
I have a code
|
||||
@@ -103,6 +116,7 @@ export default function RegisterIndividual({queryCode, isLoading, setIsLoading,
|
||||
onChange={(e) => setCode(e)}
|
||||
placeholder="Enter your registration code (optional)"
|
||||
defaultValue={code}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
||||
if (userGroups.length === 0) return true;
|
||||
|
||||
const userGroupsAdminTypes = userGroups.map((g) => users?.find((u) => u.id === g.admin)?.type).filter((t) => !!t);
|
||||
return userGroupsAdminTypes.every((t) => t !== "admin");
|
||||
return userGroupsAdminTypes.every((t) => t !== "corporate");
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
434
src/pages/api/assignments/[id]/export.tsx
Normal 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, documentId} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import ReactPDF from "@react-pdf/renderer";
|
||||
import GroupTestReport from "@/exams/pdf/group.test.report";
|
||||
import {ref, uploadBytes, getDownloadURL} from "firebase/storage";
|
||||
import {Stat, CorporateUser} from "@/interfaces/user";
|
||||
import {User, DemographicInformation} from "@/interfaces/user";
|
||||
import {Module} from "@/interfaces";
|
||||
import {ModuleScore, StudentData} from "@/interfaces/module.scores";
|
||||
import {SkillExamDetails} from "@/exams/pdf/details/skill.exam";
|
||||
import {LevelExamDetails} from "@/exams/pdf/details/level.exam";
|
||||
import {calculateBandScore, getLevelScore} from "@/utils/score";
|
||||
import {generateQRCode, getRadialProgressPNG, streamToBuffer} from "@/utils/pdf";
|
||||
import {Group} from "@/interfaces/user";
|
||||
import moment from "moment-timezone";
|
||||
|
||||
interface GroupScoreSummaryHelper {
|
||||
score: [number, number];
|
||||
label: string;
|
||||
sessions: 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 post(req, res);
|
||||
}
|
||||
|
||||
const getExamSummary = (score: number) => {
|
||||
if (score > 0.8) {
|
||||
return "Scoring between 81% and 100% on the English exam collectively demonstrates an outstanding level of proficiency in writing, speaking, listening, and reading for this group of students. Mastery of key concepts is evident across all language domains, showcasing not only a high level of skill but also a dedication to excellence. The group is encouraged to continue challenging themselves with advanced material in writing, speaking, listening, and reading to further refine their impressive command of the English language.";
|
||||
}
|
||||
|
||||
if (score > 0.6) {
|
||||
return "The group's average scores between 61% and 80% on the English exam, encompassing writing, speaking, listening, and reading, reflect a commendable level of proficiency. There's evidence of a solid grasp of key concepts collectively, and effective application of skills. Room for refinement and deeper exploration in writing, speaking, listening, and reading remains, presenting an opportunity for the entire group to further their mastery.";
|
||||
}
|
||||
|
||||
if (score > 0.4) {
|
||||
return "Scoring between 41% and 60% on the English exam across writing, speaking, listening, and reading indicates a moderate level of understanding for the group. While there's a commendable grasp of key concepts collectively, refining fundamental skills in writing, speaking, listening, and reading can lead to notable improvement. The group is encouraged to work together with consistent effort and targeted focus on weaker areas.";
|
||||
}
|
||||
|
||||
if (score > 0.2) {
|
||||
return "The group's average scores between 21% and 40% on the English exam, encompassing writing, speaking, listening, and reading, show some understanding of key concepts in each domain. However, there's room for improvement in fundamental skills for the entire group. Strengthening writing, speaking, listening, and reading abilities collectively through consistent effort and focused group study will contribute to overall proficiency.";
|
||||
}
|
||||
|
||||
return "The average performance of this group of students in English, covering writing, speaking, listening, and reading, indicates a collective need for improvement, with scores falling between 0% and 20%. Across all language domains, there's a significant gap in understanding key concepts. Strengthening fundamental skills in writing, speaking, listening, and reading is crucial for the entire group. Implementing a shared, consistent study routine and seeking group 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 collectively demonstrates an outstanding level of proficiency for this group of students, showcasing a mastery of key concepts related to vocabulary and grammar. There's evidence of not only a high level of skill but also a dedication to excellence. The group is encouraged to continue challenging themselves with advanced material in vocabulary and grammar to further refine their impressive command of the English language.";
|
||||
}
|
||||
|
||||
if (score > 0.6) {
|
||||
return "The group's average scores between 61% and 80% on the English exam reflect a commendable level of proficiency with solid grasp of key concepts related to vocabulary and grammar. Room for refinement and deeper exploration in these language skills remains, presenting an opportunity for the entire group to further their mastery. Consistent effort in honing nuanced aspects of vocabulary and grammar will contribute to even greater proficiency.";
|
||||
}
|
||||
|
||||
if (score > 0.4) {
|
||||
return "Scoring between 41% and 60% on the English exam indicates a moderate level of understanding for the group, with commendable grasp of key concepts related to vocabulary and grammar. Refining these fundamental language skills can lead to notable improvement. The group is encouraged to work together with consistent effort and targeted focus on enhancing their vocabulary and grammar.";
|
||||
}
|
||||
|
||||
if (score > 0.2) {
|
||||
return "The group's average scores between 21% and 40% on the English exam show some understanding of key concepts in vocabulary and grammar. However, there's room for improvement in these fundamental language skills for the entire group. Strengthening vocabulary and grammar collectively through consistent effort and focused group study will contribute to overall proficiency.";
|
||||
}
|
||||
|
||||
return "The average performance of this group of students in English suggests a collective need for improvement, with scores falling between 0% and 20%. There's a significant gap in understanding key concepts related to vocabulary and grammar. Strengthening fundamental language skills, such as vocabulary and grammar, is crucial for the entire group. Implementing a shared, consistent study routine and seeking group support in these areas can contribute to substantial progress.";
|
||||
};
|
||||
|
||||
const getPerformanceSummary = (module: Module, score: number) => {
|
||||
if (module === "level") return getLevelSummary(score);
|
||||
return getExamSummary(score);
|
||||
};
|
||||
|
||||
const getScoreAndTotal = (stats: Stat[]) => {
|
||||
return stats.reduce(
|
||||
(acc, {score}) => {
|
||||
return {
|
||||
...acc,
|
||||
correct: acc.correct + score.correct,
|
||||
total: acc.total + score.total,
|
||||
};
|
||||
},
|
||||
{correct: 0, total: 0},
|
||||
);
|
||||
};
|
||||
|
||||
const getLevelScoreForUserExams = (bandScore: number) => {
|
||||
const [level] = getLevelScore(bandScore);
|
||||
return level;
|
||||
};
|
||||
|
||||
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 {
|
||||
assigner: string;
|
||||
assignees: string[];
|
||||
results: any;
|
||||
exams: {module: Module}[];
|
||||
startDate: string;
|
||||
pdf?: string;
|
||||
};
|
||||
if (!data) {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.assigner !== req.session.user.id) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
if (data.pdf) {
|
||||
// if it does, return the pdf url
|
||||
const fileRef = ref(storage, data.pdf);
|
||||
const url = await getDownloadURL(fileRef);
|
||||
|
||||
res.status(200).end(url);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
||||
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;
|
||||
}
|
||||
|
||||
const flattenResults = data.results.reduce((accm: Stat[], entry: any) => {
|
||||
const stats = entry.stats as Stat[];
|
||||
return [...accm, ...stats];
|
||||
}, []) as Stat[];
|
||||
|
||||
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 flattenResultsWithGrade = flattenResults.map((e) => {
|
||||
const focus = users.find((u) => u.id === e.user)?.focus || "academic";
|
||||
const bandScore = calculateBandScore(e.score.correct, e.score.total, e.module, focus);
|
||||
|
||||
return {...e, bandScore};
|
||||
});
|
||||
|
||||
const moduleResults = data.exams.map(({module}) => {
|
||||
const moduleResults = flattenResultsWithGrade.filter((e) => e.module === module);
|
||||
|
||||
const baseBandScore = moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) / moduleResults.length;
|
||||
const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore;
|
||||
const {correct, total} = getScoreAndTotal(moduleResults);
|
||||
const png = getRadialProgressPNG("azul", correct, total);
|
||||
|
||||
return {
|
||||
bandScore,
|
||||
png,
|
||||
module: module[0].toUpperCase() + module.substring(1),
|
||||
score: bandScore,
|
||||
total,
|
||||
code: module,
|
||||
};
|
||||
}) as ModuleScore[];
|
||||
|
||||
const {correct: overallCorrect, total: overallTotal} = getScoreAndTotal(flattenResults);
|
||||
const baseOverallResult = overallCorrect / overallTotal;
|
||||
const overallResult = isNaN(baseOverallResult) ? 0 : baseOverallResult;
|
||||
|
||||
const overallPNG = getRadialProgressPNG("laranja", overallCorrect, overallTotal);
|
||||
// generate the overall detail report
|
||||
const overallDetail = {
|
||||
module: "Overall",
|
||||
score: overallCorrect,
|
||||
total: overallTotal,
|
||||
png: overallPNG,
|
||||
} as ModuleScore;
|
||||
|
||||
const testDetails = [overallDetail, ...moduleResults];
|
||||
// generate the performance summary based on the overall result
|
||||
const baseStat = data.exams[0];
|
||||
const performanceSummary = getPerformanceSummary(
|
||||
// from what I noticed, exams is either an array with the level module
|
||||
// or X modules, either way
|
||||
// as long as I verify the first entry I should be fine
|
||||
baseStat.module,
|
||||
overallResult,
|
||||
);
|
||||
|
||||
const showLevel = baseStat.module === "level";
|
||||
|
||||
// level exams have a different report structure than the skill exams
|
||||
const getCustomData = () => {
|
||||
if (showLevel) {
|
||||
return {
|
||||
title: "GROUP ENGLISH LEVEL TEST RESULT REPORT ",
|
||||
details: <LevelExamDetails detail={overallDetail} title="Group Average CEFR" />,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "GROUP ENGLISH SKILLS TEST RESULT REPORT",
|
||||
details: <SkillExamDetails testDetails={testDetails} />,
|
||||
};
|
||||
};
|
||||
|
||||
const {title, details} = getCustomData();
|
||||
|
||||
const numberOfStudents = data.assignees.length;
|
||||
|
||||
const getStudentsData = async (): Promise<StudentData[]> => {
|
||||
return data.assignees.map((id) => {
|
||||
const user = users.find((u) => u.id === id);
|
||||
const exams = flattenResultsWithGrade.filter((e) => e.user === id);
|
||||
const date =
|
||||
exams.length === 0
|
||||
? "N/A"
|
||||
: new Date(exams[0].date).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const bandScore = exams.length === 0 ? 0 : exams.reduce((accm, curr) => accm + curr.bandScore, 0) / exams.length;
|
||||
const {correct, total} = getScoreAndTotal(exams);
|
||||
|
||||
const result = exams.length === 0 ? "N/A" : `${correct}/${total}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: user?.name || "N/A",
|
||||
email: user?.email || "N/A",
|
||||
gender: user?.demographicInformation?.gender || "N/A",
|
||||
date,
|
||||
result,
|
||||
level: showLevel ? getLevelScoreForUserExams(bandScore) : undefined,
|
||||
bandScore,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const studentsData = await getStudentsData();
|
||||
|
||||
const getGroupScoreSummary = () => {
|
||||
const resultHelper = studentsData.reduce((accm: GroupScoreSummaryHelper[], curr) => {
|
||||
const {bandScore, id} = curr;
|
||||
|
||||
const flooredScore = Math.floor(bandScore);
|
||||
|
||||
const hasMatch = accm.find((a) => a.score.includes(flooredScore));
|
||||
if (hasMatch) {
|
||||
return accm.map((a) => {
|
||||
if (a.score.includes(flooredScore)) {
|
||||
return {
|
||||
...a,
|
||||
sessions: [...a.sessions, id],
|
||||
};
|
||||
}
|
||||
|
||||
return a;
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
...accm,
|
||||
{
|
||||
score: [flooredScore, flooredScore + 0.5],
|
||||
label: `${flooredScore} - ${flooredScore + 0.5}`,
|
||||
sessions: [id],
|
||||
},
|
||||
];
|
||||
}, []) as GroupScoreSummaryHelper[];
|
||||
|
||||
const result = resultHelper.map(({score, label, sessions}) => {
|
||||
const finalLabel = showLevel ? getLevelScore(score[0])[1] : label;
|
||||
return {
|
||||
label: finalLabel,
|
||||
percent: Math.floor((sessions.length / numberOfStudents) * 100),
|
||||
description: `No. Candidates ${sessions.length} of ${numberOfStudents}`,
|
||||
};
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const getInstitution = async () => {
|
||||
try {
|
||||
// due to database inconsistencies, I'll be overprotective here
|
||||
const assignerUserSnap = await getDoc(doc(db, "users", data.assigner));
|
||||
if (assignerUserSnap.exists()) {
|
||||
// we'll need the user in order to get the user data (name, email, focus, etc);
|
||||
const assignerUser = assignerUserSnap.data() as User;
|
||||
|
||||
if (assignerUser.type === "teacher") {
|
||||
// also search for groups where this user belongs
|
||||
const queryGroups = query(collection(db, "groups"), where("participants", "array-contains", assignerUser.id));
|
||||
const groupSnapshot = await getDocs(queryGroups);
|
||||
|
||||
const groups = groupSnapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
})) as Group[];
|
||||
|
||||
if (groups.length > 0) {
|
||||
const adminQuery = query(
|
||||
collection(db, "users"),
|
||||
where(
|
||||
documentId(),
|
||||
"in",
|
||||
groups.map((g) => g.admin),
|
||||
),
|
||||
);
|
||||
const adminUsersSnap = await getDocs(adminQuery);
|
||||
|
||||
const admins = adminUsersSnap.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
})) as CorporateUser[];
|
||||
|
||||
const adminData = admins.find((a) => a.corporateInformation?.companyInformation?.name);
|
||||
if (adminData) {
|
||||
return adminData.corporateInformation.companyInformation.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (assignerUser.type === "corporate" && assignerUser.corporateInformation?.companyInformation?.name) {
|
||||
return assignerUser.corporateInformation.companyInformation.name;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const institution = await getInstitution();
|
||||
const groupScoreSummary = getGroupScoreSummary();
|
||||
const demographicInformation = user.demographicInformation as DemographicInformation;
|
||||
const pdfStream = await ReactPDF.renderToStream(
|
||||
<GroupTestReport
|
||||
title={title}
|
||||
date={moment(data.startDate)
|
||||
.tz(user.demographicInformation?.timezone || "UTC")
|
||||
.format("ll HH:mm:ss")}
|
||||
name={user.name}
|
||||
email={user.email}
|
||||
id={user.id}
|
||||
gender={demographicInformation?.gender}
|
||||
summary={performanceSummary}
|
||||
renderDetails={details}
|
||||
logo={"public/logo_title.png"}
|
||||
qrcode={qrcode}
|
||||
numberOfStudents={numberOfStudents}
|
||||
institution={institution}
|
||||
studentsData={studentsData}
|
||||
showLevel={showLevel}
|
||||
summaryPNG={overallPNG}
|
||||
summaryScore={`${(overallResult * 100).toFixed(0)}%`}
|
||||
groupScoreSummary={groupScoreSummary}
|
||||
passportId={demographicInformation?.passport_id || ""}
|
||||
/>,
|
||||
);
|
||||
|
||||
// generate the file ref for storage
|
||||
const fileName = `${Date.now().toString()}.pdf`;
|
||||
const refName = `assignment_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
|
||||
await updateDoc(docSnap.ref, {
|
||||
pdf: refName,
|
||||
});
|
||||
const url = await getDownloadURL(fileRef);
|
||||
res.status(200).end(url);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ok: false});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.session.user) {
|
||||
const {id} = req.query as {id: string};
|
||||
|
||||
const docSnap = await getDoc(doc(db, "assignments", id));
|
||||
const data = docSnap.data();
|
||||
if (!data) {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.assigner !== req.session.user.id) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.pdf) {
|
||||
const fileRef = ref(storage, data.pdf);
|
||||
const url = await getDownloadURL(fileRef);
|
||||
return res.redirect(url);
|
||||
}
|
||||
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
// 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} from "firebase/firestore";
|
||||
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} from "@/interfaces/exam";
|
||||
import {capitalize, flatten} from "lodash";
|
||||
import {User} from "@/interfaces/user";
|
||||
import moment from "moment";
|
||||
import {sendEmail} from "@/email";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
@@ -34,8 +41,109 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||
res.status(200).json(docs);
|
||||
}
|
||||
|
||||
interface ExamWithUser {
|
||||
module: Module;
|
||||
id: string;
|
||||
assignee: string;
|
||||
}
|
||||
|
||||
function getRandomIndex(arr: any[]): number {
|
||||
const randomIndex = Math.floor(Math.random() * arr.length);
|
||||
return randomIndex;
|
||||
}
|
||||
|
||||
const generateExams = async (generateMultiple: Boolean, selectedModules: Module[], assignees: string[]): Promise<ExamWithUser[]> => {
|
||||
if (generateMultiple) {
|
||||
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
|
||||
const allExams = await assignees.map(async (assignee) => {
|
||||
const selectedModulePromises = await selectedModules.map(async (module: Module) => {
|
||||
try {
|
||||
const exams: Exam[] = await getExams(db, module, "true", assignee);
|
||||
|
||||
const exam = exams[getRandomIndex(exams)];
|
||||
if (exam) {
|
||||
return {module: exam.module, id: exam.id, assignee};
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
const newModules = await Promise.all(selectedModulePromises);
|
||||
|
||||
return newModules;
|
||||
}, []);
|
||||
|
||||
const exams = flatten(await Promise.all(allExams)).filter((x) => x !== null) as ExamWithUser[];
|
||||
return exams;
|
||||
}
|
||||
|
||||
const selectedModulePromises = await selectedModules.map(async (module: Module) => {
|
||||
const exams: Exam[] = await getExams(db, module, "false", undefined);
|
||||
const exam = exams[getRandomIndex(exams)];
|
||||
|
||||
if (exam) {
|
||||
return {module: exam.module, id: exam.id};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const exams = await Promise.all(selectedModulePromises);
|
||||
const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[];
|
||||
return flatten(assignees.map((assignee) => examesFiltered.map((exam) => ({...exam, assignee}))));
|
||||
};
|
||||
|
||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
await setDoc(doc(db, "assignments", uuidv4()), {assigner: req.session.user?.id, ...req.body});
|
||||
const {
|
||||
selectedModules,
|
||||
assignees,
|
||||
// Generate multiple true would generate an unique exam for each user
|
||||
// false would generate the same exam for all users
|
||||
generateMultiple = false,
|
||||
...body
|
||||
} = req.body as {
|
||||
selectedModules: Module[];
|
||||
assignees: string[];
|
||||
generateMultiple: Boolean;
|
||||
name: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees);
|
||||
|
||||
if (exams.length === 0) {
|
||||
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
|
||||
return;
|
||||
}
|
||||
|
||||
await setDoc(doc(db, "assignments", uuidv4()), {
|
||||
assigner: req.session.user?.id,
|
||||
assignees,
|
||||
results: [],
|
||||
exams,
|
||||
...body,
|
||||
});
|
||||
|
||||
res.status(200).json({ok: true});
|
||||
|
||||
for (const assigneeID of assignees) {
|
||||
const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID));
|
||||
if (!assigneeSnapshot.exists()) continue;
|
||||
|
||||
const assignee = {id: assigneeID, ...assigneeSnapshot.data()} as User;
|
||||
const name = body.name;
|
||||
const teacher = req.session.user!;
|
||||
const examModulesLabel = exams.map((x) => capitalize(x.module)).join(", ");
|
||||
const startDate = moment(body.startDate).format("DD/MM/YYYY - HH:mm");
|
||||
const endDate = moment(body.endDate).format("DD/MM/YYYY - HH:mm");
|
||||
|
||||
await sendEmail(
|
||||
"assignment",
|
||||
{user: {name: assignee.name}, assignment: {name, startDate, endDate, modules: examModulesLabel, assigner: teacher.name}},
|
||||
[assignee.email],
|
||||
"EnCoach - New Assignment!",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
23
src/pages/api/code/[id].ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// 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, deleteDoc} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
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;
|
||||
|
||||
const snapshot = await getDoc(doc(db, "codes", id as string));
|
||||
|
||||
res.status(200).json({...snapshot.data(), id: snapshot.id});
|
||||
}
|
||||