Compare commits
100 Commits
feature/pa
...
bug-fixing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bcc303b74 | ||
|
|
8002c71b91 | ||
|
|
31d3232f19 | ||
|
|
4448c2019e | ||
|
|
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 | ||
|
|
bdf65a7215 | ||
|
|
2540398ab0 | ||
|
|
cd8860f6ac | ||
|
|
647807a07c | ||
|
|
094fd05df7 | ||
|
|
1ea9d8e60f | ||
|
|
63998b50d6 | ||
|
|
0f029a21f7 | ||
|
|
7328f5c57f | ||
|
|
12d608879d | ||
|
|
e6c82412bf | ||
|
|
5e8e46ff09 | ||
|
|
7a297a6f6c | ||
|
|
432f4a735f | ||
|
|
a4f79d236d | ||
|
|
a4771d5d29 | ||
|
|
227de4ffc4 | ||
|
|
42fe650ae6 | ||
|
|
0b6a66b12d |
@@ -2,6 +2,25 @@
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: "standalone",
|
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;
|
module.exports = nextConfig;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@next/font": "13.1.6",
|
"@next/font": "13.1.6",
|
||||||
"@paypal/paypal-js": "^7.1.0",
|
"@paypal/paypal-js": "^7.1.0",
|
||||||
"@paypal/react-paypal-js": "^8.1.3",
|
"@paypal/react-paypal-js": "^8.1.3",
|
||||||
|
"@react-pdf/renderer": "^3.1.14",
|
||||||
"@tanstack/react-table": "^8.10.1",
|
"@tanstack/react-table": "^8.10.1",
|
||||||
"@types/node": "18.13.0",
|
"@types/node": "18.13.0",
|
||||||
"@types/react": "18.0.27",
|
"@types/react": "18.0.27",
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
"nodemailer-express-handlebars": "^6.1.0",
|
"nodemailer-express-handlebars": "^6.1.0",
|
||||||
"primeicons": "^6.0.1",
|
"primeicons": "^6.0.1",
|
||||||
"primereact": "^9.2.3",
|
"primereact": "^9.2.3",
|
||||||
|
"qrcode": "^1.5.3",
|
||||||
"random-words": "^2.0.0",
|
"random-words": "^2.0.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
@@ -63,6 +65,7 @@
|
|||||||
"react-string-replace": "^1.1.0",
|
"react-string-replace": "^1.1.0",
|
||||||
"react-toastify": "^9.1.2",
|
"react-toastify": "^9.1.2",
|
||||||
"react-xarrows": "^2.0.2",
|
"react-xarrows": "^2.0.2",
|
||||||
|
"read-excel-file": "^5.7.1",
|
||||||
"short-unique-id": "^5.0.2",
|
"short-unique-id": "^5.0.2",
|
||||||
"stripe": "^13.10.0",
|
"stripe": "^13.10.0",
|
||||||
"swr": "^2.1.3",
|
"swr": "^2.1.3",
|
||||||
@@ -74,11 +77,13 @@
|
|||||||
"zustand": "^4.3.6"
|
"zustand": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/blob-stream": "^0.1.33",
|
||||||
"@types/formidable": "^3.4.0",
|
"@types/formidable": "^3.4.0",
|
||||||
"@types/howler": "^2.2.11",
|
"@types/howler": "^2.2.11",
|
||||||
"@types/lodash": "^4.14.191",
|
"@types/lodash": "^4.14.191",
|
||||||
"@types/nodemailer": "^6.4.11",
|
"@types/nodemailer": "^6.4.11",
|
||||||
"@types/nodemailer-express-handlebars": "^4.0.3",
|
"@types/nodemailer-express-handlebars": "^4.0.3",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react-csv": "^1.1.10",
|
"@types/react-csv": "^1.1.10",
|
||||||
"@types/react-datepicker": "^4.15.1",
|
"@types/react-datepicker": "^4.15.1",
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
|
|||||||
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 {toast} from "react-toastify";
|
||||||
import {KeyedMutator} from "swr";
|
import {KeyedMutator} from "swr";
|
||||||
import CountrySelect from "./Low/CountrySelect";
|
import CountrySelect from "./Low/CountrySelect";
|
||||||
|
import GenderInput from "@/components/High/GenderInput";
|
||||||
|
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -19,6 +21,7 @@ interface Props {
|
|||||||
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||||
const [country, setCountry] = useState<string>();
|
const [country, setCountry] = useState<string>();
|
||||||
const [phone, setPhone] = 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 [gender, setGender] = useState<Gender>();
|
||||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
const [employment, setEmployment] = useState<EmploymentStatus>();
|
||||||
const [position, setPosition] = useState<string>();
|
const [position, setPosition] = useState<string>();
|
||||||
@@ -39,6 +42,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|||||||
gender,
|
gender,
|
||||||
employment: user.type === "corporate" ? undefined : employment,
|
employment: user.type === "corporate" ? undefined : employment,
|
||||||
position: user.type === "corporate" ? position : undefined,
|
position: user.type === "corporate" ? position : undefined,
|
||||||
|
passport_id,
|
||||||
},
|
},
|
||||||
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
||||||
})
|
})
|
||||||
@@ -72,78 +76,29 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="w-full grid grid-cols-2 gap-6">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
<CountrySelect value={country} onChange={setCountry} />
|
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||||
</div>
|
<CountrySelect value={country} onChange={setCountry} />
|
||||||
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
|
</div>
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
|
||||||
<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>
|
</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" && (
|
{user.type === "corporate" && (
|
||||||
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
||||||
)}
|
)}
|
||||||
{user.type !== "corporate" && (
|
{user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between w-full">
|
<div className="flex justify-between w-full">
|
||||||
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
||||||
Back
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
||||||
Confirm
|
Confirm
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length || 0;
|
const total = questions.length || 0;
|
||||||
const correct = answers.filter(
|
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;
|
).length;
|
||||||
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).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>
|
</div>
|
||||||
<span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span>
|
<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">
|
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8">
|
||||||
{questions.map((question, index) => (
|
{questions.map((question, index) => {
|
||||||
<div key={question.id.toString()} className="flex flex-col gap-4">
|
const id = question.id.toString();
|
||||||
<span>
|
|
||||||
{index + 1}. {question.prompt}
|
return (
|
||||||
</span>
|
<div key={question.id.toString()} className="flex flex-col gap-4">
|
||||||
<div className="flex gap-4">
|
<span>
|
||||||
<Button
|
{index + 1}. {question.prompt}
|
||||||
variant={
|
</span>
|
||||||
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "true" ? "solid" : "outline"
|
<div className="flex gap-4">
|
||||||
}
|
<Button
|
||||||
onClick={() => toggleAnswer("true", question.id.toString())}
|
variant={answers.find((x) => x.id.toString() === id)?.solution === "true" ? "solid" : "outline"}
|
||||||
className="!py-2">
|
onClick={() => toggleAnswer("true", id)}
|
||||||
True
|
className="!py-2">
|
||||||
</Button>
|
True
|
||||||
<Button
|
</Button>
|
||||||
variant={
|
<Button
|
||||||
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "false" ? "solid" : "outline"
|
variant={answers.find((x) => x.id.toString() === id)?.solution === "false" ? "solid" : "outline"}
|
||||||
}
|
onClick={() => toggleAnswer("false", id)}
|
||||||
onClick={() => toggleAnswer("false", question.id.toString())}
|
className="!py-2">
|
||||||
className="!py-2">
|
False
|
||||||
False
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
variant={answers.find((x) => x.id.toString() === id)?.solution === "not_given" ? "solid" : "outline"}
|
||||||
variant={
|
onClick={() => toggleAnswer("not_given", id)}
|
||||||
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "not_given"
|
className="!py-2">
|
||||||
? "solid"
|
Not Given
|
||||||
: "outline"
|
</Button>
|
||||||
}
|
</div>
|
||||||
onClick={() => toggleAnswer("not_given", question.id.toString())}
|
|
||||||
className="!py-2">
|
|
||||||
Not Given
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ function Blank({
|
|||||||
const [userInput, setUserInput] = useState(userSolution || "");
|
const [userInput, setUserInput] = useState(userSolution || "");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const words = userInput.split(" ").filter((x) => x !== "");
|
const words = userInput.split(" ");
|
||||||
if (words.length >= maxWords) {
|
if (words.length > maxWords) {
|
||||||
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
|
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
|
||||||
setUserInput(words.join(" ").trim());
|
setUserInput(words.join(" ").trim());
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import {BsArrowRepeat} from "react-icons/bs";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
color?: "rose" | "purple" | "red" | "green";
|
color?: "rose" | "purple" | "red" | "green" | "gray";
|
||||||
variant?: "outline" | "solid";
|
variant?: "outline" | "solid";
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -39,6 +39,11 @@ export default function Button({
|
|||||||
outline:
|
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",
|
"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: {
|
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",
|
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:
|
outline:
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import {BsList} from "react-icons/bs";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import MobileMenu from "./MobileMenu";
|
import MobileMenu from "./MobileMenu";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {Type} from "@/interfaces/user";
|
import {Type} from "@/interfaces/user";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import {isUserFromCorporate} from "@/utils/groups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -22,6 +24,7 @@ interface Props {
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||||
|
|
||||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -44,6 +47,11 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
|||||||
return today.add(7, "days").isAfter(momentDate);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
|
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
|
||||||
@@ -55,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">
|
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8">
|
||||||
{showExpirationDate() && (
|
{showExpirationDate() && (
|
||||||
<Link
|
<Link
|
||||||
href="/payment"
|
href={disablePaymentPage ? "/payment" : ""}
|
||||||
data-tip="Expiry date"
|
data-tip="Expiry date"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
@@ -72,7 +80,8 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
|||||||
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden">
|
<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" />
|
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
|
||||||
<span className="text-right -md:hidden">
|
<span className="text-right -md:hidden">
|
||||||
{user.name} | {USER_TYPE_LABELS[user.type]}
|
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
|
||||||
|
{USER_TYPE_LABELS[user.type]}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const PaymentAssetManager = (props: {
|
|||||||
type: FilesStorage;
|
type: FilesStorage;
|
||||||
reload: () => void;
|
reload: () => void;
|
||||||
paymentId: string;
|
paymentId: string;
|
||||||
canEdit: boolean;
|
|
||||||
}) => {
|
}) => {
|
||||||
const {asset, permissions, type, paymentId} = props;
|
const {asset, permissions, type, paymentId} = props;
|
||||||
|
|
||||||
@@ -122,14 +121,10 @@ const PaymentAssetManager = (props: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BsDownload onClick={downloadAsset} />
|
<BsDownload onClick={downloadAsset} />
|
||||||
{props.canEdit && (
|
<BsArrowRepeat onClick={() => fileInputReplaceRef.current?.click()} />
|
||||||
<>
|
<BsTrash onClick={deleteAsset} />
|
||||||
<BsArrowRepeat onClick={() => fileInputReplaceRef.current?.click()} />
|
{renderFileInput((e: Event) => handleFileChange(e, "patch"), fileInputReplaceRef)}
|
||||||
<BsTrash onClick={deleteAsset} />
|
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
|
||||||
{renderFileInput((e: Event) => handleFileChange(e, "patch"), fileInputReplaceRef)}
|
|
||||||
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,7 +132,7 @@ const PaymentAssetManager = (props: {
|
|||||||
return <span className="loading loading-infinity w-8" />;
|
return <span className="loading loading-infinity w-8" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return props.canEdit ? (
|
return permissions === "write" ? (
|
||||||
<>
|
<>
|
||||||
<BsUpload onClick={() => fileInputRef.current?.click()} />
|
<BsUpload onClick={() => fileInputRef.current?.click()} />
|
||||||
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
|
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
|||||||
Correct
|
Correct
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<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
|
Unanswered
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function MatchSentencesSolutions({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"w-8 h-8 rounded-full z-10 text-white",
|
"w-8 h-8 rounded-full z-10 text-white",
|
||||||
"transition duration-300 ease-in-out",
|
"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-purple",
|
||||||
userSolutions.find((x) => x.question.toString() === id.toString())?.option !== solution && "bg-mti-rose",
|
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 className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<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>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong
|
<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}) {
|
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||||
const optionColor = (option: string) => {
|
const optionColor = (option: string) => {
|
||||||
if (option === solution && !userSolution) {
|
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) {
|
if (option === solution) {
|
||||||
@@ -114,7 +114,7 @@ export default function MultipleChoice({
|
|||||||
Correct
|
Correct
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<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
|
Unanswered
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
return "rose";
|
return "rose";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "red";
|
return "gray";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -67,6 +67,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
{userSolutions &&
|
{userSolutions &&
|
||||||
questions.map((question, index) => {
|
questions.map((question, index) => {
|
||||||
const userSolution = userSolutions.find((x) => x.id === question.id.toString());
|
const userSolution = userSolutions.find((x) => x.id === question.id.toString());
|
||||||
|
const solution = question.solution.toString().toLowerCase() as Solution;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={question.id.toString()} className="flex flex-col gap-4">
|
<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>
|
</span>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant={question.solution === "true" || userSolution?.solution === "true" ? "solid" : "outline"}
|
variant={solution === "true" || userSolution?.solution.toLowerCase() === "true" ? "solid" : "outline"}
|
||||||
className="!py-2"
|
className="!py-2"
|
||||||
color={getButtonColor("true", question.solution, userSolution?.solution)}>
|
color={getButtonColor("true", solution, userSolution?.solution.toLowerCase() as Solution)}>
|
||||||
True
|
True
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={question.solution === "false" || userSolution?.solution === "false" ? "solid" : "outline"}
|
variant={solution === "false" || userSolution?.solution.toLowerCase() === "false" ? "solid" : "outline"}
|
||||||
className="!py-2"
|
className="!py-2"
|
||||||
color={getButtonColor("false", question.solution, userSolution?.solution)}>
|
color={getButtonColor("false", solution, userSolution?.solution.toLowerCase() as Solution)}>
|
||||||
False
|
False
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={
|
variant={
|
||||||
question.solution === "not_given" || userSolution?.solution === "not_given" ? "solid" : "outline"
|
solution === "not_given" || userSolution?.solution.toLowerCase() === "not_given" ? "solid" : "outline"
|
||||||
}
|
}
|
||||||
className="!py-2"
|
className="!py-2"
|
||||||
color={getButtonColor("not_given", question.solution, userSolution?.solution)}>
|
color={getButtonColor("not_given", solution, userSolution?.solution.toLowerCase() as Solution)}>
|
||||||
Not Given
|
Not Given
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +106,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
Correct
|
Correct
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<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
|
Unanswered
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function Blank({
|
|||||||
|
|
||||||
const getSolutionStyling = () => {
|
const getSolutionStyling = () => {
|
||||||
if (!userSolution) {
|
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";
|
return "bg-mti-purple-ultralight text-mti-purple-light";
|
||||||
@@ -131,7 +131,7 @@ export default function WriteBlanksSolutions({
|
|||||||
Correct
|
Correct
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<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
|
Unanswered
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
|
|||||||
@@ -1,15 +1,36 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {WritingExercise} from "@/interfaces/exam";
|
import {WritingExercise} from "@/interfaces/exam";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import {Fragment, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import {Dialog, Tab, Transition} from "@headlessui/react";
|
import {Dialog, Tab, Transition} from "@headlessui/react";
|
||||||
import {writingReverseMarking} from "@/utils/score";
|
import {writingReverseMarking} from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
|
||||||
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const formatSolution = (solution: string, errors: {correction: string | null; misspelled: string}[]) => {
|
||||||
|
const errorRegex = new RegExp(errors.map((x) => `(${x.misspelled})`).join("|"));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{reactStringReplace(solution, errorRegex, (match) => {
|
||||||
|
const correction = errors.find((x) => x.misspelled === match)?.correction;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-tip={correction ? correction : undefined}
|
||||||
|
className={clsx("text-mti-red-light font-medium underline underline-offset-2", correction && "tooltip")}>
|
||||||
|
{match}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{attachment && (
|
{attachment && (
|
||||||
@@ -64,15 +85,17 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full h-full flex flex-col gap-8">
|
<div className="w-full h-full flex flex-col gap-8">
|
||||||
{userSolutions && (
|
{userSolutions && userSolutions.length > 0 && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<span>Your answer:</span>
|
<span>Your answer:</span>
|
||||||
<textarea
|
<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">
|
||||||
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"
|
{userSolutions[0]!.evaluation && userSolutions[0]!.evaluation.misspelled_pairs
|
||||||
contentEditable={false}
|
? formatSolution(
|
||||||
readOnly
|
userSolutions[0]!.solution.replaceAll("\\n", "\n"),
|
||||||
value={userSolutions[0]!.solution}
|
userSolutions[0]!.evaluation.misspelled_pairs,
|
||||||
/>
|
)
|
||||||
|
: userSolutions[0]!.solution.replaceAll("\\n", "\n")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
@@ -116,7 +139,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<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">
|
<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")}
|
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
|
||||||
</span>
|
</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
|
|||||||
@@ -41,23 +41,25 @@ interface Props {
|
|||||||
|
|
||||||
const USER_STATUS_OPTIONS = [
|
const USER_STATUS_OPTIONS = [
|
||||||
{
|
{
|
||||||
value: 'active',
|
value: "active",
|
||||||
label: 'Active',
|
label: "Active",
|
||||||
}, {
|
},
|
||||||
value: 'disabled',
|
{
|
||||||
label: 'Disabled',
|
value: "disabled",
|
||||||
}, {
|
label: "Disabled",
|
||||||
value: 'paymentDue',
|
},
|
||||||
label: 'Payment Due',
|
{
|
||||||
}
|
value: "paymentDue",
|
||||||
|
label: "Payment Due",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const USER_TYPE_OPTIONS = Object.keys(USER_TYPE_LABELS).map((type) => ({
|
const USER_TYPE_OPTIONS = Object.keys(USER_TYPE_LABELS).map((type) => ({
|
||||||
value: type,
|
value: type,
|
||||||
label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]
|
label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency}) => ({ value: currency, label }));
|
const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({value: currency, label}));
|
||||||
|
|
||||||
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false}: Props) => {
|
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false}: Props) => {
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
||||||
@@ -65,17 +67,18 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
const [status, setStatus] = useState(user.status);
|
const [status, setStatus] = useState(user.status);
|
||||||
const [referralAgentLabel, setReferralAgentLabel] = useState<string>();
|
const [referralAgentLabel, setReferralAgentLabel] = useState<string>();
|
||||||
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
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 [referralAgent, setReferralAgent] = useState(user.type === "corporate" ? user.corporateInformation?.referralAgent : undefined);
|
||||||
const [companyName, setCompanyName] = useState(
|
const [companyName, setCompanyName] = useState(
|
||||||
user.type === "corporate"
|
user.type === "corporate"
|
||||||
? user.corporateInformation?.companyInformation.name
|
? user.corporateInformation?.companyInformation.name
|
||||||
: user.type === "agent"
|
: user.type === "agent"
|
||||||
? user.agentInformation.companyName
|
? user.agentInformation?.companyName
|
||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
const [commercialRegistration, setCommercialRegistration] = useState(
|
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 [userAmount, setUserAmount] = useState(user.type === "corporate" ? user.corporateInformation?.companyInformation.userAmount : undefined);
|
||||||
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
|
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
|
||||||
@@ -233,7 +236,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<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"
|
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}
|
options={CURRENCIES_OPTIONS}
|
||||||
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
|
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
|
||||||
onChange={(value) => setPaymentCurrency(value?.value)}
|
onChange={(value) => setPaymentCurrency(value?.value)}
|
||||||
@@ -263,7 +269,11 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
|
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
|
||||||
{referralAgentLabel && (
|
{referralAgentLabel && (
|
||||||
<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"
|
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={[
|
options={[
|
||||||
{value: "", label: "No referral"},
|
{value: "", label: "No referral"},
|
||||||
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
|
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
|
||||||
@@ -289,12 +299,13 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
isDisabled={disabled}
|
// editing country manager should only be available for dev/admin
|
||||||
|
isDisabled={!["developer", "admin"].includes(loggedInUser.type)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-4/12">
|
<div className="flex flex-col gap-3 w-4/12">
|
||||||
{referralAgent !== "" ? (
|
{referralAgent !== "" && loggedInUser.type !== "corporate" ? (
|
||||||
<>
|
<>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Commission</label>
|
<label className="font-normal text-base text-mti-gray-dim">Commission</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -352,6 +363,19 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
{user.type !== "corporate" && (
|
{user.type !== "corporate" && (
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
@@ -397,8 +421,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={user.demographicInformation?.gender}
|
value={user.demographicInformation?.gender}
|
||||||
className="flex flex-row gap-4 justify-between"
|
className="flex flex-row gap-4 justify-between"
|
||||||
disabled={disabled}
|
disabled={disabled}>
|
||||||
>
|
|
||||||
<RadioGroup.Option value="male">
|
<RadioGroup.Option value="male">
|
||||||
{({checked}) => (
|
{({checked}) => (
|
||||||
<span
|
<span
|
||||||
@@ -449,8 +472,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
isChecked={!!expiryDate}
|
isChecked={!!expiryDate}
|
||||||
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
|
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
|
||||||
disabled={disabled}
|
disabled={disabled}>
|
||||||
>
|
|
||||||
Enabled
|
Enabled
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import UserList from "@/pages/(admin)/Lists/UserList";
|
|||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowLeft, BsBriefcaseFill, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPencilSquare, BsBank} from "react-icons/bs";
|
import {BsArrowLeft, BsBriefcaseFill, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPencilSquare, BsBank, BsCurrencyDollar} from "react-icons/bs";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import IconCard from "./IconCard";
|
import IconCard from "./IconCard";
|
||||||
import useFilterStore from "@/stores/listFilterStore";
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
|
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -26,6 +27,7 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
const {stats} = useStats(user.id);
|
const {stats} = useStats(user.id);
|
||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
const {groups} = useGroups();
|
const {groups} = useGroups();
|
||||||
|
const { pending, done } = usePaymentStatusUsers();
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -146,6 +148,26 @@ 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 = () => {
|
const InactiveCountryManagerList = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -243,7 +265,7 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveStudents")}
|
onClick={() => setPage("inactiveStudents")}
|
||||||
Icon={BsPerson}
|
Icon={BsPersonFill}
|
||||||
label="Inactive Students"
|
label="Inactive Students"
|
||||||
value={
|
value={
|
||||||
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
||||||
@@ -253,14 +275,14 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveCountryManagers")}
|
onClick={() => setPage("inactiveCountryManagers")}
|
||||||
Icon={BsPerson}
|
Icon={BsBriefcaseFill}
|
||||||
label="Inactive Country Managers"
|
label="Inactive Country Managers"
|
||||||
value={users.filter(inactiveCountryManagerFilter).length}
|
value={users.filter(inactiveCountryManagerFilter).length}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveCorporate")}
|
onClick={() => setPage("inactiveCorporate")}
|
||||||
Icon={BsPerson}
|
Icon={BsBank}
|
||||||
label="Inactive Corporate"
|
label="Inactive Corporate"
|
||||||
value={
|
value={
|
||||||
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
||||||
@@ -268,6 +290,20 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
}
|
}
|
||||||
color="rose"
|
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>
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
||||||
@@ -477,6 +513,8 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
{page === "inactiveStudents" && <InactiveStudentsList />}
|
{page === "inactiveStudents" && <InactiveStudentsList />}
|
||||||
{page === "inactiveCorporate" && <InactiveCorporateList />}
|
{page === "inactiveCorporate" && <InactiveCorporateList />}
|
||||||
{page === "inactiveCountryManagers" && <InactiveCountryManagerList />}
|
{page === "inactiveCountryManagers" && <InactiveCountryManagerList />}
|
||||||
|
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
|
||||||
|
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
|
||||||
{page === "" && <DefaultDashboard />}
|
{page === "" && <DefaultDashboard />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,20 +2,17 @@
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import useUsers from "@/hooks/useUsers";
|
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 UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowLeft, BsPersonFill, BsBank} from "react-icons/bs";
|
import {BsArrowLeft, BsPersonFill, BsBank, BsCurrencyDollar} from "react-icons/bs";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
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 IconCard from "./IconCard";
|
||||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -29,6 +26,7 @@ export default function AgentDashboard({user}: Props) {
|
|||||||
const {stats} = useStats();
|
const {stats} = useStats();
|
||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
const {groups} = useGroups(user.id);
|
const {groups} = useGroups(user.id);
|
||||||
|
const { pending, done } = usePaymentStatusUsers();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && page === "");
|
||||||
@@ -40,9 +38,9 @@ export default function AgentDashboard({user}: Props) {
|
|||||||
const inactiveReferredCorporateFilter = (x: User) =>
|
const inactiveReferredCorporateFilter = (x: User) =>
|
||||||
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
const UserDisplay = ({ displayUser, allowClick = true }: {displayUser: User, allowClick?: boolean}) => (
|
||||||
<div
|
<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">
|
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" />
|
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||||
<div className="flex flex-col gap-1 items-start">
|
<div className="flex flex-col gap-1 items-start">
|
||||||
@@ -66,7 +64,7 @@ export default function AgentDashboard({user}: Props) {
|
|||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">Corporate ({users.filter(referredCorporateFilter).length})</h2>
|
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(referredCorporateFilter).length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filters={[referredCorporateFilter]} />
|
<UserList user={user} filters={[referredCorporateFilter]} />
|
||||||
@@ -84,7 +82,7 @@ export default function AgentDashboard({user}: Props) {
|
|||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(inactiveReferredCorporateFilter).length})</h2>
|
<h2 className="text-2xl font-semibold">Inactive Referred Corporate ({users.filter(inactiveReferredCorporateFilter).length})</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserList user={user} filters={[inactiveReferredCorporateFilter]} />
|
<UserList user={user} filters={[inactiveReferredCorporateFilter]} />
|
||||||
@@ -106,7 +104,26 @@ export default function AgentDashboard({user}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">Corporate ({users.filter(filter).length})</h2>
|
<h2 className="text-2xl font-semibold">Corporate ({users.filter(filter).length})</h2>
|
||||||
</div>
|
</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]} />
|
<UserList user={user} filters={[filter]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -117,15 +134,15 @@ export default function AgentDashboard({user}: Props) {
|
|||||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
|
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("referredCorporate")}
|
onClick={() => setPage("referredCorporate")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsBank}
|
||||||
label="Corporate"
|
label="Referred Corporate"
|
||||||
value={users.filter(referredCorporateFilter).length}
|
value={users.filter(referredCorporateFilter).length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveReferredCorporate")}
|
onClick={() => setPage("inactiveReferredCorporate")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsBank}
|
||||||
label="Inactive Corporate"
|
label="Inactive Referred Corporate"
|
||||||
value={users.filter(inactiveReferredCorporateFilter).length}
|
value={users.filter(inactiveReferredCorporateFilter).length}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
@@ -136,17 +153,31 @@ export default function AgentDashboard({user}: Props) {
|
|||||||
value={users.filter(corporateFilter).length}
|
value={users.filter(corporateFilter).length}
|
||||||
color="purple"
|
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>
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Latest Corporate</span>
|
<span className="p-4">Latest Referred Corporate</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{users
|
||||||
.filter(referredCorporateFilter)
|
.filter(referredCorporateFilter)
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} displayUser={x} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,12 +188,12 @@ export default function AgentDashboard({user}: Props) {
|
|||||||
.filter(corporateFilter)
|
.filter(corporateFilter)
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} displayUser={x} allowClick={false} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Corporate expiring in 1 month</span>
|
<span className="p-4">Referenced corporate expiring in 1 month</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{users
|
||||||
.filter(
|
.filter(
|
||||||
@@ -172,7 +203,7 @@ export default function AgentDashboard({user}: Props) {
|
|||||||
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||||
)
|
)
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} displayUser={x} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,6 +236,8 @@ export default function AgentDashboard({user}: Props) {
|
|||||||
{page === "referredCorporate" && <ReferredCorporateList />}
|
{page === "referredCorporate" && <ReferredCorporateList />}
|
||||||
{page === "corporate" && <CorporateList />}
|
{page === "corporate" && <CorporateList />}
|
||||||
{page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
|
{page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
|
||||||
|
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
|
||||||
|
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
|
||||||
{page === "" && <DefaultDashboard />}
|
{page === "" && <DefaultDashboard />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,19 +2,20 @@ import ProgressBar from "@/components/Low/ProgressBar";
|
|||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import {Stat} from "@/interfaces/user";
|
|
||||||
import {calculateBandScore} from "@/utils/score";
|
import {calculateBandScore} from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useState} from "react";
|
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
|
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClick?: () => void;
|
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 {users} = useUsers();
|
||||||
|
const renderPdfIcon = usePDFDownload("assignments");
|
||||||
|
|
||||||
const calculateAverageModuleScore = (module: Module) => {
|
const calculateAverageModuleScore = (module: Module) => {
|
||||||
const resultModuleBandScores = results.map((r) => {
|
const resultModuleBandScores = results.map((r) => {
|
||||||
@@ -33,7 +34,10 @@ export default function AssignmentCard({id, name, assigner, startDate, endDate,
|
|||||||
onClick={onClick}
|
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">
|
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">
|
<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
|
<ProgressBar
|
||||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||||
percentage={(results.length / assignees.length) * 100}
|
percentage={(results.length / assignees.length) * 100}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import ProfileSummary from "@/components/ProfileSummary";
|
|||||||
import useAssignments from "@/hooks/useAssignments";
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import {User} from "@/interfaces/user";
|
import {CorporateUser, User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
||||||
import {averageScore, groupBySession} from "@/utils/stats";
|
import {averageScore, groupBySession} from "@/utils/stats";
|
||||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
||||||
@@ -18,6 +19,7 @@ import {capitalize} from "lodash";
|
|||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
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 {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
|
|
||||||
@@ -26,6 +28,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StudentDashboard({user}: Props) {
|
export default function StudentDashboard({user}: Props) {
|
||||||
|
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
||||||
|
|
||||||
const {stats} = useStats(user.id);
|
const {stats} = useStats(user.id);
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
||||||
|
|
||||||
@@ -37,6 +41,10 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
const setAssignment = useExamStore((state) => state.setAssignment);
|
const setAssignment = useExamStore((state) => state.setAssignment);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUserCorporate(user.id).then(setCorporateUserToShow);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const startAssignment = (assignment: Assignment) => {
|
const startAssignment = (assignment: Assignment) => {
|
||||||
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
|
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
|
||||||
|
|
||||||
@@ -60,6 +68,11 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
|
|
||||||
return (
|
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
|
<ProfileSummary
|
||||||
user={user}
|
user={user}
|
||||||
items={[
|
items={[
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import useUsers from "@/hooks/useUsers";
|
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 UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -44,6 +44,7 @@ import clsx from "clsx";
|
|||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import AssignmentCreator from "./AssignmentCreator";
|
import AssignmentCreator from "./AssignmentCreator";
|
||||||
import AssignmentView from "./AssignmentView";
|
import AssignmentView from "./AssignmentView";
|
||||||
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -55,6 +56,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
||||||
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
||||||
|
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
||||||
|
|
||||||
const {stats} = useStats();
|
const {stats} = useStats();
|
||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
@@ -65,6 +67,10 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && page === "");
|
||||||
}, [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 studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
|
||||||
|
|
||||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||||
@@ -226,7 +232,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{assignments.filter(pastFilter).map((a) => (
|
{assignments.filter(pastFilter).map((a) => (
|
||||||
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} />
|
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -236,7 +242,16 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
|
|
||||||
const DefaultDashboard = () => (
|
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
|
<IconCard
|
||||||
onClick={() => setPage("students")}
|
onClick={() => setPage("students")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{exerciseIndex === -1 && (
|
{exerciseIndex === -1 && partIndex === 0 && (
|
||||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
||||||
Start now
|
Start now
|
||||||
</Button>
|
</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;
|
||||||
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 };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {Module} from ".";
|
import {Module} from ".";
|
||||||
|
|
||||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||||
|
export type Variant = "diagnostic" | "partial";
|
||||||
|
|
||||||
export interface ReadingExam {
|
export interface ReadingExam {
|
||||||
parts: ReadingPart[];
|
parts: ReadingPart[];
|
||||||
@@ -9,6 +10,7 @@ export interface ReadingExam {
|
|||||||
minTimer: number;
|
minTimer: number;
|
||||||
type: "academic" | "general";
|
type: "academic" | "general";
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
|
variant?: Variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReadingPart {
|
export interface ReadingPart {
|
||||||
@@ -25,6 +27,7 @@ export interface LevelExam {
|
|||||||
exercises: Exercise[];
|
exercises: Exercise[];
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
|
variant?: Variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListeningExam {
|
export interface ListeningExam {
|
||||||
@@ -33,6 +36,7 @@ export interface ListeningExam {
|
|||||||
module: "listening";
|
module: "listening";
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
|
variant?: Variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListeningPart {
|
export interface ListeningPart {
|
||||||
@@ -63,6 +67,7 @@ export interface WritingExam {
|
|||||||
exercises: Exercise[];
|
exercises: Exercise[];
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
|
variant?: Variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WordCounter {
|
interface WordCounter {
|
||||||
@@ -76,6 +81,7 @@ export interface SpeakingExam {
|
|||||||
exercises: Exercise[];
|
exercises: Exercise[];
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
|
variant?: Variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Exercise =
|
export type Exercise =
|
||||||
@@ -92,6 +98,7 @@ export interface Evaluation {
|
|||||||
comment: string;
|
comment: string;
|
||||||
overall: number;
|
overall: number;
|
||||||
task_response: {[key: string]: number};
|
task_response: {[key: string]: number};
|
||||||
|
misspelled_pairs?: {correction: string | null; misspelled: string}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InteractiveSpeakingEvaluation extends Evaluation {
|
interface InteractiveSpeakingEvaluation extends Evaluation {
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
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[];
|
||||||
|
}
|
||||||
@@ -77,6 +77,7 @@ export interface DemographicInformation {
|
|||||||
phone: string;
|
phone: string;
|
||||||
gender: Gender;
|
gender: Gender;
|
||||||
employment: EmploymentStatus;
|
employment: EmploymentStatus;
|
||||||
|
passport_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DemographicCorporateInformation {
|
export interface DemographicCorporateInformation {
|
||||||
|
|||||||
@@ -6,26 +6,42 @@ import {Type, User} from "@/interfaces/user";
|
|||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize, uniqBy} from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import {useFilePicker} from "use-file-picker";
|
import {useFilePicker} from "use-file-picker";
|
||||||
|
import readXlsxFile from "read-excel-file";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import {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}) {
|
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 [isLoading, setIsLoading] = useState(false);
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
|
|
||||||
const {openFilePicker, filesContent} = useFilePicker({
|
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||||
accept: ".txt",
|
accept: ".xlsx",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
|
readAs: "ArrayBuffer",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,29 +57,43 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filesContent.length > 0) {
|
if (filesContent.length > 0) {
|
||||||
const file = filesContent[0];
|
const file = filesContent[0];
|
||||||
const emails = file.content
|
readXlsxFile(file.content).then((rows) => {
|
||||||
.split("\n")
|
const information = uniqBy(
|
||||||
.map((x) => x.trim())
|
rows
|
||||||
.filter((x) => new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/).test(x))
|
.map((row) => {
|
||||||
.filter((x) => !users.map((u) => u.email).includes(x));
|
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}`,
|
||||||
|
passport_id: passport_id.toString(),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
})
|
||||||
|
.filter((x) => !!x) as typeof infos,
|
||||||
|
(x) => x.email,
|
||||||
|
);
|
||||||
|
|
||||||
if (emails.length === 0) {
|
if (information.length === 0) {
|
||||||
toast.error("Please upload a .txt file containing e-mails, one per line! All already registered e-mails have also been ignored!");
|
toast.error(
|
||||||
return;
|
"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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filesContent]);
|
}, [filesContent]);
|
||||||
|
|
||||||
const generateCode = (type: Type) => {
|
const generateCode = (type: Type) => {
|
||||||
const uid = new ShortUniqueId();
|
const uid = new ShortUniqueId();
|
||||||
const codes = emails.map(() => uid.randomUUID(6));
|
const codes = infos.map(() => uid.randomUUID(6));
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.post("/api/code", {type, codes, emails, expiryDate})
|
.post("/api/code", {type, codes, infos: infos, expiryDate})
|
||||||
.then(({data, status}) => {
|
.then(({data, status}) => {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
toast.success(`Successfully generated ${capitalize(type)} codes and they have been notified by e-mail!`, {toastId: "success"});
|
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 (
|
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>
|
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
|
||||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
<span>Please upload an Excel file with the following format:</span>
|
||||||
</Button>
|
<table className="w-full">
|
||||||
{user && (user.type === "developer" || user.type === "admin") && (
|
<thead>
|
||||||
<>
|
<tr>
|
||||||
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2">
|
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
||||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}>
|
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
||||||
Enabled
|
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
||||||
</Checkbox>
|
<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>
|
</div>
|
||||||
{isExpiryDateEnabled && (
|
</div>
|
||||||
<ReactDatePicker
|
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
||||||
className={clsx(
|
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
</Button>
|
||||||
"hover:border-mti-purple tooltip",
|
{user && (user.type === "developer" || user.type === "admin") && (
|
||||||
"transition duration-300 ease-in-out",
|
<>
|
||||||
)}
|
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2">
|
||||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
||||||
dateFormat="dd/MM/yyyy"
|
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}>
|
||||||
selected={expiryDate}
|
Enabled
|
||||||
onChange={(date) => setExpiryDate(date)}
|
</Checkbox>
|
||||||
/>
|
</div>
|
||||||
)}
|
{isExpiryDateEnabled && (
|
||||||
</>
|
<ReactDatePicker
|
||||||
)}
|
className={clsx(
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Select the type of user they should be</label>
|
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
{user && (
|
"hover:border-mti-purple tooltip",
|
||||||
<select
|
"transition duration-300 ease-in-out",
|
||||||
defaultValue="student"
|
)}
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||||
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">
|
dateFormat="dd/MM/yyyy"
|
||||||
{Object.keys(USER_TYPE_LABELS).map((type) => (
|
selected={expiryDate}
|
||||||
<option key={type} value={type}>
|
onChange={(date) => setExpiryDate(date)}
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
/>
|
||||||
</option>
|
)}
|
||||||
))}
|
</>
|
||||||
</select>
|
)}
|
||||||
)}
|
<label className="font-normal text-base text-mti-gray-dim">Select the type of user they should be</label>
|
||||||
<Button onClick={() => generateCode(type)} disabled={emails.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
{user && (
|
||||||
Generate & Send
|
<select
|
||||||
</Button>
|
defaultValue="student"
|
||||||
</div>
|
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 {toast} from "react-toastify";
|
||||||
import ShortUniqueId from "short-unique-id";
|
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}) {
|
export default function CodeGenerator({user}: {user: User}) {
|
||||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||||
@@ -63,11 +72,13 @@ export default function CodeGenerator({user}: {user: User}) {
|
|||||||
defaultValue="student"
|
defaultValue="student"
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
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">
|
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) => (
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
<option key={type} value={type}>
|
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type))
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
.map((type) => (
|
||||||
</option>
|
<option key={type} value={type}>
|
||||||
))}
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
{user && (user.type === "developer" || user.type === "admin") && (
|
{user && (user.type === "developer" || user.type === "admin") && (
|
||||||
|
|||||||
@@ -9,16 +9,18 @@ import {Disclosure, Transition} from "@headlessui/react";
|
|||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize, uniq, uniqBy} from "lodash";
|
||||||
import {useEffect, useRef, useState} from "react";
|
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 {toast} from "react-toastify";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import {uuidv4} from "@firebase/util";
|
import {uuidv4} from "@firebase/util";
|
||||||
import {useFilePicker} from "use-file-picker";
|
import {useFilePicker} from "use-file-picker";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
|
import readXlsxFile from "read-excel-file";
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Group>();
|
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 {
|
interface CreateDialogProps {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -31,40 +33,49 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
||||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||||
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
||||||
const {openFilePicker, filesContent} = useFilePicker({
|
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||||
accept: ".txt",
|
accept: ".xlsx",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
|
readAs: "ArrayBuffer",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filesContent.length > 0) {
|
if (filesContent.length > 0) {
|
||||||
const file = filesContent[0];
|
const file = filesContent[0];
|
||||||
const emails = file.content
|
readXlsxFile(file.content).then((rows) => {
|
||||||
.toLowerCase()
|
const emails = uniq(
|
||||||
.split("\n")
|
rows
|
||||||
.filter((x) => new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/).test(x));
|
.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) {
|
if (emails.length === 0) {
|
||||||
toast.error("Please upload a .txt file containing e-mails, one per line!");
|
toast.error("Please upload an Excel file containing e-mails!");
|
||||||
return;
|
clear();
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
||||||
const filteredUsers = emailUsers.filter(
|
const filteredUsers = emailUsers.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
((user.type === "developer" || user.type === "admin" || user.type === "corporate") &&
|
((user.type === "developer" || user.type === "admin" || user.type === "corporate") &&
|
||||||
(x?.type === "student" || x?.type === "teacher")) ||
|
(x?.type === "student" || x?.type === "teacher")) ||
|
||||||
(user.type === "teacher" && x?.type === "student"),
|
(user.type === "teacher" && x?.type === "student"),
|
||||||
);
|
);
|
||||||
|
|
||||||
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
||||||
toast.success(
|
toast.success(
|
||||||
user.type !== "teacher"
|
user.type !== "teacher"
|
||||||
? "Added all teachers and students found in the file you've provided!"
|
? "Added all teachers and students found in the file you've provided!"
|
||||||
: "Added all students found in the file you've provided!",
|
: "Added all students found in the file you've provided!",
|
||||||
{toastId: "upload-success"},
|
{toastId: "upload-success"},
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filesContent, user.type, users]);
|
}, [filesContent, user.type, users]);
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
@@ -90,7 +101,12 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
|
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<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">
|
<div className="flex gap-8 w-full">
|
||||||
<Select
|
<Select
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -119,9 +135,11 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button className="w-full max-w-[300px]" onClick={openFilePicker} variant="outline">
|
{user.type !== "teacher" && (
|
||||||
{filesContent.length === 0 ? "Upload participants .txt file" : filesContent[0].name}
|
<Button className="w-full max-w-[300px]" onClick={openFilePicker} variant="outline">
|
||||||
</Button>
|
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export default function ExamPage({page}: Props) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => setSessionId(uuidv4()), []);
|
useEffect(() => setSessionId(uuidv4()), []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.type === "developer") console.log(exam);
|
||||||
|
}, [exam, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedModules.length > 0 && timeSpent === 0 && !showSolutions;
|
selectedModules.length > 0 && timeSpent === 0 && !showSolutions;
|
||||||
@@ -64,6 +67,10 @@ export default function ExamPage({page}: Props) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedModules.length]);
|
}, [selectedModules.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSolutions) setModuleIndex(-1);
|
||||||
|
}, [showSolutions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
|
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
|
||||||
@@ -247,14 +254,15 @@ export default function ExamPage({page}: Props) {
|
|||||||
user={user!}
|
user={user!}
|
||||||
disableSelection={page === "exams"}
|
disableSelection={page === "exams"}
|
||||||
onStart={(modules, avoid) => {
|
onStart={(modules, avoid) => {
|
||||||
setSelectedModules(modules);
|
setModuleIndex(0);
|
||||||
setAvoidRepeated(avoid);
|
setAvoidRepeated(avoid);
|
||||||
|
setSelectedModules(modules);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (moduleIndex >= selectedModules.length) {
|
if (moduleIndex >= selectedModules.length || moduleIndex === -1) {
|
||||||
return (
|
return (
|
||||||
<Finish
|
<Finish
|
||||||
isLoading={isEvaluationLoading}
|
isLoading={isEvaluationLoading}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
|||||||
|
|
||||||
<div className="w-full flex gap-4">
|
<div className="w-full flex gap-4">
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<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
|
<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="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={[
|
options={[
|
||||||
@@ -171,7 +171,7 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<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
|
<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="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) => ({
|
options={Object.keys(availableDurations).map((value) => ({
|
||||||
|
|||||||
@@ -10,16 +10,20 @@ import {KeyedMutator} from "swr";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
queryCode?: string;
|
queryCode?: string;
|
||||||
defaultEmail?: string;
|
defaultInformation?: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
passport_id?: string;
|
||||||
|
};
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
setIsLoading: (isLoading: boolean) => void;
|
setIsLoading: (isLoading: boolean) => void;
|
||||||
mutateUser: KeyedMutator<User>;
|
mutateUser: KeyedMutator<User>;
|
||||||
sendEmailVerification: typeof sendEmailVerification;
|
sendEmailVerification: typeof sendEmailVerification;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RegisterIndividual({queryCode, defaultEmail, isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
|
export default function RegisterIndividual({queryCode, defaultInformation, isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState(defaultInformation?.name || "");
|
||||||
const [email, setEmail] = useState(defaultEmail || "");
|
const [email, setEmail] = useState(defaultInformation?.email || "");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [code, setCode] = useState(queryCode || "");
|
const [code, setCode] = useState(queryCode || "");
|
||||||
@@ -48,6 +52,7 @@ export default function RegisterIndividual({queryCode, defaultEmail, isLoading,
|
|||||||
password,
|
password,
|
||||||
type: "individual",
|
type: "individual",
|
||||||
code,
|
code,
|
||||||
|
passport_id: defaultInformation?.passport_id,
|
||||||
profilePicture: "/defaultAvatar.png",
|
profilePicture: "/defaultAvatar.png",
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -73,14 +78,14 @@ export default function RegisterIndividual({queryCode, defaultEmail, isLoading,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex flex-col items-center gap-6 w-full" onSubmit={register}>
|
<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="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" value={name} required />
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
onChange={(e) => setEmail(e)}
|
onChange={(e) => setEmail(e)}
|
||||||
placeholder="Enter email address"
|
placeholder="Enter email address"
|
||||||
value={email}
|
value={email}
|
||||||
disabled={!!defaultEmail}
|
disabled={!!defaultInformation?.email}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -100,7 +105,6 @@ export default function RegisterIndividual({queryCode, defaultEmail, isLoading,
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/** TODO: Add a checkbox to disable code */}
|
|
||||||
<div className="flex flex-col gap-4 w-full items-start">
|
<div className="flex flex-col gap-4 w-full items-start">
|
||||||
<Checkbox isChecked={hasCode} onChange={setHasCode}>
|
<Checkbox isChecked={hasCode} onChange={setHasCode}>
|
||||||
I have a code
|
I have a code
|
||||||
@@ -112,6 +116,7 @@ export default function RegisterIndividual({queryCode, defaultEmail, isLoading,
|
|||||||
onChange={(e) => setCode(e)}
|
onChange={(e) => setCode(e)}
|
||||||
placeholder="Enter your registration code (optional)"
|
placeholder="Enter your registration code (optional)"
|
||||||
defaultValue={code}
|
defaultValue={code}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
|||||||
if (userGroups.length === 0) return true;
|
if (userGroups.length === 0) return true;
|
||||||
|
|
||||||
const userGroupsAdminTypes = userGroups.map((g) => users?.find((u) => u.id === g.admin)?.type).filter((t) => !!t);
|
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 (
|
return (
|
||||||
|
|||||||
496
src/pages/api/assignments/[id]/export.tsx
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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={new Date(data.startDate).toLocaleString()}
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -19,7 +19,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {type, codes, emails, expiryDate} = req.body as {type: Type; codes: string[]; emails?: string[]; expiryDate: null | Date};
|
const {type, codes, infos, expiryDate} = req.body as {
|
||||||
|
type: Type;
|
||||||
|
codes: string[];
|
||||||
|
infos?: {email: string; name: string; passport_id: string}[];
|
||||||
|
expiryDate: null | Date;
|
||||||
|
};
|
||||||
const permission = PERMISSIONS.generateCode[type];
|
const permission = PERMISSIONS.generateCode[type];
|
||||||
|
|
||||||
if (!permission.includes(req.session.user.type)) {
|
if (!permission.includes(req.session.user.type)) {
|
||||||
@@ -47,8 +52,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const codeRef = doc(db, "codes", code);
|
const codeRef = doc(db, "codes", code);
|
||||||
await setDoc(codeRef, {type, code, creator: req.session.user!.id, expiryDate});
|
await setDoc(codeRef, {type, code, creator: req.session.user!.id, expiryDate});
|
||||||
|
|
||||||
if (emails && emails.length > index) {
|
if (infos && infos.length > index) {
|
||||||
await setDoc(codeRef, {email: emails[index]}, {merge: true});
|
const {email, name, passport_id} = infos[index];
|
||||||
|
await setDoc(codeRef, {email: email.trim(), name: name.trim(), passport_id: passport_id.trim()}, {merge: true});
|
||||||
|
|
||||||
const transport = prepareMailer();
|
const transport = prepareMailer();
|
||||||
const mailOptions = prepareMailOptions(
|
const mailOptions = prepareMailOptions(
|
||||||
@@ -56,7 +62,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
type,
|
type,
|
||||||
code,
|
code,
|
||||||
},
|
},
|
||||||
[emails[index]],
|
[email.trim()],
|
||||||
"EnCoach Registration",
|
"EnCoach Registration",
|
||||||
"main",
|
"main",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore";
|
import {getFirestore, collection, getDocs, setDoc, doc, query, where} from "firebase/firestore";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Group} from "@/interfaces/user";
|
import {Group} from "@/interfaces/user";
|
||||||
@@ -22,25 +22,19 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {admin} = req.query as {admin: string};
|
const {admin, participant} = req.query as {admin: string; participant: string};
|
||||||
const snapshot = await getDocs(collection(db, "groups"));
|
|
||||||
|
|
||||||
const groups: Group[] = snapshot.docs.map((doc) => ({
|
const queryConstraints = [
|
||||||
|
...(admin ? [where("admin", "==", admin)] : []),
|
||||||
|
...(participant ? [where("participants", "array-contains", participant)] : []),
|
||||||
|
];
|
||||||
|
const snapshot = await getDocs(queryConstraints.length > 0 ? query(collection(db, "groups"), ...queryConstraints) : collection(db, "groups"));
|
||||||
|
const groups = snapshot.docs.map((doc) => ({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
...doc.data(),
|
...doc.data(),
|
||||||
})) as Group[];
|
})) as Group[];
|
||||||
|
|
||||||
if (admin) {
|
res.status(200).json(groups);
|
||||||
res.status(200).json(groups.filter((x) => x.admin === admin));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json(
|
|
||||||
snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ const db = getFirestore(app);
|
|||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
|
||||||
res.status(401).json({ok: false});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "GET") await get(req, res);
|
if (req.method === "GET") await get(req, res);
|
||||||
if (req.method === "POST") await post(req, res);
|
if (req.method === "POST") await post(req, res);
|
||||||
}
|
}
|
||||||
@@ -34,6 +29,11 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ok: false});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!["developer", "admin"].includes(req.session.user!.type))
|
if (!["developer", "admin"].includes(req.session.user!.type))
|
||||||
return res.status(403).json({ok: false, reason: "You do not have permission to create a new package"});
|
return res.status(403).json({ok: false, reason: "You do not have permission to create a new package"});
|
||||||
|
|
||||||
|
|||||||
85
src/pages/api/payments/assigned.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// 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,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { Payment } from "@/interfaces/paypal";
|
||||||
|
import { PaymentsStatus } from "@/interfaces/user.payments";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "GET") return await get(req, res);
|
||||||
|
|
||||||
|
res.status(404).json(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// user can fetch payments assigned to him as an agent
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if it's an admin, don't apply query filters
|
||||||
|
const whereClauses = ["admin", "developer"].includes(req.session.user.type)
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
// where("agent", "==", "xRMirufz6PPQqxKBgvPTWiWKBD63"),
|
||||||
|
where(req.session.user.type, "==", req.session.user.id),
|
||||||
|
// Based on the logic of query we should be able to do this:
|
||||||
|
// where("isPaid", "==", paid === "paid"),
|
||||||
|
// but for some reason it is ignoring all but the first clause
|
||||||
|
// I opted into only fetching relevant content for the user
|
||||||
|
// and then filter it with JS
|
||||||
|
];
|
||||||
|
|
||||||
|
const codeQuery = query(collection(db, "payments"), ...whereClauses);
|
||||||
|
|
||||||
|
const snapshot = await getDocs(codeQuery);
|
||||||
|
if (snapshot.empty) {
|
||||||
|
res.status(200).json({
|
||||||
|
pending: [],
|
||||||
|
done: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const docs = snapshot.docs.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
...doc.data(),
|
||||||
|
})) as Payment[];
|
||||||
|
|
||||||
|
const paidStatusEntries = docs.reduce(
|
||||||
|
(acc: PaymentsStatus, doc) => {
|
||||||
|
if (doc.isPaid) {
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
done: [...acc.done, doc.corporate],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
pending: [...acc.pending, doc.corporate],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pending: [],
|
||||||
|
done: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
res.status(200).json({
|
||||||
|
pending: [...new Set(paidStatusEntries.pending)],
|
||||||
|
done: [...new Set(paidStatusEntries.done)],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -37,8 +37,9 @@ async function register(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {email, password, code} = req.body as {
|
const {email, passport_id, password, code} = req.body as {
|
||||||
email: string;
|
email: string;
|
||||||
|
passport_id?: string;
|
||||||
password: string;
|
password: string;
|
||||||
code?: string;
|
code?: string;
|
||||||
};
|
};
|
||||||
@@ -67,6 +68,7 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
focus: "academic",
|
focus: "academic",
|
||||||
type: email.endsWith("@ecrop.dev") ? "developer" : codeData ? codeData.type : "student",
|
type: email.endsWith("@ecrop.dev") ? "developer" : codeData ? codeData.type : "student",
|
||||||
subscriptionExpirationDate: codeData ? codeData.expiryDate : moment().subtract(1, "days").toISOString(),
|
subscriptionExpirationDate: codeData ? codeData.expiryDate : moment().subtract(1, "days").toISOString(),
|
||||||
|
...(passport_id ? {demographicInformation: {passport_id}} : {}),
|
||||||
registrationDate: new Date(),
|
registrationDate: new Date(),
|
||||||
status: code ? "active" : "paymentDue",
|
status: code ? "active" : "paymentDue",
|
||||||
};
|
};
|
||||||
|
|||||||
383
src/pages/api/stats/[id]/export.tsx
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { app, storage } from "@/firebase";
|
||||||
|
import {
|
||||||
|
getFirestore,
|
||||||
|
doc,
|
||||||
|
getDoc,
|
||||||
|
updateDoc,
|
||||||
|
getDocs,
|
||||||
|
query,
|
||||||
|
collection,
|
||||||
|
where,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import ReactPDF from "@react-pdf/renderer";
|
||||||
|
import TestReport from "@/exams/pdf/test.report";
|
||||||
|
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
||||||
|
import { DemographicInformation, User } from "@/interfaces/user";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { ModuleScore } from "@/interfaces/module.scores";
|
||||||
|
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam";
|
||||||
|
import { LevelExamDetails } from "@/exams/pdf/details/level.exam";
|
||||||
|
import { calculateBandScore } from "@/utils/score";
|
||||||
|
import axios from "axios";
|
||||||
|
import { moduleLabels } from "@/utils/moduleUtils";
|
||||||
|
import {
|
||||||
|
generateQRCode,
|
||||||
|
getRadialProgressPNG,
|
||||||
|
streamToBuffer,
|
||||||
|
} from "@/utils/pdf";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
if (req.method === "POST") return post(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExamSummary = (score: number) => {
|
||||||
|
if (score > 0.8) {
|
||||||
|
return "Scoring between 81% and 100% on the English exam demonstrates an outstanding level of proficiency in writing, speaking, listening, and reading. Mastery of key concepts is evident across all language domains, showcasing not only a high level of skill but also a dedication to excellence. Continuing to challenge oneself with advanced material in writing, speaking, listening, and reading will further refine the already impressive command of the English language.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > 0.6) {
|
||||||
|
return "Scoring between 61% and 80% on the English exam, encompassing writing, speaking, listening, and reading, reflects a commendable level of proficiency in each domain. There's evidence of a solid grasp of key concepts, and effective application of skills. Room for refinement and deeper exploration in writing, speaking, listening, and reading remains, presenting an opportunity for further mastery.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > 0.4) {
|
||||||
|
return "Scoring between 41% and 60% on the English exam across writing, speaking, listening, and reading demonstrates a moderate level of understanding in each domain. While there's a commendable grasp of key concepts, refining fundamental skills in writing, speaking, listening, and reading can lead to notable improvement. Consistent effort and targeted focus on weaker areas are recommended.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > 0.2) {
|
||||||
|
return "Scoring between 21% and 40% on the English exam, spanning writing, speaking, listening, and reading, indicates some understanding of key concepts in each domain. However, there's room for improvement in fundamental skills. Strengthening writing, speaking, listening, and reading abilities through consistent effort and focused study will contribute to overall proficiency.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "This student's performance on the English exam, encompassing writing, speaking, listening, and reading, reflects a significant need for improvement, scoring between 0% and 20%. There's a notable gap in understanding key concepts across all language domains. Strengthening fundamental skills in writing, speaking, listening, and reading is crucial. Developing a consistent study routine and seeking additional support in each area can contribute to substantial progress.";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLevelSummary = (score: number) => {
|
||||||
|
if (score > 0.8) {
|
||||||
|
return "Scoring between 81% and 100% on the English exam showcases an outstanding level of understanding and proficiency. Your performance reflects a mastery of key concepts, including grammar, vocabulary, and comprehension. You exhibit a high level of skill in applying these elements effectively. Your dedication to excellence is evident, and your consistent, stellar performance is commendable. Continue to challenge yourself with advanced material to further refine your already impressive command of the English language. Your commitment to excellence positions you as a standout student in English studies, and your achievements are a testament to your hard work and capability.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > 0.6) {
|
||||||
|
return "Scoring between 61% and 80% on the English exam reflects a commendable level of understanding and proficiency. You have demonstrated a solid grasp of key concepts, including grammar, vocabulary, and comprehension. There's evidence of effective application of skills, but room for refinement and deeper exploration remains. Consistent effort in honing nuanced aspects of language will contribute to even greater mastery. Continue engaging with challenging material and seeking opportunities for advanced comprehension. With sustained dedication, you have the potential to elevate your performance to an exceptional level and further excel in your English studies.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > 0.4) {
|
||||||
|
return "Scoring between 41% and 60% on the English exam reflects a moderate level of understanding. You demonstrate a grasp of some key concepts, but there's room for refinement in areas like grammar, vocabulary, and comprehension. Consistent effort and a strategic focus on weaker areas can lead to notable improvement. Engaging with supplementary resources and seeking feedback will further enhance your skills. With continued dedication, there's a solid foundation to build upon, and achieving a higher level of proficiency is within reach. Keep up the good work and aim for sustained progress in your English studies.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > 0.2) {
|
||||||
|
return "Scoring between 21% and 40% on the English exam shows some understanding of key concepts, but there's still ample room for improvement. Strengthening foundational skills, such as grammar, vocabulary, and comprehension, is essential. Consistent effort and focused study can help bridge gaps in knowledge and elevate your performance. Consider seeking additional guidance or resources to refine your understanding of the material. With commitment and targeted improvements, you have the potential to make significant strides in your English proficiency.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Your performance on the English exam falls within the 0% to 20% range, indicating a need for improvement. There's room to enhance your grasp of fundamental concepts like grammar, vocabulary, and comprehension. Establishing a consistent study routine and seeking extra support can be beneficial. With dedication and targeted efforts, you have the potential to significantly boost your performance in upcoming assessments.";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPerformanceSummary = (module: Module, score: number) => {
|
||||||
|
if (module === "level") return getLevelSummary(score);
|
||||||
|
return getExamSummary(score);
|
||||||
|
};
|
||||||
|
interface SkillsFeedbackRequest {
|
||||||
|
code: Module;
|
||||||
|
name: string;
|
||||||
|
grade: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillsFeedbackResponse extends SkillsFeedbackRequest {
|
||||||
|
evaluation: string;
|
||||||
|
suggestions: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => {
|
||||||
|
const backendRequest = await axios.post(
|
||||||
|
`${process.env.BACKEND_URL}/grading_summary`,
|
||||||
|
{ sections },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return backendRequest.data?.sections;
|
||||||
|
};
|
||||||
|
|
||||||
|
// perform the request with several retries if needed
|
||||||
|
const handleSkillsFeedbackRequest = async (
|
||||||
|
sections: SkillsFeedbackRequest[]
|
||||||
|
): Promise<SkillsFeedbackResponse[] | null> => {
|
||||||
|
let i = 0;
|
||||||
|
try {
|
||||||
|
const data = await getSkillsFeedback(sections);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
if (i < 3) {
|
||||||
|
i++;
|
||||||
|
return handleSkillsFeedbackRequest(sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// verify if it's a logged user that is trying to export
|
||||||
|
if (req.session.user) {
|
||||||
|
const { id } = req.query as { id: string };
|
||||||
|
// fetch stats entries for this particular user with the requested exam session
|
||||||
|
const docsSnap = await getDocs(
|
||||||
|
query(
|
||||||
|
collection(db, "stats"),
|
||||||
|
where("session", "==", id),
|
||||||
|
where("user", "==", req.session.user.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (docsSnap.empty) {
|
||||||
|
res.status(400).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = docsSnap.docs.map((d) => d.data());
|
||||||
|
// verify if the stats already have a pdf generated
|
||||||
|
const hasPDF = stats.find((s) => s.pdf);
|
||||||
|
|
||||||
|
if (hasPDF) {
|
||||||
|
// if it does, return the pdf url
|
||||||
|
const fileRef = ref(storage, hasPDF.pdf);
|
||||||
|
const url = await getDownloadURL(fileRef);
|
||||||
|
|
||||||
|
res.status(200).end(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// generate the pdf report
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// stats may contain multiple exams of the same type so we need to aggregate them
|
||||||
|
const results = (
|
||||||
|
stats.reduce((accm: ModuleScore[], { module, score }) => {
|
||||||
|
const fixedModuleStr =
|
||||||
|
module[0].toUpperCase() + module.substring(1);
|
||||||
|
if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) {
|
||||||
|
return accm.map((e: ModuleScore) => {
|
||||||
|
if (e.module === fixedModuleStr) {
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
score: e.score + score.correct,
|
||||||
|
total: e.total + score.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...accm,
|
||||||
|
{
|
||||||
|
module: fixedModuleStr,
|
||||||
|
score: score.correct,
|
||||||
|
total: score.total,
|
||||||
|
code: module,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, []) as ModuleScore[]
|
||||||
|
).map((moduleScore) => {
|
||||||
|
const { score, total } = moduleScore;
|
||||||
|
// with all the scores aggreated we can calculate the band score for each module
|
||||||
|
const bandScore = calculateBandScore(
|
||||||
|
score,
|
||||||
|
total,
|
||||||
|
moduleScore.code as Module,
|
||||||
|
user.focus
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...moduleScore,
|
||||||
|
// generate the closest radial progress png for the score
|
||||||
|
png: getRadialProgressPNG("azul", score, total),
|
||||||
|
bandScore,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// get the skills feedback from the backend based on the module grade
|
||||||
|
const skillsFeedback = (await handleSkillsFeedbackRequest(
|
||||||
|
results.map(({ code, bandScore }) => ({
|
||||||
|
code,
|
||||||
|
name: moduleLabels[code],
|
||||||
|
grade: bandScore,
|
||||||
|
}))
|
||||||
|
)) as SkillsFeedbackResponse[];
|
||||||
|
|
||||||
|
if (!skillsFeedback) {
|
||||||
|
res.status(500).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// assign the feedback to the results
|
||||||
|
const finalResults = results.map((result) => {
|
||||||
|
const feedback = skillsFeedback.find(
|
||||||
|
(f: SkillsFeedbackResponse) => f.code === result.code
|
||||||
|
);
|
||||||
|
|
||||||
|
if (feedback) {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
evaluation: feedback?.evaluation,
|
||||||
|
suggestions: feedback?.suggestions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// calculate the overall score out of all the aggregated results
|
||||||
|
const overallScore = results.reduce(
|
||||||
|
(accm, { score }) => accm + score,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const overallTotal = results.reduce(
|
||||||
|
(accm, { total }) => accm + total,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const overallResult = overallScore / overallTotal;
|
||||||
|
|
||||||
|
const overallPNG = getRadialProgressPNG("laranja", overallScore, overallTotal);
|
||||||
|
|
||||||
|
// generate the overall detail report
|
||||||
|
const overallDetail = {
|
||||||
|
module: "Overall",
|
||||||
|
score: overallScore,
|
||||||
|
total: overallTotal,
|
||||||
|
png: overallPNG,
|
||||||
|
} as ModuleScore;
|
||||||
|
const testDetails = [overallDetail, ...finalResults];
|
||||||
|
|
||||||
|
const [stat] = stats;
|
||||||
|
|
||||||
|
// generate the performance summary based on the overall result
|
||||||
|
const performanceSummary = getPerformanceSummary(
|
||||||
|
stat.module,
|
||||||
|
overallResult
|
||||||
|
);
|
||||||
|
|
||||||
|
// level exams have a different report structure than the skill exams
|
||||||
|
const getCustomData = () => {
|
||||||
|
if (stat.module === "level") {
|
||||||
|
return {
|
||||||
|
title: "ENGLISH LEVEL TEST RESULT REPORT ",
|
||||||
|
details: (
|
||||||
|
<LevelExamDetails
|
||||||
|
detail={overallDetail}
|
||||||
|
title="Level as per CEFR Levels"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: "ENGLISH SKILLS TEST RESULT REPORT",
|
||||||
|
details: <SkillExamDetails testDetails={testDetails} />,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { title, details } = getCustomData();
|
||||||
|
|
||||||
|
const demographicInformation = user.demographicInformation as DemographicInformation;
|
||||||
|
const pdfStream = await ReactPDF.renderToStream(
|
||||||
|
<TestReport
|
||||||
|
title={title}
|
||||||
|
date={new Date(stat.date).toLocaleString()}
|
||||||
|
name={user.name}
|
||||||
|
email={user.email}
|
||||||
|
id={user.id}
|
||||||
|
gender={demographicInformation?.gender}
|
||||||
|
summary={performanceSummary}
|
||||||
|
testDetails={testDetails}
|
||||||
|
renderDetails={details}
|
||||||
|
logo={"public/logo_title.png"}
|
||||||
|
qrcode={qrcode}
|
||||||
|
summaryPNG={overallPNG}
|
||||||
|
summaryScore={`${(overallResult * 100).toFixed(0)}%`}
|
||||||
|
passportId={demographicInformation?.passport_id || ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// generate the file ref for storage
|
||||||
|
const fileName = `${Date.now().toString()}.pdf`;
|
||||||
|
const refName = `exam_report/${fileName}`;
|
||||||
|
const fileRef = ref(storage, refName);
|
||||||
|
|
||||||
|
// upload the pdf to storage
|
||||||
|
const pdfBuffer = await streamToBuffer(pdfStream);
|
||||||
|
const snapshot = await uploadBytes(fileRef, pdfBuffer, {
|
||||||
|
contentType: "application/pdf",
|
||||||
|
});
|
||||||
|
|
||||||
|
// update the stats entries with the pdf url to prevent duplication
|
||||||
|
docsSnap.docs.forEach(async (doc) => {
|
||||||
|
await updateDoc(doc.ref, {
|
||||||
|
pdf: refName,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const url = await getDownloadURL(fileRef);
|
||||||
|
res.status(200).end(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const { id } = req.query as { id: string };
|
||||||
|
const docsSnap = await getDocs(
|
||||||
|
query(collection(db, "stats"), where("session", "==", id))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (docsSnap.empty) {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = docsSnap.docs.map((d) => d.data());
|
||||||
|
|
||||||
|
const hasPDF = stats.find((s) => s.pdf);
|
||||||
|
|
||||||
|
if (hasPDF) {
|
||||||
|
const fileRef = ref(storage, hasPDF.pdf);
|
||||||
|
const url = await getDownloadURL(fileRef);
|
||||||
|
return res.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
23
src/pages/api/stats/[id]/index.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, "stats", id as string));
|
||||||
|
|
||||||
|
res.status(200).json({...snapshot.data(), id: snapshot.id});
|
||||||
|
}
|
||||||
@@ -49,6 +49,10 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res.json({ok: true});
|
||||||
|
|
||||||
|
await auth.deleteUser(id);
|
||||||
|
await deleteDoc(doc(db, "users", id));
|
||||||
const userCodeDocs = await getDocs(query(collection(db, "codes"), where("userId", "==", id)));
|
const userCodeDocs = await getDocs(query(collection(db, "codes"), where("userId", "==", id)));
|
||||||
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id)));
|
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id)));
|
||||||
const userGroupAdminDocs = await getDocs(query(collection(db, "groups"), where("admin", "==", id)));
|
const userGroupAdminDocs = await getDocs(query(collection(db, "groups"), where("admin", "==", id)));
|
||||||
@@ -62,11 +66,6 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
async (x) => await setDoc(x.ref, {participants: x.data().participants.filter((y: string) => y !== id)}, {merge: true}),
|
async (x) => await setDoc(x.ref, {participants: x.data().participants.filter((y: string) => y !== id)}, {merge: true}),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await auth.deleteUser(id);
|
|
||||||
await deleteDoc(doc(db, "users", id));
|
|
||||||
|
|
||||||
res.json({ok: true});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function Generation() {
|
|||||||
value={module}
|
value={module}
|
||||||
onChange={setModule}
|
onChange={setModule}
|
||||||
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
||||||
{[...MODULE_ARRAY, "level"].map((x) => (
|
{[...MODULE_ARRAY].map((x) => (
|
||||||
<RadioGroup.Option value={x} key={x}>
|
<RadioGroup.Option value={x} key={x}>
|
||||||
{({checked}) => (
|
{({checked}) => (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -67,7 +67,12 @@ export default function Home({envVariables}: {envVariables: {[key: string]: stri
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setShowDemographicInput(!user.demographicInformation);
|
setShowDemographicInput(
|
||||||
|
!user.demographicInformation ||
|
||||||
|
!user.demographicInformation.country ||
|
||||||
|
!user.demographicInformation.gender ||
|
||||||
|
!user.demographicInformation.phone,
|
||||||
|
);
|
||||||
setShowDiagnostics(user.isFirstLogin && user.type === "student");
|
setShowDiagnostics(user.isFirstLogin && user.type === "student");
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ const PaymentCreator = ({onClose, reload, showComission = false}: {onClose: () =
|
|||||||
<div className="w-full grid grid-cols-5 gap-2">
|
<div className="w-full grid grid-cols-5 gap-2">
|
||||||
<Input name="paymentValue" onChange={() => {}} type="number" value={price} defaultValue={0} className="col-span-3" disabled />
|
<Input name="paymentValue" onChange={() => {}} type="number" value={price} defaultValue={0} className="col-span-3" disabled />
|
||||||
<Select
|
<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"
|
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-mti-gray-platinum/40 text-mti-gray-dim cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
options={CURRENCIES.map(({label, currency}) => ({value: currency, label}))}
|
options={CURRENCIES.map(({label, currency}) => ({value: currency, label}))}
|
||||||
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
|
||||||
onChange={() => {}}
|
onChange={() => {}}
|
||||||
@@ -346,6 +346,11 @@ export default function PaymentRecord() {
|
|||||||
]);
|
]);
|
||||||
}, [corporateTransfer]);
|
}, [corporateTransfer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && user.type === "corporate") return setCorporate(user);
|
||||||
|
if (user && user.type === "agent") return setAgent(user);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const updatePayment = (payment: Payment, key: string, value: any) => {
|
const updatePayment = (payment: Payment, key: string, value: any) => {
|
||||||
axios
|
axios
|
||||||
.patch(`api/payments/${payment.id}`, {...payment, [key]: value})
|
.patch(`api/payments/${payment.id}`, {...payment, [key]: value})
|
||||||
@@ -366,7 +371,7 @@ export default function PaymentRecord() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (reason.response.status === 403) {
|
if (reason.response.status === 403) {
|
||||||
toast.error("You do not have permission to delete this exam!");
|
toast.error("You do not have permission to delete an approved payment record!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +395,6 @@ export default function PaymentRecord() {
|
|||||||
reload={reload}
|
reload={reload}
|
||||||
permissions={info.row.original.isPaid ? "read" : "write"}
|
permissions={info.row.original.isPaid ? "read" : "write"}
|
||||||
asset={info.row.original.corporateTransfer}
|
asset={info.row.original.corporateTransfer}
|
||||||
canEdit={!info.row.original.isPaid}
|
|
||||||
paymentId={info.row.original.id}
|
paymentId={info.row.original.id}
|
||||||
type="corporate"
|
type="corporate"
|
||||||
/>
|
/>
|
||||||
@@ -410,7 +414,6 @@ export default function PaymentRecord() {
|
|||||||
permissions="read"
|
permissions="read"
|
||||||
asset={info.row.original.commissionTransfer}
|
asset={info.row.original.commissionTransfer}
|
||||||
paymentId={info.row.original.id}
|
paymentId={info.row.original.id}
|
||||||
canEdit={!info.row.original.isPaid}
|
|
||||||
type="commission"
|
type="commission"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -429,7 +432,6 @@ export default function PaymentRecord() {
|
|||||||
permissions="read"
|
permissions="read"
|
||||||
asset={info.row.original.corporateTransfer}
|
asset={info.row.original.corporateTransfer}
|
||||||
paymentId={info.row.original.id}
|
paymentId={info.row.original.id}
|
||||||
canEdit={!info.row.original.isPaid}
|
|
||||||
type="corporate"
|
type="corporate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -445,7 +447,6 @@ export default function PaymentRecord() {
|
|||||||
permissions={info.row.original.isPaid ? "read" : "write"}
|
permissions={info.row.original.isPaid ? "read" : "write"}
|
||||||
asset={info.row.original.commissionTransfer}
|
asset={info.row.original.commissionTransfer}
|
||||||
paymentId={info.row.original.id}
|
paymentId={info.row.original.id}
|
||||||
canEdit={!info.row.original.isPaid}
|
|
||||||
type="commission"
|
type="commission"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -464,7 +465,6 @@ export default function PaymentRecord() {
|
|||||||
permissions="write"
|
permissions="write"
|
||||||
asset={info.row.original.corporateTransfer}
|
asset={info.row.original.corporateTransfer}
|
||||||
paymentId={info.row.original.id}
|
paymentId={info.row.original.id}
|
||||||
canEdit={!info.row.original.isPaid}
|
|
||||||
type="corporate"
|
type="corporate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -480,7 +480,6 @@ export default function PaymentRecord() {
|
|||||||
permissions="write"
|
permissions="write"
|
||||||
asset={info.row.original.commissionTransfer}
|
asset={info.row.original.commissionTransfer}
|
||||||
paymentId={info.row.original.id}
|
paymentId={info.row.original.id}
|
||||||
canEdit={!info.row.original.isPaid}
|
|
||||||
type="commission"
|
type="commission"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,9 +539,25 @@ export default function PaymentRecord() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const commissionColumn = () => {
|
const hiddenToCorporateColumns = () => {
|
||||||
if (user && user.type !== "corporate")
|
if (user && user.type !== "corporate")
|
||||||
return [
|
return [
|
||||||
|
columnHelper.accessor("agent", {
|
||||||
|
header: "Country Manager",
|
||||||
|
id: "agent",
|
||||||
|
cell: (info) => {
|
||||||
|
const {user, value} = columHelperValue(info.column.id, info);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedAgentUser(user)}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
columnHelper.accessor("agentCommission", {
|
columnHelper.accessor("agentCommission", {
|
||||||
header: "Commission",
|
header: "Commission",
|
||||||
id: "agentCommission",
|
id: "agentCommission",
|
||||||
@@ -608,23 +623,7 @@ export default function PaymentRecord() {
|
|||||||
return <span>{finalValue}</span>;
|
return <span>{finalValue}</span>;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("agent", {
|
...hiddenToCorporateColumns(),
|
||||||
header: "Country Manager",
|
|
||||||
id: "agent",
|
|
||||||
cell: (info) => {
|
|
||||||
const {user, value} = columHelperValue(info.column.id, info);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
|
||||||
)}
|
|
||||||
onClick={() => setSelectedAgentUser(user)}>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...commissionColumn(),
|
|
||||||
columnHelper.accessor("isPaid", {
|
columnHelper.accessor("isPaid", {
|
||||||
header: "Paid",
|
header: "Paid",
|
||||||
id: "isPaid",
|
id: "isPaid",
|
||||||
@@ -635,7 +634,7 @@ export default function PaymentRecord() {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
isChecked={value}
|
isChecked={value}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (user?.type === agent || value) return null;
|
if (user?.type === agent || user?.type === "corporate" || value) return null;
|
||||||
if (!info.row.original.commissionTransfer || !info.row.original.corporateTransfer)
|
if (!info.row.original.commissionTransfer || !info.row.original.corporateTransfer)
|
||||||
return alert("All files need to be uploaded to consider it paid!");
|
return alert("All files need to be uploaded to consider it paid!");
|
||||||
if (!confirm(`Are you sure you want to consider this payment paid?`)) return null;
|
if (!confirm(`Are you sure you want to consider this payment paid?`)) return null;
|
||||||
@@ -785,30 +784,45 @@ export default function PaymentRecord() {
|
|||||||
|
|
||||||
<div className="w-full flex flex-end justify-between p-2">
|
<div className="w-full flex flex-end justify-between p-2">
|
||||||
<h1 className="text-2xl font-semibold">Payment Record</h1>
|
<h1 className="text-2xl font-semibold">Payment Record</h1>
|
||||||
{(user.type === "developer" || user.type === "admin") && (
|
<div className="flex justify-end gap-2">
|
||||||
<div className="flex justify-end gap-2">
|
{(user.type === "developer" || user.type === "admin" || user.type === "agent" || user.type === "corporate") && (
|
||||||
<Button className="max-w-[200px]" variant="outline">
|
<Button className="max-w-[200px]" variant="outline">
|
||||||
<CSVLink data={csvRows} headers={csvColumns} filename="payment-records.csv">
|
<CSVLink data={csvRows} headers={csvColumns} filename="payment-records.csv">
|
||||||
Download CSV
|
Download CSV
|
||||||
</CSVLink>
|
</CSVLink>
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{(user.type === "developer" || user.type === "admin") && (
|
||||||
<Button className="max-w-[200px]" variant="outline" onClick={() => setIsCreatingPayment(true)}>
|
<Button className="max-w-[200px]" variant="outline" onClick={() => setIsCreatingPayment(true)}>
|
||||||
New Payment
|
New Payment
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
|
<div className={clsx("grid grid-cols-1 md:grid-cols-2 gap-8 w-full", user.type !== "corporate" && "lg:grid-cols-3")}>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Corporate account *</label>
|
<label className="font-normal text-base text-mti-gray-dim">Corporate account *</label>
|
||||||
<Select
|
<Select
|
||||||
isClearable
|
isClearable={user.type !== "corporate"}
|
||||||
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",
|
||||||
|
user.type === "corporate" && "!bg-mti-gray-platinum/40 !text-mti-gray-dim !cursor-not-allowed",
|
||||||
|
)}
|
||||||
options={(users.filter((u) => u.type === "corporate") as CorporateUser[]).map((user) => ({
|
options={(users.filter((u) => u.type === "corporate") as CorporateUser[]).map((user) => ({
|
||||||
value: user.id,
|
value: user.id,
|
||||||
meta: user,
|
meta: user,
|
||||||
label: `${user.corporateInformation.companyInformation.name || user.name} - ${user.email}`,
|
label: `${user.corporateInformation?.companyInformation?.name || user.name} - ${user.email}`,
|
||||||
}))}
|
}))}
|
||||||
|
defaultValue={
|
||||||
|
user.type === "corporate"
|
||||||
|
? {
|
||||||
|
value: user.id,
|
||||||
|
meta: user,
|
||||||
|
label: `${user.corporateInformation?.companyInformation?.name || user.name} - ${user.email}`,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
isDisabled={user.type === "corporate"}
|
||||||
onChange={(value) => setCorporate((value as any)?.meta ?? undefined)}
|
onChange={(value) => setCorporate((value as any)?.meta ?? undefined)}
|
||||||
styles={{
|
styles={{
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
@@ -828,40 +842,42 @@ export default function PaymentRecord() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
{user.type !== "corporate" && (
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Country manager *</label>
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<Select
|
<label className="font-normal text-base text-mti-gray-dim">Country manager *</label>
|
||||||
isClearable
|
<Select
|
||||||
isDisabled={user.type === "agent"}
|
isClearable
|
||||||
className={clsx(
|
isDisabled={user.type === "agent"}
|
||||||
"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 rounded-full border border-mti-gray-platinum focus:outline-none",
|
className={clsx(
|
||||||
user.type === "agent" ? "bg-mti-gray-platinum/40" : "bg-white",
|
"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 rounded-full border border-mti-gray-platinum focus:outline-none",
|
||||||
)}
|
user.type === "agent" ? "bg-mti-gray-platinum/40" : "bg-white",
|
||||||
options={(users.filter((u) => u.type === "agent") as AgentUser[]).map((user) => ({
|
)}
|
||||||
value: user.id,
|
options={(users.filter((u) => u.type === "agent") as AgentUser[]).map((user) => ({
|
||||||
meta: user,
|
value: user.id,
|
||||||
label: `${user.name} - ${user.email}`,
|
meta: user,
|
||||||
}))}
|
label: `${user.name} - ${user.email}`,
|
||||||
value={agent ? {value: agent?.id, label: `${agent.name} - ${agent.email}`} : undefined}
|
}))}
|
||||||
onChange={(value) => setAgent(value !== null ? (value as any).meta : undefined)}
|
value={agent ? {value: agent?.id, label: `${agent.name} - ${agent.email}`} : undefined}
|
||||||
styles={{
|
onChange={(value) => setAgent(value !== null ? (value as any).meta : undefined)}
|
||||||
control: (styles) => ({
|
styles={{
|
||||||
...styles,
|
control: (styles) => ({
|
||||||
paddingLeft: "4px",
|
...styles,
|
||||||
border: "none",
|
paddingLeft: "4px",
|
||||||
outline: "none",
|
border: "none",
|
||||||
":focus": {
|
|
||||||
outline: "none",
|
outline: "none",
|
||||||
},
|
":focus": {
|
||||||
}),
|
outline: "none",
|
||||||
option: (styles, state) => ({
|
},
|
||||||
...styles,
|
}),
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
option: (styles, state) => ({
|
||||||
color: state.isFocused ? "black" : styles.color,
|
...styles,
|
||||||
}),
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
}}
|
color: state.isFocused ? "black" : styles.color,
|
||||||
/>
|
}),
|
||||||
</div>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Paid</label>
|
<label className="font-normal text-base text-mti-gray-dim">Paid</label>
|
||||||
<Select
|
<Select
|
||||||
@@ -917,37 +933,39 @@ export default function PaymentRecord() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
{user.type !== "corporate" && (
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Commission transfer</label>
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<Select
|
<label className="font-normal text-base text-mti-gray-dim">Commission transfer</label>
|
||||||
isClearable
|
<Select
|
||||||
className={clsx(
|
isClearable
|
||||||
"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 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 disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none",
|
||||||
options={IS_FILE_SUBMITTED_OPTIONS}
|
)}
|
||||||
value={IS_FILE_SUBMITTED_OPTIONS.find((e) => e.value === commissionTransfer)}
|
options={IS_FILE_SUBMITTED_OPTIONS}
|
||||||
onChange={(value) => {
|
value={IS_FILE_SUBMITTED_OPTIONS.find((e) => e.value === commissionTransfer)}
|
||||||
if (value) return setCommissionTransfer(value.value);
|
onChange={(value) => {
|
||||||
setCommissionTransfer(null);
|
if (value) return setCommissionTransfer(value.value);
|
||||||
}}
|
setCommissionTransfer(null);
|
||||||
styles={{
|
}}
|
||||||
control: (styles) => ({
|
styles={{
|
||||||
...styles,
|
control: (styles) => ({
|
||||||
paddingLeft: "4px",
|
...styles,
|
||||||
border: "none",
|
paddingLeft: "4px",
|
||||||
outline: "none",
|
border: "none",
|
||||||
":focus": {
|
|
||||||
outline: "none",
|
outline: "none",
|
||||||
},
|
":focus": {
|
||||||
}),
|
outline: "none",
|
||||||
option: (styles, state) => ({
|
},
|
||||||
...styles,
|
}),
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
option: (styles, state) => ({
|
||||||
color: state.isFocused ? "black" : styles.color,
|
...styles,
|
||||||
}),
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
}}
|
color: state.isFocused ? "black" : styles.color,
|
||||||
/>
|
}),
|
||||||
</div>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Corporate transfer</label>
|
<label className="font-normal text-base text-mti-gray-dim">Corporate transfer</label>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {ChangeEvent, useEffect, useRef, useState} from "react";
|
import {ChangeEvent, ReactNode, useEffect, useRef, useState} from "react";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
@@ -13,7 +13,7 @@ import axios from "axios";
|
|||||||
import {ErrorMessage} from "@/constants/errors";
|
import {ErrorMessage} from "@/constants/errors";
|
||||||
import {RadioGroup} from "@headlessui/react";
|
import {RadioGroup} from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
|
import {CorporateUser, EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
|
||||||
import CountrySelect from "@/components/Low/CountrySelect";
|
import CountrySelect from "@/components/Low/CountrySelect";
|
||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -21,6 +21,10 @@ import {BsCamera, BsCameraFill} from "react-icons/bs";
|
|||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import {convertBase64} from "@/utils";
|
||||||
|
import {Divider} from "primereact/divider";
|
||||||
|
import GenderInput from "@/components/High/GenderInput";
|
||||||
|
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -72,7 +76,9 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
const [employment, setEmployment] = useState<EmploymentStatus | undefined>(
|
const [employment, setEmployment] = useState<EmploymentStatus | undefined>(
|
||||||
user.type === "corporate" ? undefined : user.demographicInformation?.employment,
|
user.type === "corporate" ? undefined : user.demographicInformation?.employment,
|
||||||
);
|
);
|
||||||
|
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
||||||
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
||||||
|
const [corporateInformation, setCorporateInformation] = useState(user.type === "corporate" ? user.corporateInformation : undefined);
|
||||||
const [companyName, setCompanyName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyName : undefined);
|
const [companyName, setCompanyName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyName : undefined);
|
||||||
const [commercialRegistration, setCommercialRegistration] = useState<string | undefined>(
|
const [commercialRegistration, setCommercialRegistration] = useState<string | undefined>(
|
||||||
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
||||||
@@ -91,19 +97,6 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
|
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
|
||||||
};
|
};
|
||||||
|
|
||||||
const convertBase64 = (file: File) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const fileReader = new FileReader();
|
|
||||||
fileReader.readAsDataURL(file);
|
|
||||||
fileReader.onload = () => {
|
|
||||||
resolve(fileReader.result);
|
|
||||||
};
|
|
||||||
fileReader.onerror = (error) => {
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadProfilePicture = async (event: ChangeEvent<HTMLInputElement>) => {
|
const uploadProfilePicture = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
if (event.target.files && event.target.files[0]) {
|
if (event.target.files && event.target.files[0]) {
|
||||||
const picture = event.target.files[0];
|
const picture = event.target.files[0];
|
||||||
@@ -152,7 +145,9 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
employment: user?.type === "corporate" ? undefined : employment,
|
employment: user?.type === "corporate" ? undefined : employment,
|
||||||
position: user?.type === "corporate" ? position : undefined,
|
position: user?.type === "corporate" ? position : undefined,
|
||||||
gender,
|
gender,
|
||||||
|
passport_id,
|
||||||
},
|
},
|
||||||
|
...(user.type === "corporate" ? {corporateInformation} : {}),
|
||||||
});
|
});
|
||||||
if (request.status === 200) {
|
if (request.status === 200) {
|
||||||
toast.success("Your profile has been updated!");
|
toast.success("Your profile has been updated!");
|
||||||
@@ -165,6 +160,93 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DoubleColumnRow = ({children}: {children: ReactNode}) => <div className="flex flex-col md:flex-row gap-8 w-full">{children}</div>;
|
||||||
|
|
||||||
|
const PasswordInput = () => (
|
||||||
|
<DoubleColumnRow>
|
||||||
|
<Input
|
||||||
|
label="Current Password"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
onChange={(e) => setPassword(e)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="New Password"
|
||||||
|
type="password"
|
||||||
|
name="newPassword"
|
||||||
|
onChange={(e) => setNewPassword(e)}
|
||||||
|
placeholder="Enter your new password (optional)"
|
||||||
|
/>
|
||||||
|
</DoubleColumnRow>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NameInput = () => (
|
||||||
|
<Input label="Name" type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" defaultValue={name} required />
|
||||||
|
);
|
||||||
|
|
||||||
|
const AgentInformationInput = () => (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
||||||
|
<Input
|
||||||
|
label="Corporate Name"
|
||||||
|
type="text"
|
||||||
|
name="companyName"
|
||||||
|
onChange={() => null}
|
||||||
|
placeholder="Enter corporate name"
|
||||||
|
defaultValue={companyName}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Commercial Registration"
|
||||||
|
type="text"
|
||||||
|
name="commercialRegistration"
|
||||||
|
onChange={() => null}
|
||||||
|
placeholder="Enter commercial registration"
|
||||||
|
defaultValue={commercialRegistration}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CountryInput = () => (
|
||||||
|
<div className="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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PhoneInput = () => (
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
name="phone"
|
||||||
|
label="Phone number"
|
||||||
|
onChange={(e) => setPhone(e)}
|
||||||
|
placeholder="Enter phone number"
|
||||||
|
defaultValue={phone}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ExpirationDate = () => (
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Expiry Date (click to purchase)</label>
|
||||||
|
<Link
|
||||||
|
href="/payment"
|
||||||
|
className={clsx(
|
||||||
|
"p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!user.subscriptionExpirationDate
|
||||||
|
? "!bg-mti-green-ultralight !border-mti-green-light"
|
||||||
|
: expirationDateColor(user.subscriptionExpirationDate),
|
||||||
|
"bg-white border-mti-gray-platinum",
|
||||||
|
)}>
|
||||||
|
{!user.subscriptionExpirationDate && "Unlimited"}
|
||||||
|
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<section className="w-full flex flex-col gap-4 md:gap-8 px-4 py-8">
|
<section className="w-full flex flex-col gap-4 md:gap-8 px-4 py-8">
|
||||||
@@ -173,16 +255,26 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
<div className="flex flex-col gap-8 w-full md:w-2/3">
|
<div className="flex flex-col gap-8 w-full md:w-2/3">
|
||||||
<h1 className="text-4xl font-bold mb-6 -md:hidden">Edit Profile</h1>
|
<h1 className="text-4xl font-bold mb-6 -md:hidden">Edit Profile</h1>
|
||||||
<form className="flex flex-col items-center gap-6 w-full">
|
<form className="flex flex-col items-center gap-6 w-full">
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<DoubleColumnRow>
|
||||||
<Input
|
{user.type !== "corporate" ? (
|
||||||
label="Name"
|
<NameInput />
|
||||||
type="text"
|
) : (
|
||||||
name="name"
|
<Input
|
||||||
onChange={(e) => setName(e)}
|
label="Company name"
|
||||||
placeholder="Enter your name"
|
type="text"
|
||||||
defaultValue={name}
|
name="name"
|
||||||
required
|
onChange={(e) =>
|
||||||
/>
|
setCorporateInformation((prev) => ({
|
||||||
|
...prev!,
|
||||||
|
companyInformation: {...prev!.companyInformation, name: e},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Enter your company's name"
|
||||||
|
defaultValue={corporateInformation?.companyInformation.name}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="E-mail Address"
|
label="E-mail Address"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -192,167 +284,137 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
defaultValue={email}
|
defaultValue={email}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</DoubleColumnRow>
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<PasswordInput />
|
||||||
|
|
||||||
|
{user.type === "student" && (
|
||||||
<Input
|
<Input
|
||||||
label="Current Password"
|
type="text"
|
||||||
type="password"
|
name="passport_id"
|
||||||
name="password"
|
label="Passport/National ID"
|
||||||
onChange={(e) => setPassword(e)}
|
onChange={(e) => setPassportID(e)}
|
||||||
placeholder="Enter your password"
|
placeholder="Enter National ID or Passport number"
|
||||||
|
value={passport_id}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
)}
|
||||||
label="New Password"
|
{user.type === "agent" && <AgentInformationInput />}
|
||||||
type="password"
|
|
||||||
name="newPassword"
|
|
||||||
onChange={(e) => setNewPassword(e)}
|
|
||||||
placeholder="Enter your new password (optional)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{user.type === "agent" && (
|
<DoubleColumnRow>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
<CountryInput />
|
||||||
<Input
|
<PhoneInput />
|
||||||
label="Corporate Name"
|
</DoubleColumnRow>
|
||||||
type="text"
|
|
||||||
name="companyName"
|
<Divider />
|
||||||
onChange={() => null}
|
|
||||||
placeholder="Enter corporate name"
|
{user.type === "corporate" && (
|
||||||
defaultValue={companyName}
|
<>
|
||||||
disabled
|
<DoubleColumnRow>
|
||||||
/>
|
<Input
|
||||||
<Input
|
type="number"
|
||||||
label="Commercial Registration"
|
name="companyUsers"
|
||||||
type="text"
|
onChange={() => null}
|
||||||
name="commercialRegistration"
|
label="Number of users"
|
||||||
onChange={() => null}
|
defaultValue={user.corporateInformation.companyInformation.userAmount}
|
||||||
placeholder="Enter commercial registration"
|
disabled
|
||||||
defaultValue={commercialRegistration}
|
required
|
||||||
disabled
|
/>
|
||||||
/>
|
<Input
|
||||||
</div>
|
type="text"
|
||||||
|
name="pricing"
|
||||||
|
onChange={() => null}
|
||||||
|
label="Pricing"
|
||||||
|
defaultValue={`${user.corporateInformation.payment?.value} ${user.corporateInformation.payment?.currency}`}
|
||||||
|
disabled
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</DoubleColumnRow>
|
||||||
|
<ExpirationDate />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
{user.type === "corporate" && (
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
<Divider />
|
||||||
<CountrySelect value={country} onChange={setCountry} />
|
<DoubleColumnRow>
|
||||||
</div>
|
<NameInput />
|
||||||
<Input
|
<Input
|
||||||
type="tel"
|
name="position"
|
||||||
name="phone"
|
onChange={setPosition}
|
||||||
label="Phone number"
|
defaultValue={position}
|
||||||
onChange={(e) => setPhone(e)}
|
type="text"
|
||||||
placeholder="Enter phone number"
|
label="Position"
|
||||||
defaultValue={phone}
|
placeholder="CEO, Head of Marketing..."
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</DoubleColumnRow>
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
</>
|
||||||
{user.type === "corporate" && (
|
)}
|
||||||
<Input
|
|
||||||
name="position"
|
{user.type === "corporate" && user.corporateInformation.referralAgent && (
|
||||||
onChange={setPosition}
|
<>
|
||||||
defaultValue={position}
|
<Divider />
|
||||||
type="text"
|
<DoubleColumnRow>
|
||||||
label="Position"
|
<Input
|
||||||
placeholder="CEO, Head of Marketing..."
|
name="agentName"
|
||||||
required
|
onChange={() => null}
|
||||||
/>
|
defaultValue={users.find((x) => x.id === user.corporateInformation.referralAgent)?.name}
|
||||||
)}
|
type="text"
|
||||||
{user.type !== "corporate" && (
|
label="Country Manager's Name"
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
placeholder="Not available"
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
|
required
|
||||||
<RadioGroup
|
disabled
|
||||||
value={employment}
|
/>
|
||||||
onChange={setEmployment}
|
<Input
|
||||||
className="grid grid-cols-2 items-center gap-4 place-items-center">
|
name="agentEmail"
|
||||||
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
onChange={() => null}
|
||||||
<RadioGroup.Option value={status} key={status}>
|
defaultValue={users.find((x) => x.id === user.corporateInformation.referralAgent)?.email}
|
||||||
{({checked}) => (
|
type="text"
|
||||||
<span
|
label="Country Manager's E-mail"
|
||||||
className={clsx(
|
placeholder="Not available"
|
||||||
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
required
|
||||||
"transition duration-300 ease-in-out",
|
disabled
|
||||||
!checked
|
/>
|
||||||
? "bg-white border-mti-gray-platinum"
|
</DoubleColumnRow>
|
||||||
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
<DoubleColumnRow>
|
||||||
)}>
|
<div className="flex flex-col gap-2 w-full">
|
||||||
{label}
|
<label className="font-normal text-base text-mti-gray-dim">Country Manager's Country *</label>
|
||||||
</span>
|
<CountrySelect
|
||||||
)}
|
value={
|
||||||
</RadioGroup.Option>
|
users.find((x) => x.id === user.corporateInformation.referralAgent)?.demographicInformation
|
||||||
))}
|
?.country
|
||||||
</RadioGroup>
|
}
|
||||||
|
onChange={() => null}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
name="agentPhone"
|
||||||
|
label="Country Manager's Phone"
|
||||||
|
onChange={() => null}
|
||||||
|
placeholder="Not available"
|
||||||
|
defaultValue={
|
||||||
|
users.find((x) => x.id === user.corporateInformation.referralAgent)?.demographicInformation?.phone
|
||||||
|
}
|
||||||
|
disabled
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</DoubleColumnRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user.type !== "corporate" && (
|
||||||
|
<DoubleColumnRow>
|
||||||
|
<EmploymentStatusInput value={employment} onChange={setEmployment} />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-8 w-full">
|
||||||
|
<GenderInput value={gender} onChange={setGender} />
|
||||||
|
<ExpirationDate />
|
||||||
</div>
|
</div>
|
||||||
)}
|
</DoubleColumnRow>
|
||||||
<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={gender} onChange={setGender} 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>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date (click to purchase)</label>
|
|
||||||
<Link
|
|
||||||
href="/payment"
|
|
||||||
className={clsx(
|
|
||||||
"p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!user.subscriptionExpirationDate
|
|
||||||
? "!bg-mti-green-ultralight !border-mti-green-light"
|
|
||||||
: expirationDateColor(user.subscriptionExpirationDate),
|
|
||||||
"bg-white border-mti-gray-platinum",
|
|
||||||
)}>
|
|
||||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
|
||||||
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-6 w-48">
|
<div className="flex flex-col gap-6 w-48">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import useGroups from "@/hooks/useGroups";
|
|||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
import {uuidv4} from "@firebase/util";
|
import {uuidv4} from "@firebase/util";
|
||||||
|
import {usePDFDownload} from "@/hooks/usePDFDownload";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -69,8 +70,8 @@ export default function History({user}: {user: User}) {
|
|||||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const renderPdfIcon = usePDFDownload("stats");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stats && !isStatsLoading) {
|
if (stats && !isStatsLoading) {
|
||||||
@@ -174,7 +175,7 @@ export default function History({user}: {user: User}) {
|
|||||||
level: calculateBandScore(x.correct, x.total, x.module, user.focus),
|
level: calculateBandScore(x.correct, x.total, x.module, user.focus),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const timeSpent = dateStats[0].timeSpent;
|
const {timeSpent, session} = dateStats[0];
|
||||||
|
|
||||||
const selectExam = () => {
|
const selectExam = () => {
|
||||||
const examPromises = uniqBy(dateStats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
const examPromises = uniqBy(dateStats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
||||||
@@ -195,6 +196,12 @@ export default function History({user}: {user: User}) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const textColor = clsx(
|
||||||
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
|
correct / total < 0.3 && "text-mti-rose",
|
||||||
|
);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
||||||
@@ -207,15 +214,13 @@ export default function History({user}: {user: User}) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<div className="flex flex-row gap-2">
|
||||||
className={clsx(
|
<span className={textColor}>
|
||||||
correct / total >= 0.7 && "text-mti-purple",
|
Level{" "}
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
||||||
correct / total < 0.3 && "text-mti-rose",
|
</span>
|
||||||
)}>
|
{renderPdfIcon(session, textColor, textColor)}
|
||||||
Level{" "}
|
</div>
|
||||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-1">
|
<div className="w-full flex flex-col gap-1">
|
||||||
|
|||||||
@@ -23,12 +23,18 @@ export const getServerSideProps = (context: any) => {
|
|||||||
|
|
||||||
export default function Register({code: queryCode}: {code: string}) {
|
export default function Register({code: queryCode}: {code: string}) {
|
||||||
const [defaultEmail, setDefaultEmail] = useState<string>();
|
const [defaultEmail, setDefaultEmail] = useState<string>();
|
||||||
|
const [defaultName, setDefaultName] = useState<string>();
|
||||||
|
const [passport_id, setPassportID] = useState<string>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (queryCode) {
|
if (queryCode) {
|
||||||
(async () => {
|
(async () => {
|
||||||
axios.get<{email?: string}>(`/api/code/${queryCode}`).then((result) => setDefaultEmail(result.data.email));
|
axios.get<{email?: string; name?: string; passport_id?: string}>(`/api/code/${queryCode}`).then((result) => {
|
||||||
|
setDefaultEmail(result.data.email);
|
||||||
|
setDefaultName(result.data.name);
|
||||||
|
setPassportID(result.data.passport_id);
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
}, [queryCode]);
|
}, [queryCode]);
|
||||||
@@ -95,7 +101,9 @@ export default function Register({code: queryCode}: {code: string}) {
|
|||||||
mutateUser={mutateUser}
|
mutateUser={mutateUser}
|
||||||
sendEmailVerification={sendEmailVerification}
|
sendEmailVerification={sendEmailVerification}
|
||||||
queryCode={queryCode}
|
queryCode={queryCode}
|
||||||
defaultEmail={defaultEmail}
|
defaultInformation={
|
||||||
|
defaultEmail && defaultName ? {email: defaultEmail, name: defaultName, passport_id} : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel>
|
<Tab.Panel>
|
||||||
|
|||||||
18
src/utils/groups.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {CorporateUser, Group, User} from "@/interfaces/user";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const isUserFromCorporate = async (userID: string) => {
|
||||||
|
const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`)).data;
|
||||||
|
const users = (await axios.get<User[]>("/api/users/list")).data;
|
||||||
|
|
||||||
|
const adminTypes = groups.map((g) => users.find((u) => u.id === g.admin)?.type);
|
||||||
|
return adminTypes.includes("corporate");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserCorporate = async (userID: string) => {
|
||||||
|
const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`)).data;
|
||||||
|
const users = (await axios.get<User[]>("/api/users/list")).data;
|
||||||
|
|
||||||
|
const admins = groups.map((g) => users.find((u) => u.id === g.admin));
|
||||||
|
return admins.map((x) => x?.type).includes("corporate") ? (admins[0] as CorporateUser) : undefined;
|
||||||
|
};
|
||||||
@@ -12,3 +12,16 @@ export function dateSorter(a: any, b: any, direction: "asc" | "desc", key: strin
|
|||||||
export function env(key: string) {
|
export function env(key: string) {
|
||||||
return (window as any).__ENV[key];
|
return (window as any).__ENV[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const convertBase64 = (file: File) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fileReader = new FileReader();
|
||||||
|
fileReader.readAsDataURL(file);
|
||||||
|
fileReader.onload = () => {
|
||||||
|
resolve(fileReader.result);
|
||||||
|
};
|
||||||
|
fileReader.onerror = (error) => {
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
44
src/utils/pdf.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import qrcode from "qrcode";
|
||||||
|
|
||||||
|
export const generateQRCode = async (link: string) => {
|
||||||
|
try {
|
||||||
|
const qrCodeDataURL = await qrcode.toDataURL(link);
|
||||||
|
return qrCodeDataURL;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating QR code:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Radial Progress PNGs were generated with only two colors
|
||||||
|
// and they use some baseline score (10%, 20%, 30%..)
|
||||||
|
type RADIAL_PROGRESS_COLOR = "laranja" | "azul";
|
||||||
|
|
||||||
|
export const getRadialProgressPNG = (
|
||||||
|
color: RADIAL_PROGRESS_COLOR,
|
||||||
|
score: number,
|
||||||
|
total: number
|
||||||
|
) => {
|
||||||
|
// calculate the percentage of the score
|
||||||
|
// and round it to the closest available image
|
||||||
|
const percent = (score / total) * 100;
|
||||||
|
if (isNaN(percent)) return `public/radial_progress/${color}_0.png`;
|
||||||
|
const remainder = percent % 10;
|
||||||
|
const roundedPercent = percent - remainder;
|
||||||
|
return `public/radial_progress/${color}_${roundedPercent}.png`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamToBuffer = async (
|
||||||
|
stream: NodeJS.ReadableStream
|
||||||
|
): Promise<Buffer> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
stream.on("data", (data) => {
|
||||||
|
chunks.push(data);
|
||||||
|
});
|
||||||
|
stream.on("end", () => {
|
||||||
|
resolve(Buffer.concat(chunks));
|
||||||
|
});
|
||||||
|
stream.on("error", reject);
|
||||||
|
});
|
||||||
|
};
|
||||||