Compare commits

..

113 Commits

Author SHA1 Message Date
Tiago Ribeiro
7b0f8c1c20 - It is no longer possible to upload/edit/delete a transfer after it is considered paid
- When deleting a row, the transfers are also deleted from the storage
2024-01-09 12:12:20 +00:00
Tiago Ribeiro
db2f5f2c0b Removed an unused console.log 2024-01-09 11:32:46 +00:00
Tiago Ribeiro
0ed843125a Made it so the isPaid property is controlled with the file uploads/deletes 2024-01-09 11:32:17 +00:00
Tiago Ribeiro
14d19257df Merged in feature/68/update-evaluation-to-background (pull request #15)
Feature/68/update evaluation to background
2024-01-09 10:16:37 +00:00
Tiago Ribeiro
2cd18376f2 Merge branch 'develop' into feature/68/update-evaluation-to-background 2024-01-08 20:59:55 +00:00
Tiago Ribeiro
0694950bba Solved a bug where the "Perfect answer" would not show for speaking 2024-01-08 20:59:20 +00:00
Tiago Ribeiro
c6b15eaca1 Solved a small bug 2024-01-08 20:57:03 +00:00
Tiago Ribeiro
9ceb71ae2f Refactored evaluation process for improved efficiency:
- Initial response set to null; Frontend now generates a list of anticipated stats;
- Background evaluation dynamically updates DB upon completion;
- Frontend actively monitors and finalizes upon stat evaluation completion.
2024-01-08 17:02:46 +00:00
Tiago Ribeiro
957400cb82 Payment Record: Created Filters for Corporate and Country Manager that has submitted file in payment records 2024-01-07 15:22:46 +00:00
Tiago Ribeiro
e687a2b3e5 Payment Record: Prevent the tick without all files submitted 2024-01-07 15:06:59 +00:00
Tiago Ribeiro
026730c077 Updated the evaluation to work recursively when failing 2024-01-03 15:32:51 +00:00
Tiago Ribeiro
35d1157b0c Added the ability for a corporate account to check the payment record 2024-01-03 10:41:00 +00:00
Tiago Ribeiro
06dc92fdaa Added a confirmation for the payment record 2024-01-03 10:33:37 +00:00
Tiago Ribeiro
c9cac3539c Made sure it only happens for corporate students 2024-01-02 11:48:15 +00:00
Tiago Ribeiro
d2276eba1d Made it so that students, connected to a corporate, if they change their e-mail, they get unassigned 2024-01-02 11:07:18 +00:00
Tiago Ribeiro
1c2c3fe402 Lock the e-mail on input if the code has an e-mail associated 2023-12-31 15:22:42 +00:00
João Ramos
d4b90b5fa4 Merged in feature-payment-corporate-id (pull request #10)
Replaced display of payment id with corporate's id

Approved-by: Tiago Ribeiro
2023-12-29 10:47:55 +00:00
Tiago Ribeiro
383ddde7b5 Merged develop into feature-payment-corporate-id 2023-12-29 10:42:20 +00:00
João Ramos
e56636ca1f Merged in bug-fixing-271223 (pull request #14)
Bug fixing 271223

Approved-by: Tiago Ribeiro
2023-12-29 10:41:53 +00:00
João Ramos
e0be2fd222 Merged develop into feature-payment-corporate-id 2023-12-28 18:29:12 +00:00
Joao Ramos
9e23e3e608 Added support for currency in the CSV 2023-12-28 18:28:18 +00:00
João Ramos
47ecc2be27 Merged develop into bug-fixing-271223 2023-12-28 18:13:01 +00:00
Joao Ramos
3ca0ad353e Removed unnecessary code 2023-12-28 18:11:30 +00:00
Tiago Ribeiro
5447c89da4 Updated the permissions to be admin instead of owner 2023-12-28 11:44:21 +00:00
Joao Ramos
c88757c869 Profile is now auto filled with the user data 2023-12-27 22:33:11 +00:00
Joao Ramos
8831729470 Changed behaviour for new payment values 2023-12-27 22:14:11 +00:00
Joao Ramos
b3bb5a2337 Added search area for table users 2023-12-27 21:44:13 +00:00
Joao Ramos
b7ddee1db2 Company Name is now displayed on the Settings table, if available 2023-12-27 21:06:06 +00:00
João Ramos
d85b9db535 Merged develop into feature-payment-corporate-id 2023-12-27 19:27:04 +00:00
Joao Ramos
d03d790327 Merge branch 'develop' into feature-payment-corporate-id
CSV Improvements after merge issues
2023-12-27 19:21:02 +00:00
Tiago Ribeiro
79b159f948 Merged in improvement/stats-page-updaes (pull request #13)
Updates to the Stats page according to the client's requests
2023-12-27 15:03:03 +00:00
Tiago Ribeiro
3a0a9e1e99 Merge branch 'develop' into improvement/stats-page-updaes 2023-12-27 15:02:12 +00:00
João Ramos
cc2d0bf1b0 Merged in feature-paymentFilters (pull request #8)
Added payment and date filter

Approved-by: Tiago Ribeiro
2023-12-27 14:54:08 +00:00
João Ramos
03a199983b Merged develop into feature-paymentFilters 2023-12-27 14:52:53 +00:00
Tiago Ribeiro
a07e5a7312 Merge branch 'develop' into improvement/stats-page-updaes 2023-12-27 14:50:10 +00:00
Joao Ramos
fe5833b061 Fixed Date range picker label 2023-12-27 14:50:10 +00:00
Joao Ramos
0c2200f49f Removed debugger 2023-12-27 14:48:55 +00:00
Tiago Ribeiro
cb73196503 Limited the chevron to only work if it does not go after today 2023-12-27 14:47:42 +00:00
Tiago Ribeiro
c5fe405389 Updated the scale to be between 0 and 9 2023-12-27 09:53:45 +00:00
Tiago Ribeiro
fddc3ff2f3 Finished updating the stats page according to the client's requests 2023-12-27 09:14:13 +00:00
João Ramos
9dbe876d65 Merged in feature-payment-block-corporate-update (pull request #9)
Blocked Corporate user update through payment records screen

Approved-by: Tiago Ribeiro
2023-12-26 20:25:46 +00:00
Tiago Ribeiro
fd402bbd32 Merged develop into feature-payment-block-corporate-update 2023-12-26 20:24:39 +00:00
João Ramos
f2aa377cfe Merged in feature-country-manager-flag (pull request #11)
Feature country manager flag

Approved-by: Tiago Ribeiro
2023-12-26 20:23:56 +00:00
Tiago Ribeiro
0f0223725e Merged develop into feature-paymentFilters 2023-12-26 20:23:40 +00:00
Tiago Ribeiro
3ef29e43f5 Merged develop into feature-country-manager-flag 2023-12-26 20:22:06 +00:00
João Ramos
60a7835040 Merged in feature-payment-export-csv (pull request #12)
Feature payment export csv

Approved-by: Tiago Ribeiro
2023-12-26 20:21:13 +00:00
Tiago Ribeiro
1c645fcba2 Added an ID to every payment record column 2023-12-23 20:44:04 +00:00
Joao Ramos
938a5e9c7c Removed debugger 2023-12-23 14:52:56 +00:00
Joao Ramos
cc655fed6c Added flag display for agents 2023-12-23 14:51:28 +00:00
Joao Ramos
7f9692a3d9 Replaced display of payment id with corporate's id 2023-12-23 14:38:18 +00:00
Joao Ramos
cf90cae4eb Blocked Corporate user update through payment records screen 2023-12-23 14:29:24 +00:00
Joao Ramos
fea8e0672e Added Export CSV 2023-12-23 00:01:24 +00:00
Joao Ramos
359748841f Added payment and date filter 2023-12-22 23:22:25 +00:00
Tiago Ribeiro
438778a03c Added more control over the stats appearing in the stats page 2023-12-18 22:42:14 +00:00
Tiago Ribeiro
c37bb2691b Added the ability to view the stats in a specific time interval 2023-12-18 17:57:27 +00:00
João Ramos
6c49409de8 Merged in bug-fixing-140223 (pull request #7)
Bug fixing 140223
2023-12-15 15:52:07 +00:00
Tiago Ribeiro
2a335026de Used a already set constant instead of creating a new function 2023-12-15 10:53:25 +00:00
Tiago Ribeiro
7712e5c71d Made it so it also reloads the users 2023-12-15 10:51:31 +00:00
Tiago Ribeiro
861d97222a Updated from companyName to name 2023-12-15 10:40:32 +00:00
Tiago Ribeiro
de862f635c Merge branch 'develop' into bug-fixing-140223 2023-12-15 10:37:38 +00:00
João Ramos
ae058422aa Merged in feature-paymentAssetManagement (pull request #5)
Added file storage handling for Corporate and Commission transfer

Approved-by: Tiago Ribeiro
2023-12-15 10:09:02 +00:00
Joao Ramos
44454d1e05 Changed corporate from user.name to user.companyName
Company name is now updateable
2023-12-14 17:57:39 +00:00
Joao Ramos
a2b9ba17a7 Replaced 'Agent' with 'Country Manager' on role display 2023-12-14 17:38:24 +00:00
Joao Ramos
6f61fe1564 Comission is now hidden from everyone apart from admins 2023-12-14 17:34:52 +00:00
Joao Ramos
73d7ddc4af Profile screen was crashing 2023-12-14 17:34:12 +00:00
João Ramos
263f4afa82 Merged develop into feature-paymentAssetManagement 2023-12-14 17:21:24 +00:00
Joao Ramos
45cf2dc279 Added a number asset to limit to a specific number of decimal cases if needed 2023-12-14 17:20:36 +00:00
João Ramos
786a425d85 Merged in feature-displayRole (pull request #6)
Show User type in the navbar

Approved-by: Tiago Ribeiro
2023-12-14 09:58:06 +00:00
Tiago Ribeiro
d57223bd01 Added a Fixed decimal point for the payment records 2023-12-14 09:52:47 +00:00
Joao Ramos
fbc2cff3f1 Minor identation changes 2023-12-13 23:58:48 +00:00
Joao Ramos
9ad4f077d1 Added user type to navbar 2023-12-13 23:57:32 +00:00
Joao Ramos
e2b6061310 Merge branch 'develop' into feature-paymentAssetManagement 2023-12-13 23:43:52 +00:00
Joao Ramos
b77e97a9d2 Changed icon used for replacing files 2023-12-13 23:39:22 +00:00
João Ramos
67925c8a9e Merged in feature-newPaymentOnUserUpdate (pull request #3)
Feature newPaymentOnUserUpdate

Approved-by: Tiago Ribeiro
2023-12-13 23:33:30 +00:00
Joao Ramos
020ecff29c Implemented file storage handling for Corporate Transfer and Comission Transfer 2023-12-13 23:29:14 +00:00
Tiago Ribeiro
964660ed5d Merged develop into feature-newPaymentOnUserUpdate 2023-12-13 22:01:43 +00:00
João Ramos
1390af62ab Merged in feature-levelScoringTiers (pull request #4)
Changed approach to display level for Level Testing

Approved-by: Tiago Ribeiro
2023-12-13 22:01:07 +00:00
Joao Ramos
15947f942c Fixed issue with payment records on update 2023-12-13 17:08:26 +00:00
Joao Ramos
7b3c3d15db Changed approach to display level for Level Testing 2023-12-13 00:14:34 +00:00
Joao Ramos
1cff6fe242 Temporary fix on date/data payment 2023-12-12 22:48:38 +00:00
Joao Ramos
4cbd045502 Added comission to user card and updated user update to insert a new payment entry 2023-12-12 22:44:33 +00:00
Joao Ramos
21b612eaa4 Removed references to Referred corporated 2023-12-11 22:34:42 +00:00
Tiago Ribeiro
ef18e304a1 - Created a package list for student packages;
- Updated the group creation wizard to work as a modal;
2023-12-11 13:43:23 +00:00
Tiago Ribeiro
8e4223a9e7 Slight tweak on the sidebar logout 2023-12-09 15:44:02 +00:00
Tiago Ribeiro
7d696735ba Improved a bit of the UI for the admin dashboard 2023-12-09 15:35:56 +00:00
Tiago Ribeiro
e0ecc5be05 Merge branch 'develop' into improvement-37/writing-evaluation-perfect-answer 2023-12-09 14:47:06 +00:00
João Ramos
77af0b3495 Merged in feature-multiplerandomexams (pull request #1)
Dynamic tests generation of assignment + Minor changes

Approved-by: Tiago Ribeiro
2023-12-09 14:43:48 +00:00
Tiago Ribeiro
e2e38284a7 Uncommented a section 2023-12-09 14:39:03 +00:00
Tiago Ribeiro
ccd2560451 Merged develop into feature-multiplerandomexams 2023-12-09 14:37:34 +00:00
João Ramos
390658f2b0 Merged in feature-removeCompanyReferences (pull request #2)
Changed Comercial labels to Corporate

Approved-by: Tiago Ribeiro
2023-12-09 14:28:30 +00:00
Joao Ramos
450a4e9fe3 Changed Comercial labels to Corporate 2023-12-08 15:43:19 +00:00
Joao Ramos
dfbbf0456d Revert "Changed Comercial labels to Corporate"
This reverts commit 9c8d7988c5.
2023-12-08 14:55:16 +00:00
Joao Ramos
d46f92edb2 Added Referenced corporate expiring in 1 month 2023-12-07 23:42:04 +00:00
Joao Ramos
26c4368f31 Minor improvement on reusability of filter function 2023-12-07 23:34:31 +00:00
Joao Ramos
ec56a5426b Added Inactive Referred corporate 2023-12-07 23:31:16 +00:00
Joao Ramos
fe32584ff9 Add Inactive Country manager 2023-12-07 23:23:39 +00:00
Joao Ramos
db7762c6e2 Replaced Teacher labels 2023-12-07 23:20:19 +00:00
Joao Ramos
e70e26f84c Updated checkbox string 2023-12-07 23:17:23 +00:00
Joao Ramos
7dc9d568d1 Replaced Teachers Icon 2023-12-07 23:13:42 +00:00
Joao Ramos
0049ab272b Added dynamic generation of exams as an option 2023-12-07 23:07:35 +00:00
Joao Ramos
f48885bba6 Updatd UI to display the unique tests for each user in an assignment 2023-12-07 18:23:44 +00:00
Joao Ramos
5eaa0ac269 Assignments now generate unique list of exams for each user 2023-12-07 18:23:00 +00:00
Joao Ramos
f7af21878e Separate get exam bussiness logic into a backend asset 2023-12-07 18:20:11 +00:00
Joao Ramos
9d4071d4cd Added debug settings for vscoe 2023-12-07 18:19:01 +00:00
Tiago Ribeiro
6f5dd86cd1 Updated so the new payment prefills with all of the corporate's payment information 2023-12-07 16:36:57 +00:00
Tiago Ribeiro
8b9537b272 Merge branch 'develop' into improvement-37/writing-evaluation-perfect-answer 2023-12-06 16:43:14 +00:00
Tiago Ribeiro
a526e76c70 Added a feature to allow a user to filter the payment record 2023-12-06 16:41:11 +00:00
Joao Ramos
62b2f477f4 Replaced Corporate Icon on Agent Dashboard 2023-12-06 15:54:49 +00:00
Joao Ramos
f36384fdb4 Replaced Corporate Icon on Admin dashboard 2023-12-06 15:43:44 +00:00
Joao Ramos
9c8d7988c5 Changed Comercial labels to Corporate 2023-12-06 15:16:48 +00:00
Tiago Ribeiro
18f163768c Made it so, when a user registers with an eCrop e-mail, they get the role of a developer 2023-12-06 15:15:50 +00:00
Tiago Ribeiro
72083439af Updated Writing and Speaking to have a tab system for the evaluation vs the "perfect answer" 2023-12-06 14:48:54 +00:00
Tiago Ribeiro
523149327b Turned the name into a fallback when there is no corporate name 2023-12-06 11:31:56 +00:00
69 changed files with 3743 additions and 1070 deletions

28
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,28 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev",
"serverReadyAction": {
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

View File

@@ -49,6 +49,7 @@
"random-words": "^2.0.0",
"react": "18.2.0",
"react-chartjs-2": "^5.2.0",
"react-csv": "^2.2.2",
"react-currency-input-field": "^3.6.12",
"react-datepicker": "^4.18.0",
"react-dom": "18.2.0",
@@ -78,6 +79,7 @@
"@types/lodash": "^4.14.191",
"@types/nodemailer": "^6.4.11",
"@types/nodemailer-express-handlebars": "^4.0.3",
"@types/react-csv": "^1.1.10",
"@types/react-datepicker": "^4.15.1",
"@types/uuid": "^9.0.1",
"@types/wavesurfer.js": "^6.0.6",

View File

@@ -62,7 +62,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
<form className="flex flex-col items-center justify-items-center gap-6 w-full h-full -md:px-4 lg:w-1/2 mb-32" onSubmit={save}>
{user.type === "agent" && (
<div className="w-full flex gap-8">
<Input type="text" onChange={setCompanyName} name="companyName" label="Company Name" required />
<Input type="text" onChange={setCompanyName} name="companyName" label="Corporate Name" required />
<Input
type="text"
onChange={setCommercialRegistration}

View File

@@ -1,5 +1,4 @@
import {infoButtonStyle} from "@/constants/buttonStyles";
import {BAND_SCORES} from "@/constants/ielts";
import {Module} from "@/interfaces";
import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
@@ -23,8 +22,8 @@ interface Props {
export default function Diagnostic({onFinish}: Props) {
const [focus, setFocus] = useState<"academic" | "general">();
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1});
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9});
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1, level: 0});
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9, level: 9});
const router = useRouter();
@@ -52,7 +51,7 @@ export default function Diagnostic({onFinish}: Props) {
axios
.patch("/api/users/update", {
focus,
levels: Object.values(levels).includes(-1) ? {reading: 0, listening: 0, writing: 0, speaking: 0} : levels,
levels: Object.values(levels).includes(-1) ? {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0} : levels,
desiredLevels,
isFirstLogin: false,
})

View File

@@ -26,6 +26,8 @@ export default function Writing({
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (localStorage.getItem("enable_paste")) return;
const listener = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
e.preventDefault();
@@ -93,22 +95,8 @@ export default function Writing({
)}
<div className="flex flex-col h-full w-full gap-9 mb-20">
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<span>
{prefix.split("\\n").map((line, index) => (
<React.Fragment key={index}>
{line}
<br />
</React.Fragment>
))}
</span>
<span className="font-semibold">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
<p>{line}</p>
<br />
</Fragment>
))}
</span>
<span className="whitespace-pre-wrap">{prefix.replaceAll("\\n", "\n")}</span>
<span className="font-semibold whitespace-pre-wrap">{prompt.replaceAll("\\n", "\n")}</span>
{attachment && (
<img
onClick={() => setIsModalOpen(true)}
@@ -120,14 +108,7 @@ export default function Writing({
</div>
<div className="w-full h-full flex flex-col gap-4">
<span>
{suffix.split("\\n").map((line, index) => (
<React.Fragment key={index}>
{line}
<br />
</React.Fragment>
))}
</span>
<span className="whitespace-pre-wrap">{suffix}</span>
<textarea
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full min-h-[300px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"

View File

@@ -0,0 +1,30 @@
import {Module} from "@/interfaces";
import clsx from "clsx";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
interface Props {
module: Module;
children: string;
}
export default function Badge({module, children}: Props) {
return (
<div
key={module}
className={clsx(
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="w-4 h-4" />}
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
{module === "writing" && <BsPen className="w-4 h-4" />}
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
{module === "level" && <BsClipboard className="w-4 h-4" />}
<span className="text-sm">{children}</span>
</div>
);
}

View File

@@ -6,11 +6,15 @@ interface Props {
isChecked: boolean;
onChange: (isChecked: boolean) => void;
children: ReactNode;
disabled?: boolean;
}
export default function Checkbox({isChecked, onChange, children}: Props) {
export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
return (
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => onChange(!isChecked)}>
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => {
if(disabled) return;
onChange(!isChecked);
}}>
<input type="checkbox" className="hidden" />
<div
className={clsx(

View File

@@ -103,7 +103,7 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
)}>
Record
</Link>
{["admin", "developer", "agent"].includes(user.type) && (
{["admin", "developer", "agent", "corporate"].includes(user.type) && (
<Link
href="/payment-record"
className={clsx(

View File

@@ -8,6 +8,8 @@ import clsx from "clsx";
import moment from "moment";
import MobileMenu from "./MobileMenu";
import {useState} from "react";
import {Type} from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user";
interface Props {
user: User;
@@ -69,7 +71,9 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
)}
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden">
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
<span className="text-right -md:hidden">{user.name}</span>
<span className="text-right -md:hidden">
{user.name} | {USER_TYPE_LABELS[user.type]}
</span>
</Link>
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
<BsList className="text-mti-purple-light w-8 h-8" />

View File

@@ -0,0 +1,150 @@
import React, {ChangeEvent} from "react";
import {BsUpload, BsDownload, BsTrash, BsArrowRepeat, BsXCircleFill} from "react-icons/bs";
import {FilesStorage} from "@/interfaces/storage.files";
import axios from "axios";
interface Asset {
file: string | File;
complete: boolean;
}
const PaymentAssetManager = (props: {
asset: string | undefined;
permissions: "read" | "write";
type: FilesStorage;
reload: () => void;
paymentId: string;
canEdit: boolean;
}) => {
const {asset, permissions, type, paymentId} = props;
const fileInputRef = React.useRef<HTMLInputElement>(null);
const fileInputReplaceRef = React.useRef<HTMLInputElement>(null);
const [managingAsset, setManagingAsset] = React.useState<Asset>({
file: asset || "",
complete: asset ? true : false,
});
const {file, complete} = managingAsset;
const deleteAsset = () => {
if (confirm("Are you sure you want to delete this document?")) {
axios
.delete(`/api/payments/files/${type}/${paymentId}`)
.then((response) => {
if (response.status === 200) {
console.log("File deleted successfully!");
setManagingAsset({
file: "",
complete: false,
});
return;
}
console.error("File deletion failed");
})
.catch((error) => {
console.error("Error occurred during file deletion:", error);
})
.finally(props.reload);
}
};
const renderFileInput = (onChange: any, ref: React.RefObject<HTMLInputElement>) => (
<input type="file" ref={ref} style={{display: "none"}} onChange={onChange} multiple={false} accept="application/pdf" />
);
const handleFileChange = async (e: Event, method: "post" | "patch") => {
const newFile = (e.target as HTMLInputElement).files?.[0];
if (newFile) {
setManagingAsset({
file: newFile,
complete: false,
});
const formData = new FormData();
formData.append("file", newFile);
axios[method](`/api/payments/files/${type}/${paymentId}`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((response) => {
if (response.status === 200) {
console.log("File uploaded successfully!");
console.log("Uploaded File URL:", response.data.ref);
// Further actions upon successful upload
setManagingAsset({
file: response.data.ref,
complete: true,
});
return;
}
console.error("File upload failed");
})
.catch((error) => {
console.error("Error occurred during file upload:", error);
})
.finally(props.reload);
}
};
const downloadAsset = () => {
axios
.get(`/api/payments/files/${type}/${paymentId}`)
.then((response) => {
if (response.status === 200) {
console.log("Uploaded File URL:", response.data.url);
const link = document.createElement("a");
link.download = response.data.filename;
link.href = response.data.url;
link.click();
return;
}
console.error("Failed to download file");
})
.catch((error) => {
console.error("Error occurred during file upload:", error);
});
};
if (permissions === "read") {
if (file) return <BsDownload onClick={downloadAsset} />;
return null;
}
if (file) {
if (complete) {
return (
<>
<BsDownload onClick={downloadAsset} />
{props.canEdit && (
<>
<BsArrowRepeat onClick={() => fileInputReplaceRef.current?.click()} />
<BsTrash onClick={deleteAsset} />
{renderFileInput((e: Event) => handleFileChange(e, "patch"), fileInputReplaceRef)}
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
</>
)}
</>
);
}
return <span className="loading loading-infinity w-8" />;
}
return props.canEdit ? (
<>
<BsUpload onClick={() => fileInputRef.current?.click()} />
{renderFileInput((e: Event) => handleFileChange(e, "post"), fileInputRef)}
</>
) : (
<BsXCircleFill />
);
};
export default PaymentAssetManager;

View File

@@ -99,7 +99,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
)}
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
{["admin", "developer", "agent"].includes(userType || "") && (
{["admin", "developer", "agent", "corporate"].includes(userType || "") && (
<Nav
disabled={disableNavigation}
Icon={BsCurrencyDollar}
@@ -144,7 +144,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
)}
</div>
<div className="flex flex-col gap-0 absolute bottom-12">
<div className="flex flex-col gap-0 bottom-12 fixed">
<div
role="button"
tabIndex={1}

View File

@@ -6,6 +6,8 @@ import Button from "../Low/Button";
import dynamic from "next/dynamic";
import axios from "axios";
import {speakingReverseMarking} from "@/utils/score";
import {Tab} from "@headlessui/react";
import clsx from "clsx";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
@@ -47,7 +49,7 @@ export default function InteractiveSpeaking({
</div>
<div className="flex flex-col gap-4">
<span className="font-bold">You should talk about the following things:</span>
<div className="grid grid-cols-3 gap-6 text-center">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 text-center">
{prompts.map((x, index) => (
<div className="italic flex flex-col gap-2 text-sm" key={index}>
<video key={index} controls className="">
@@ -61,11 +63,11 @@ export default function InteractiveSpeaking({
</div>
<div className="w-full h-full flex flex-col gap-8">
<div className="flex items-center gap-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{solutionsURL.map((x, index) => (
<div
key={index}
className="w-full min-w-[460px] p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<div className="flex gap-8 items-center justify-center py-8">
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
</div>
@@ -73,7 +75,7 @@ export default function InteractiveSpeaking({
))}
</div>
{userSolutions && userSolutions.length > 0 && (
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
@@ -82,9 +84,81 @@ export default function InteractiveSpeaking({
</div>
))}
</div>
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
{userSolutions[0].evaluation!.comment}
</div>
{userSolutions[0].evaluation &&
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? (
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Evaluation
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Recommended Answer (Prompt 1)
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Recommended Answer (Prompt 2)
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Recommended Answer (Prompt 3)
</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer_1!.replaceAll(/\s{2,}/g, "\n\n")}
</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer_2!.replaceAll(/\s{2,}/g, "\n\n")}
</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer_3!.replaceAll(/\s{2,}/g, "\n\n")}
</span>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
) : (
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-speaking/10 rounded-3xl">
{userSolutions[0].evaluation!.comment}
</div>
)}
</div>
)}
</div>

View File

@@ -6,6 +6,8 @@ import Button from "../Low/Button";
import dynamic from "next/dynamic";
import axios from "axios";
import {speakingReverseMarking} from "@/utils/score";
import {Tab} from "@headlessui/react";
import clsx from "clsx";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
@@ -69,7 +71,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
</div>
</div>
{userSolutions && userSolutions.length > 0 && (
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
@@ -78,9 +80,52 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
</div>
))}
</div>
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
{userSolutions[0].evaluation!.comment}
</div>
{userSolutions[0].evaluation &&
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Evaluation
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Recommended Answer
</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer &&
userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n")}
{userSolutions[0].evaluation!.perfect_answer_1 &&
userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")}
</span>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
) : (
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-speaking/10 rounded-3xl">
{userSolutions[0].evaluation!.comment}
</div>
)}
</div>
)}
</div>

View File

@@ -1,15 +1,11 @@
/* eslint-disable @next/next/no-img-element */
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {WritingExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify";
import {Fragment, useState} from "react";
import Button from "../Low/Button";
import {Dialog, Transition} from "@headlessui/react";
import {Dialog, Tab, Transition} from "@headlessui/react";
import {writingReverseMarking} from "@/utils/score";
import clsx from "clsx";
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -79,7 +75,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
/>
</div>
)}
{userSolutions && userSolutions.length > 0 && (
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
@@ -88,9 +84,48 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
</div>
))}
</div>
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
{userSolutions[0].evaluation!.comment}
</div>
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
)
}>
Evaluation
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
)
}>
Recommended Answer
</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n")}
</span>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
) : (
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-writing/10 rounded-3xl">
{userSolutions[0].evaluation!.comment}
</div>
)}
</div>
)}
</div>

View File

@@ -36,9 +36,30 @@ interface Props {
onViewStudents?: () => void;
onViewTeachers?: () => void;
onViewCorporate?: () => void;
disabled?: boolean;
}
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate}: Props) => {
const USER_STATUS_OPTIONS = [
{
value: 'active',
label: 'Active',
}, {
value: 'disabled',
label: 'Disabled',
}, {
value: 'paymentDue',
label: 'Payment Due',
}
];
const USER_TYPE_OPTIONS = Object.keys(USER_TYPE_LABELS).map((type) => ({
value: type,
label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]
}));
const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency}) => ({ value: currency, label }));
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false}: Props) => {
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
const [type, setType] = useState(user.type);
const [status, setStatus] = useState(user.status);
@@ -58,9 +79,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
);
const [userAmount, setUserAmount] = useState(user.type === "corporate" ? user.corporateInformation?.companyInformation.userAmount : undefined);
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : undefined);
const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : "EUR");
const [monthlyDuration, setMonthlyDuration] = useState(user.type === "corporate" ? user.corporateInformation?.monthlyDuration : undefined);
const [commissionValue, setCommission] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.commission : undefined);
const {stats} = useStats(user.id);
const {users} = useUsers();
@@ -90,7 +111,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
agentInformation:
type === "agent"
? {
companyName,
name: companyName,
commercialRegistration,
}
: undefined,
@@ -100,12 +121,13 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
referralAgent,
monthlyDuration,
companyInformation: {
companyName,
name: companyName,
userAmount,
},
payment: {
value: paymentValue,
currency: paymentCurrency,
...(referralAgent === "" ? {} : {commission: commissionValue}),
},
}
: undefined,
@@ -146,22 +168,24 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
<Input
label="Company Name"
label="Corporate Name"
type="text"
name="companyName"
onChange={setCompanyName}
placeholder="Enter company name"
placeholder="Enter corporate name"
defaultValue={companyName}
required
disabled={disabled}
/>
<Input
label="Commercial Registration"
type="text"
name="commercialRegistration"
onChange={setCommercialRegistration}
placeholder="Enter company name"
placeholder="Enter commercial registration"
defaultValue={commercialRegistration}
required
disabled={disabled}
/>
</div>
<Divider className="w-full !m-0" />
@@ -171,12 +195,13 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
<Input
label="Company Name"
label="Corporate Name"
type="text"
name="companyName"
onChange={setCompanyName}
placeholder="Enter company name"
placeholder="Enter corporate name"
defaultValue={companyName}
disabled={disabled}
/>
<Input
label="Number of Users"
@@ -185,6 +210,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
onChange={(e) => setUserAmount(e ? parseInt(e) : undefined)}
placeholder="Enter number of users"
defaultValue={userAmount}
disabled={disabled}
/>
<Input
label="Monthly Duration"
@@ -193,9 +219,47 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
onChange={(e) => setMonthlyDuration(e ? parseInt(e) : undefined)}
placeholder="Enter monthly duration"
defaultValue={monthlyDuration}
disabled={disabled}
/>
<div className="flex flex-col gap-3 w-full">
<div className="flex flex-col gap-3 w-full lg:col-span-2">
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
<div className="w-full grid grid-cols-5 gap-2">
<Input
name="paymentValue"
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
type="number"
defaultValue={paymentValue || 0}
className="col-span-3"
disabled={disabled}
/>
<Select
className="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={CURRENCIES_OPTIONS}
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
onChange={(value) => setPaymentCurrency(value?.value)}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
isDisabled={disabled}
/>
</div>
</div>
</div>
<div className="flex gap-3 w-full">
<div className="flex flex-col gap-3 w-8/12">
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
{referralAgentLabel && (
<Select
@@ -225,31 +289,26 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
color: state.isFocused ? "black" : styles.color,
}),
}}
isDisabled={disabled}
/>
)}
</div>
<div className="flex flex-col gap-3 w-full lg:col-span-2">
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
<div className="w-full grid grid-cols-5 gap-2">
<Input
name="paymentValue"
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
type="number"
defaultValue={paymentValue || 0}
className="col-span-3"
/>
<select
defaultValue={paymentCurrency}
onChange={(e) => setPaymentCurrency(e.target.value)}
className="p-6 col-span-2 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
{CURRENCIES.map(({label, currency}) => (
<option value={currency} key={currency}>
{label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-3 w-4/12">
{referralAgent !== "" ? (
<>
<label className="font-normal text-base text-mti-gray-dim">Commission</label>
<Input
name="commissionValue"
onChange={(e) => setCommission(e ? parseInt(e) : undefined)}
type="number"
defaultValue={commissionValue || 0}
className="col-span-3"
disabled={disabled}
/>
</>
) : (
<div />
)}
</div>
</div>
<Divider className="w-full !m-0" />
@@ -299,7 +358,8 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
<RadioGroup
value={user.demographicInformation?.employment}
className="grid grid-cols-2 items-center gap-4 place-items-center">
className="grid grid-cols-2 items-center gap-4 place-items-center"
disabled={disabled}>
{EMPLOYMENT_STATUS.map(({status, label}) => (
<RadioGroup.Option value={status} key={status}>
{({checked}) => (
@@ -334,7 +394,11 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<div className="flex flex-col gap-8 w-full">
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
<RadioGroup value={user.demographicInformation?.gender} className="flex flex-row gap-4 justify-between">
<RadioGroup
value={user.demographicInformation?.gender}
className="flex flex-row gap-4 justify-between"
disabled={disabled}
>
<RadioGroup.Option value="male">
{({checked}) => (
<span
@@ -384,7 +448,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
<Checkbox
isChecked={!!expiryDate}
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}>
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
disabled={disabled}
>
Enabled
</Checkbox>
</div>
@@ -417,6 +483,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
dateFormat="dd/MM/yyyy"
selected={moment(expiryDate).toDate()}
onChange={(date) => setExpiryDate(date)}
disabled={disabled}
/>
)}
</div>
@@ -428,27 +495,55 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<div className="flex flex-col md:flex-row gap-8 w-full">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Status</label>
<select
defaultValue={user.status}
onChange={(e) => setStatus(e.target.value as typeof user.status)}
className="p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
<option value="active">Active</option>
<option value="disabled">Disabled</option>
<option value="paymentDue">Payment Due</option>
</select>
<Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={USER_STATUS_OPTIONS}
value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
onChange={(value) => setStatus(value?.value as typeof user.status)}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
isDisabled={disabled}
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Type</label>
<select
defaultValue={user.type}
onChange={(e) => setType(e.target.value as typeof user.type)}
className="p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
{Object.keys(USER_TYPE_LABELS).map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
<Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={USER_TYPE_OPTIONS}
value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
onChange={(value) => setType(value?.value as typeof user.type)}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
isDisabled={disabled}
/>
</div>
</div>
</>
@@ -477,7 +572,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}>
Close
</Button>
<Button onClick={updateUser} className="w-full max-w-[200px]">
<Button disabled={disabled} onClick={updateUser} className="w-full max-w-[200px]">
Update
</Button>
</div>

View File

@@ -2,96 +2,119 @@ import {Module} from "@/interfaces";
export const MODULES: Module[] = ["reading", "listening", "writing", "speaking"];
export const BAND_SCORES: {[key in Module]: number[]} = {
reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
level: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
};
// BAND SCORES is not in use anymore and level scoring is made based on thresholds
// export const BAND_SCORES: {[key in Module]: number[]} = {
// reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
// listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9],
// writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
// speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
// level: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
// };
export const moduleResultText = (level: number) => {
if (level === 9) {
return (
<>
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating
excellent mastery of the assessed knowledge.
<br />
<br />
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
the results.
<br />
<br />
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
academic journey.
</>
);
}
export type LevelScore = "Advanced" | "Upper-Intermediate" | "Intermediate" | "Pre-Intermediate" | "Elementary" | "Beginner";
if (level >= 6) {
return (
<>
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
good understanding of the assessed knowledge.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
journey.
</>
);
}
if (level >= 3) {
return (
<>
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a
satisfactory understanding of the assessed knowledge.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
journey.
</>
);
}
const generateHighestScoreText = () : React.ReactNode => (
<>
<br />
<br />
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
the results.
<br />
<br />
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
academic journey.
</>
);
return (
<>
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the
required standards.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
endeavors.
</>
);
};
const generateAverageScoreText = () : React.ReactNode => (
<>
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
journey.
</>
);
const generateLowestScoreText = () : React.ReactNode => (
<>
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
endeavors.
</>
)
export const levelResultText = (level: number) => {
if(level === 9) {
return (
<>
{"Outstanding! Your command of English is excellent. Focus on fine-tuning subtle language nuances and exploring sophisticated vocabulary. Keep up the excellent work!"}
{generateHighestScoreText()}
</>
);
}
if(level >= 8) {
return (
<>
{"Impressive! You're approaching fluency. Continue refining nuances in grammar and expanding your vocabulary to express ideas more precisely."}
{generateAverageScoreText()}
</>
);
}
if(level >= 6) {
return (
<>
{"Great job! You're navigating the complexities of English. Keep honing your grammar skills and exploring more advanced vocabulary."}
{generateAverageScoreText()}
</>
);
}
if(level >= 4) {
return (
<>
{"Well done! You're moving beyond the basics. Work on expanding your vocabulary and refining your understanding of grammar structures."}
{generateAverageScoreText()}
</>
);
}
if(level >= 2) {
return (
<>
{"Good effort! You're making progress. Continue studying and pay attention to common vocabulary and fundamental grammar rules."}
{generateAverageScoreText()}
</>
);
}
if(level >= 0) {
return (
<>
{"Keep practicing! You're just starting, and improvement takes time. Focus on building your vocabulary and basic grammar skills."}
{generateLowestScoreText()}
</>
);
}
return null;
};
export const moduleResultText = (module: Module, level: number) => {
if(module === 'level') return levelResultText(level);
if (level === 9) {
return (
<>
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating
excellent mastery of the assessed knowledge.
<br />
<br />
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of
the results.
<br />
<br />
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
academic journey.
{generateHighestScoreText()}
</>
);
}
@@ -101,14 +124,7 @@ export const levelResultText = (level: number) => {
<>
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
good understanding of the assessed knowledge.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
journey.
{generateAverageScoreText()}
</>
);
}
@@ -118,14 +134,7 @@ export const levelResultText = (level: number) => {
<>
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a
satisfactory understanding of the assessed knowledge.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
journey.
{generateAverageScoreText()}
</>
);
}
@@ -134,14 +143,7 @@ export const levelResultText = (level: number) => {
<>
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the
required standards.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
endeavors.
{generateLowestScoreText()}
</>
);
};
};

View File

@@ -7,16 +7,7 @@ import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
import {useEffect, useState} from "react";
import {
BsArrowLeft,
BsBriefcaseFill,
BsGlobeCentralSouthAsia,
BsPerson,
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPersonLinesFill,
} from "react-icons/bs";
import {BsArrowLeft, BsBriefcaseFill, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPencilSquare, BsBank} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import IconCard from "./IconCard";
@@ -43,6 +34,12 @@ export default function AdminDashboard({user}: Props) {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(reload, [page]);
const inactiveCountryManagerFilter = (x: User) =>
x.type === "agent" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
@@ -149,6 +146,24 @@ export default function AdminDashboard({user}: Props) {
</>
);
const InactiveCountryManagerList = () => {
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Inactive Country Managers ({users.filter(inactiveCountryManagerFilter).length})</h2>
</div>
<UserList user={user} filters={[inactiveCountryManagerFilter]} />
</>
);
};
const InactiveStudentsList = () => {
const filter = (x: User) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
@@ -191,7 +206,7 @@ export default function AdminDashboard({user}: Props) {
const DefaultDashboard = () => (
<>
<section className="w-full flex flex-wrap gap-4 items-center justify-between">
<section className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 place-items-center items-center justify-between">
<IconCard
Icon={BsPersonFill}
label="Students"
@@ -200,14 +215,14 @@ export default function AdminDashboard({user}: Props) {
color="purple"
/>
<IconCard
Icon={BsPersonLinesFill}
Icon={BsPencilSquare}
label="Teachers"
value={users.filter((x) => x.type === "teacher").length}
onClick={() => setPage("teachers")}
color="purple"
/>
<IconCard
Icon={BsPersonLinesFill}
Icon={BsBank}
label="Corporate"
value={users.filter((x) => x.type === "corporate").length}
onClick={() => setPage("corporate")}
@@ -236,6 +251,13 @@ export default function AdminDashboard({user}: Props) {
}
color="rose"
/>
<IconCard
onClick={() => setPage("inactiveCountryManagers")}
Icon={BsPerson}
label="Inactive Country Managers"
value={users.filter(inactiveCountryManagerFilter).length}
color="rose"
/>
<IconCard
onClick={() => setPage("inactiveCorporate")}
Icon={BsPerson}
@@ -298,7 +320,7 @@ export default function AdminDashboard({user}: Props) {
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Teachers expiring in 1 month</span>
<span className="p-4">Country Manager expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
@@ -342,7 +364,7 @@ export default function AdminDashboard({user}: Props) {
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Teachers</span>
<span className="p-4">Expired Country Manager</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
@@ -454,6 +476,7 @@ export default function AdminDashboard({user}: Props) {
{page === "agents" && <AgentsList />}
{page === "inactiveStudents" && <InactiveStudentsList />}
{page === "inactiveCorporate" && <InactiveCorporateList />}
{page === "inactiveCountryManagers" && <InactiveCountryManagerList />}
{page === "" && <DefaultDashboard />}
</>
);

View File

@@ -7,20 +7,7 @@ import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
import {useEffect, useState} from "react";
import {
BsArrowLeft,
BsClipboard2Data,
BsClipboard2DataFill,
BsClock,
BsGlobeCentralSouthAsia,
BsPaperclip,
BsPerson,
BsPersonAdd,
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPersonLinesFill,
} from "react-icons/bs";
import {BsArrowLeft, BsPersonFill, BsBank} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
@@ -50,6 +37,8 @@ export default function AgentDashboard({user}: Props) {
const corporateFilter = (user: User) => user.type === "corporate";
const referredCorporateFilter = (x: User) =>
x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
const inactiveReferredCorporateFilter = (x: User) =>
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
const UserDisplay = (displayUser: User) => (
<div
@@ -68,8 +57,6 @@ export default function AgentDashboard({user}: Props) {
);
const ReferredCorporateList = () => {
const filter = (x: User) => x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
return (
<>
<div className="flex flex-col gap-4">
@@ -79,10 +66,28 @@ export default function AgentDashboard({user}: Props) {
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(filter).length})</h2>
<h2 className="text-2xl font-semibold">Corporate ({users.filter(referredCorporateFilter).length})</h2>
</div>
<UserList user={user} filters={[filter]} />
<UserList user={user} filters={[referredCorporateFilter]} />
</>
);
};
const InactiveReferredCorporateList = () => {
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(inactiveReferredCorporateFilter).length})</h2>
</div>
<UserList user={user} filters={[inactiveReferredCorporateFilter]} />
</>
);
};
@@ -99,7 +104,7 @@ export default function AgentDashboard({user}: Props) {
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(filter).length})</h2>
<h2 className="text-2xl font-semibold">Corporate ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filters={[filter]} />
@@ -113,13 +118,20 @@ export default function AgentDashboard({user}: Props) {
<IconCard
onClick={() => setPage("referredCorporate")}
Icon={BsPersonFill}
label="Referred Corporate"
label="Corporate"
value={users.filter(referredCorporateFilter).length}
color="purple"
/>
<IconCard
onClick={() => setPage("corporate")}
onClick={() => setPage("inactiveReferredCorporate")}
Icon={BsPersonFill}
label="Inactive Corporate"
value={users.filter(inactiveReferredCorporateFilter).length}
color="rose"
/>
<IconCard
onClick={() => setPage("corporate")}
Icon={BsBank}
label="Corporate"
value={users.filter(corporateFilter).length}
color="purple"
@@ -128,7 +140,7 @@ export default function AgentDashboard({user}: Props) {
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest Referred Corporate</span>
<span className="p-4">Latest Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(referredCorporateFilter)
@@ -149,6 +161,21 @@ export default function AgentDashboard({user}: Props) {
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Corporate expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
referredCorporateFilter(x) &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</>
);
@@ -177,6 +204,7 @@ export default function AgentDashboard({user}: Props) {
</Modal>
{page === "referredCorporate" && <ReferredCorporateList />}
{page === "corporate" && <CorporateList />}
{page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
{page === "" && <DefaultDashboard />}
</>
);

View File

@@ -18,6 +18,7 @@ import {getExam} from "@/utils/exams";
import {toast} from "react-toastify";
import {uuidv4} from "@firebase/util";
import {Assignment} from "@/interfaces/results";
import Checkbox from "@/components/Low/Checkbox";
interface Props {
isCreating: boolean;
@@ -35,6 +36,8 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
const [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "day").toDate());
const [endDate, setEndDate] = useState<Date | null>(assignment ? moment(assignment.endDate).toDate() : moment().add(8, "day").toDate());
// creates a new exam for each assignee or just one exam for all assignees
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module);
@@ -46,36 +49,33 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
};
const createAssignment = () => {
setIsLoading(true);
setIsLoading(true);
const examPromises = selectedModules.map(async (module) => getExam(module, false));
Promise.all(examPromises)
.then((exams) => {
(assignment ? axios.patch : axios.post)(`/api/assignments${assignment ? `/${assignment.id}` : ""}`, {
assigner,
assignees,
name,
startDate,
endDate,
results: [],
exams: exams.map((e) => ({module: e?.module, id: e?.id})),
})
.then(() => {
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
cancelCreation();
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
})
.finally(() => setIsLoading(false));
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
setIsLoading(false);
});
};
(assignment ? axios.patch : axios.post)(
`/api/assignments${assignment ? `/${assignment.id}` : ""}`,
{
assignees,
name,
startDate,
endDate,
selectedModules,
generateMultiple,
}
)
.then(() => {
toast.success(
`The assignment "${name}" has been ${
assignment ? "updated" : "created"
} successfully!`
);
cancelCreation();
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
})
.finally(() => setIsLoading(false));
};
const deleteAssignment = () => {
if (assignment) {
@@ -284,6 +284,11 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
))}
</div>
</section>
<div className="flex gap-4 w-full justify-end">
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple(d => !d)}>
Generate different exams
</Checkbox>
</div>
<div className="flex gap-4 w-full justify-end">
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
Cancel

View File

@@ -19,7 +19,7 @@ import {
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPersonLinesFill,
BsPencilSquare,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
@@ -170,7 +170,7 @@ export default function CorporateDashboard({user}: Props) {
/>
<IconCard
onClick={() => setPage("teachers")}
Icon={BsPersonLinesFill}
Icon={BsPencilSquare}
label="Teachers"
value={users.filter(teacherFilter).length}
color="purple"

View File

@@ -19,7 +19,7 @@ export default function IconCard({Icon, label, value, color, onClick}: Props) {
return (
<div
onClick={onClick}
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
<Icon className={clsx("text-6xl", colorClasses[color])} />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">{label}</span>

View File

@@ -38,7 +38,7 @@ export default function StudentDashboard({user}: Props) {
const setAssignment = useExamStore((state) => state.setAssignment);
const startAssignment = (assignment: Assignment) => {
const examPromises = assignment.exams.map((e) => getExamById(e.module, e.id));
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
@@ -121,6 +121,7 @@ export default function StudentDashboard({user}: Props) {
<div className="flex justify-between w-full items-center">
<div className="grid grid-cols-2 gap-2 place-items-center justify-center w-fit min-w-[104px] -md:mt-2">
{assignment.exams
.filter((e) => e.assignee === user.id)
.map((e) => e.module)
.sort(sortByModuleName)
.map((module) => (
@@ -190,11 +191,12 @@ export default function StudentDashboard({user}: Props) {
{module === "listening" && <BsHeadphones className="text-ielts-listening w-4 h-4 md:w-5 md:h-5" />}
{module === "writing" && <BsPen className="text-ielts-writing w-4 h-4 md:w-5 md:h-5" />}
{module === "speaking" && <BsMegaphone className="text-ielts-speaking w-4 h-4 md:w-5 md:h-5" />}
{module === "level" && <BsClipboard className="text-ielts-level w-4 h-4 md:w-5 md:h-5" />}
</div>
<div className="flex justify-between w-full">
<span className="font-bold md:font-extrabold text-sm">{capitalize(module)}</span>
<span className="text-sm font-normal text-mti-gray-dim">
Level {user.levels[module]} / Level {user.desiredLevels[module]}
Level {user.levels[module] || 0} / Level {user.desiredLevels[module] || 9}
</span>
</div>
</div>

View File

@@ -24,7 +24,6 @@ import {
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPersonLinesFill,
BsPlus,
BsRepeat,
BsRepeat1,

View File

@@ -10,6 +10,8 @@ import Link from "next/link";
import {useRouter} from "next/router";
import {Fragment, useEffect, useState} from "react";
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
import {LevelScore} from "@/constants/ielts";
import {getLevelScore} from "@/utils/score";
interface Score {
module: Module;
@@ -66,6 +68,22 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
return exam.exercises.length;
};
const bandScore: number = calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus);
const showLevel = (level: number) => {
if (selectedModule === "level") {
const [levelStr, grade] = getLevelScore(level);
return (
<div className="flex flex-col items-center justify-center gap-1">
<span className="text-xl font-bold">{levelStr}</span>
<span className="text-xl">{grade}</span>
</div>
);
}
return <span className="text-3xl font-bold">{level}</span>;
};
return (
<>
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8">
@@ -136,14 +154,16 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
{isLoading && (
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-12 items-center">
<span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} />
<span className={clsx("font-bold text-2xl", moduleColors[selectedModule].progress)}>Evaluating your answers...</span>
<span className={clsx("font-bold text-2xl text-center", moduleColors[selectedModule].progress)}>
Evaluating your answers, please be patient...
<br />
You can also check it later on your records page!
</span>
</div>
)}
{!isLoading && (
<div className="w-full flex gap-9 mt-32 items-center justify-between mb-20">
<span className="max-w-3xl">
{moduleResultText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))}
</span>
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
<div className="flex gap-9 px-16">
<div
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
@@ -156,9 +176,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
moduleColors[selectedModule].inner,
)}>
<span className="text-xl">Level</span>
<span className="text-3xl font-bold">
{calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus)}
</span>
{showLevel(bandScore)}
</div>
</div>
<div className="flex flex-col gap-5">

View File

@@ -1,5 +1,6 @@
import {initializeApp} from "firebase/app";
import * as admin from "firebase-admin/app";
import { getStorage } from "firebase/storage";
const serviceAccount = require("@/constants/serviceAccountKey.json");
@@ -19,3 +20,4 @@ export const adminApp = admin.initializeApp(
},
Math.random().toString(),
);
export const storage = getStorage(app);

View File

@@ -0,0 +1,48 @@
import {useState, useMemo} from 'react';
import Input from "@/components/Low/Input";
/*fields example = [
['id'],
['companyInformation', 'companyInformation', 'name']
]*/
const getFieldValue = (fields: string[], data: any): string => {
if(fields.length === 0) return data;
const [key, ...otherFields] = fields;
if(data[key]) return getFieldValue(otherFields, data[key]);
return data;
}
export const useListSearch = (fields: string[][], rows: any[]) => {
const [text, setText] = useState('');
const renderSearch = () => (
<Input
label="Search"
type="text"
name="search"
onChange={setText}
placeholder="Enter search text"
value={text}
/>
)
const updatedRows = useMemo(() => {
const searchText = text.toLowerCase();
return rows.filter((row) => {
return fields.some((fieldsKeys) => {
const value = getFieldValue(fieldsKeys, row);
if(typeof value === 'string') {
return value.toLowerCase().includes(searchText);
}
})
})
}, [fields, rows, text])
return {
rows: updatedRows,
renderSearch,
}
}

View File

@@ -10,7 +10,7 @@ export default function useStats(id?: string) {
useEffect(() => {
setIsLoading(true);
axios
.get<Stat[]>(!id ? "/api/stats" : `/api/stats/${id}`)
.get<Stat[]>(!id ? "/api/stats" : `/api/stats/user/${id}`)
.then((response) => setStats(response.data))
.finally(() => setIsLoading(false));
}, [id]);

View File

@@ -44,6 +44,7 @@ export interface ListeningPart {
}
export interface UserSolution {
id?: string;
solutions: any[];
module?: Module;
exam?: string;
@@ -92,6 +93,18 @@ export interface Evaluation {
overall: number;
task_response: {[key: string]: number};
}
interface InteractiveSpeakingEvaluation extends Evaluation {
perfect_answer_1?: string;
perfect_answer_2?: string;
perfect_answer_3?: string;
}
interface CommonEvaluation extends Evaluation {
perfect_answer?: string;
perfect_answer_1?: string;
}
export interface WritingExercise {
id: string;
type: "writing";
@@ -106,7 +119,7 @@ export interface WritingExercise {
userSolutions: {
id: string;
solution: string;
evaluation?: Evaluation;
evaluation?: CommonEvaluation;
}[];
}
@@ -120,7 +133,7 @@ export interface SpeakingExercise {
userSolutions: {
id: string;
solution: string;
evaluation?: Evaluation;
evaluation?: CommonEvaluation;
}[];
}
@@ -133,7 +146,7 @@ export interface InteractiveSpeakingExercise {
userSolutions: {
id: string;
solution: {question: string; answer: string}[];
evaluation?: Evaluation;
evaluation?: InteractiveSpeakingEvaluation;
}[];
}

View File

@@ -31,5 +31,7 @@ export interface Payment {
currency: string;
value: number;
isPaid: boolean;
date: Date;
date: Date | string;
corporateTransfer?: string;
commissionTransfer?: string;
}

View File

@@ -19,7 +19,7 @@ export interface Assignment {
type: "academic" | "general";
stats: Stat[];
}[];
exams: {id: string; module: Module}[];
exams: {id: string; module: Module, assignee: string}[];
startDate: Date;
endDate: Date;
}

View File

@@ -0,0 +1 @@
export type FilesStorage = "commission" | "corporate";

View File

@@ -57,6 +57,7 @@ export interface CorporateInformation {
payment?: {
value: number;
currency: string;
commission: number;
};
referralAgent?: string;
}
@@ -97,6 +98,7 @@ export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
];
export interface Stat {
id: string;
user: string;
exam: string;
exercise: string;

View File

@@ -16,6 +16,7 @@ import {toast} from "react-toastify";
import Select from "react-select";
import {uuidv4} from "@firebase/util";
import {useFilePicker} from "use-file-picker";
import Modal from "@/components/Modal";
const columnHelper = createColumnHelper<Group>();
@@ -23,10 +24,10 @@ interface CreateDialogProps {
user: User;
users: User[];
group?: Group;
onCreate: (group: Group) => void;
onClose: () => void;
}
const CreatePanel = ({user, users, group, onCreate}: CreateDialogProps) => {
const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
const [name, setName] = useState<string | undefined>(group?.name || undefined);
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
@@ -66,6 +67,24 @@ const CreatePanel = ({user, users, group, onCreate}: CreateDialogProps) => {
}
}, [filesContent, user.type, users]);
const submit = () => {
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
toast.error("That group name is reserved and cannot be used, please enter another one.");
return;
}
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", {name, admin, participants})
.then(() => {
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
return true;
})
.catch(() => {
toast.error("Something went wrong, please try again later!");
return false;
})
.finally(onClose);
};
return (
<div className="flex flex-col gap-12 mt-4 w-full px-4 py-2">
<div className="flex flex-col gap-8">
@@ -106,18 +125,14 @@ const CreatePanel = ({user, users, group, onCreate}: CreateDialogProps) => {
</div>
</div>
</div>
<Button
className="w-full max-w-[200px] self-end"
disabled={!name}
onClick={() => {
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
toast.error("That group name is reserved and cannot be used, please enter another one.");
return;
}
onCreate({name: name!, admin, participants, id: group?.id || uuidv4()});
}}>
{!group ? "Create" : "Update"}
</Button>
<div className="flex w-full justify-end items-center gap-8 mt-8">
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
Cancel
</Button>
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!name}>
Submit
</Button>
</div>
</div>
);
};
@@ -125,56 +140,19 @@ const CreatePanel = ({user, users, group, onCreate}: CreateDialogProps) => {
const filterTypes = ["corporate", "teacher"];
export default function GroupList({user}: {user: User}) {
const [editingID, setEditingID] = useState<string>();
const [showDisclosure, setShowDisclosure] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group>();
const [filterByUser, setFilterByUser] = useState(false);
const {users} = useUsers();
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined);
useEffect(() => {
if (editingID) setShowDisclosure(true);
}, [editingID]);
useEffect(() => {
if (showDisclosure) document.getElementById("disclosure")?.scrollTo();
if (!showDisclosure) setEditingID(undefined);
}, [showDisclosure]);
useEffect(() => {
if (user && (user.type === "corporate" || user.type === "teacher")) {
setFilterByUser(true);
}
}, [user]);
const createGroup = (group: Group) => {
return axios
.post<{ok: boolean}>("/api/groups", group)
.then(() => {
toast.success(`Group "${group.name}" created successfully`);
return true;
})
.catch(() => {
toast.error("Something went wrong, please try again later!");
return false;
})
.finally(reload);
};
const updateGroup = (group: Group) => {
return axios
.patch<{ok: boolean}>(`/api/groups/${group.id}`, group)
.then(() => {
toast.success(`Group "${group.name}" created successfully`);
return true;
})
.catch(() => {
toast.error("Something went wrong, please try again later!");
return false;
})
.finally(reload);
};
const deleteGroup = (group: Group) => {
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
@@ -216,10 +194,10 @@ export default function GroupList({user}: {user: User}) {
cell: ({row}: {row: {original: Group}}) => {
return (
<>
{(user?.type === "developer" || user?.type === "admin" || user.id === row.original.admin) && (
{user && (user.type === "developer" || user.type === "admin" || user.id === row.original.admin) && (
<div className="flex gap-2">
{editingID !== row.original.id && (
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingID(row.original.id)}>
{!row.original.disableEditing && (
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingGroup(row.original)}>
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
@@ -242,8 +220,32 @@ export default function GroupList({user}: {user: User}) {
getCoreRowModel: getCoreRowModel(),
});
const closeModal = () => {
setIsCreating(false);
setEditingGroup(undefined);
reload();
};
return (
<>
<div className="w-full h-full rounded-xl">
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
<CreatePanel
group={editingGroup}
user={user}
onClose={closeModal}
users={
user?.type === "corporate" || user?.type === "teacher"
? users.filter(
(u) =>
groups
.filter((g) => g.admin === user.id)
.flatMap((g) => g.participants)
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id),
)
: users
}
/>
</Modal>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
@@ -268,54 +270,12 @@ export default function GroupList({user}: {user: User}) {
))}
</tbody>
</table>
<>
<div
className={clsx(
"w-full px-4 py-2 bg-mti-purple-ultralight/40 flex gap-2 items-center justify-center rounded-lg",
"transition duration-300 ease-in-out",
"hover:bg-mti-purple-ultralight cursor-pointer",
)}
onClick={() => setShowDisclosure((prev) => !prev)}>
{!showDisclosure ? <BsPlus className="w-6 h-6" /> : <BsDash className="w-6 h-6" />}
<span>{!showDisclosure ? "Create group" : "Cancel"}</span>
</div>
<Transition
show={showDisclosure}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0">
<div id="#disclosure">
<CreatePanel
group={editingID ? groups.find((x) => x.id === editingID) : undefined}
user={user}
users={
user?.type === "corporate" || user?.type === "teacher"
? users.filter(
(u) =>
groups
.filter((g) => g.admin === user.id)
.flatMap((g) => g.participants)
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id),
)
: users
}
onCreate={(group) => {
(!editingID ? createGroup : updateGroup)(group).then((result) => {
if (result) {
setShowDisclosure(false);
setEditingID(undefined);
reload();
}
});
}}
/>
</div>
</Transition>
</>
</>
<button
onClick={() => setIsCreating(true)}
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
New Group
</button>
</div>
);
}

View File

@@ -0,0 +1,261 @@
import Input from "@/components/Low/Input";
import Modal from "@/components/Modal";
import {PERMISSIONS} from "@/constants/userPermissions";
import useExams from "@/hooks/useExams";
import usePackages from "@/hooks/usePackages";
import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces";
import {Exam} from "@/interfaces/exam";
import {Package} from "@/interfaces/paypal";
import {Type, User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import {useRouter} from "next/router";
import {useState} from "react";
import {BsCheck, BsPencil, BsTrash, BsUpload} from "react-icons/bs";
import {toast} from "react-toastify";
import Select from "react-select";
import {CURRENCIES} from "@/resources/paypal";
import Button from "@/components/Low/Button";
const CLASSES: {[key in Module]: string} = {
reading: "text-ielts-reading",
listening: "text-ielts-listening",
speaking: "text-ielts-speaking",
writing: "text-ielts-writing",
level: "text-ielts-level",
};
const columnHelper = createColumnHelper<Package>();
type DurationUnit = "days" | "weeks" | "months" | "years";
function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void}) {
const [duration, setDuration] = useState(pack?.duration || 1);
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months");
const [price, setPrice] = useState(pack?.price || 0);
const [currency, setCurrency] = useState<string>(pack?.currency || "EUR");
const submit = () => {
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", {
duration,
duration_unit: unit,
price,
currency,
})
.then(() => {
toast.success("New payment has been created successfully!");
onClose();
})
.catch(() => {
toast.error("Something went wrong, please try again later!");
});
};
return (
<div className="flex flex-col gap-8 py-8">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Price *</label>
<div className="flex gap-4 items-center">
<Input defaultValue={price} name="price" type="number" onChange={(e) => setPrice(parseInt(e))} />
<Select
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={CURRENCIES.map(({label, currency}) => ({value: currency, label}))}
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
onChange={(value) => setCurrency(value?.value || "EUR")}
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
</div>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Duration *</label>
<div className="flex gap-4 items-center">
<Input defaultValue={duration} name="duration" type="number" onChange={(e) => setDuration(parseInt(e))} />
<Select
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={[
{value: "days", label: "Days"},
{value: "weeks", label: "Weeks"},
{value: "months", label: "Months"},
{value: "years", label: "Years"},
]}
defaultValue={{value: "months", label: "Months"}}
onChange={(value) => setUnit((value?.value as DurationUnit) || "months")}
value={{value: unit, label: capitalize(unit)}}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
</div>
<div className="flex w-full justify-end items-center gap-8 mt-8">
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
Cancel
</Button>
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!duration || !price}>
Submit
</Button>
</div>
</div>
);
}
export default function PackageList({user}: {user: User}) {
const [isCreating, setIsCreating] = useState(false);
const [editingPackage, setEditingPackage] = useState<Package>();
const {packages, reload} = usePackages();
const deletePackage = async (pack: Package) => {
if (!confirm(`Are you sure you want to delete this package?`)) return;
axios
.delete(`/api/packages/${pack.id}`)
.then(() => toast.success(`Deleted the "${pack.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Package not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this exam!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
const defaultColumns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("duration", {
header: "Duration",
cell: (info) => (
<span>
{info.getValue()} {info.row.original.duration_unit}
</span>
),
}),
columnHelper.accessor("price", {
header: "Price",
cell: (info) => (
<span>
{info.getValue()} {info.row.original.currency}
</span>
),
}),
{
header: "",
id: "actions",
cell: ({row}: {row: {original: Package}}) => {
return (
<div className="flex gap-4">
{["developer", "admin"].includes(user.type) && (
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingPackage(row.original)}>
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
{["developer", "admin"].includes(user.type) && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deletePackage(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
];
const table = useReactTable({
data: packages,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const closeModal = () => {
setIsCreating(false);
setEditingPackage(undefined);
reload();
};
return (
<div className="w-full h-full rounded-xl">
<Modal
isOpen={isCreating || !!editingPackage}
onClose={closeModal}
title={editingPackage ? `Editing ${editingPackage.id}` : "New Package"}>
<PackageCreator onClose={closeModal} pack={editingPackage} />
</Modal>
<table className="bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<button
onClick={() => setIsCreating(true)}
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
New Package
</button>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import Button from "@/components/Low/Button";
import {PERMISSIONS} from "@/constants/userPermissions";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
import {Type, User, userTypes} from "@/interfaces/user";
import {Type, User, userTypes, CorporateUser} from "@/interfaces/user";
import {Popover, Transition} from "@headlessui/react";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import axios from "axios";
@@ -19,9 +19,15 @@ import UserCard from "@/components/UserCard";
import {USER_TYPE_LABELS} from "@/resources/user";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
import {isCorporateUser} from '@/resources/user';
import { useListSearch } from "@/hooks/useListSearch";
const columnHelper = createColumnHelper<User>();
const searchFields = [
['name'],
['email'],
['corporateInformation', 'companyInformation', 'name'],
];
export default function UserList({user, filters = []}: {user: User; filters?: ((user: User) => boolean)[]}) {
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
const [sorter, setSorter] = useState<string>();
@@ -325,6 +331,15 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
) as any,
cell: (info) => USER_TYPE_LABELS[info.getValue()],
}),
columnHelper.accessor('corporateInformation.companyInformation.name', {
header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "companyName"))}>
<span>Company Name</span>
<SorterArrow name="companyName" />
</button>
) as any,
cell: (info) => getCorporateName(info.row.original),
}),
columnHelper.accessor("subscriptionExpirationDate", {
header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "expiryDate"))}>
@@ -378,6 +393,14 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
return undefined;
};
const getCorporateName = (user: User) => {
if(isCorporateUser(user)) {
return user.corporateInformation?.companyInformation?.name
}
return '';
}
const sortFunction = (a: User, b: User) => {
if (sorter === "name" || sorter === reverseString("name"))
return sorter === "name" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
@@ -445,11 +468,28 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
: b.demographicInformation!.gender.localeCompare(a.demographicInformation!.gender);
}
if(sorter === 'companyName' || sorter === reverseString('companyName')) {
const aCorporateName = getCorporateName(a);
const bCorporateName = getCorporateName(b);
if (!aCorporateName && bCorporateName) return sorter === "companyName" ? -1 : 1;
if (aCorporateName && !bCorporateName) return sorter === "companyName" ? 1 : -1;
if (!aCorporateName && !bCorporateName) return 0;
return sorter === "companyName"
? aCorporateName.localeCompare(bCorporateName)
: bCorporateName.localeCompare(aCorporateName);
}
return a.id.localeCompare(b.id);
};
const { rows: filteredRows, renderSearch } = useListSearch(
searchFields,
displayUsers,
)
const table = useReactTable({
data: displayUsers,
data: filteredRows,
columns: (!showDemographicInformation ? defaultColumns : demographicColumns) as any,
getCoreRowModel: getCoreRowModel(),
});
@@ -532,30 +572,33 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
)}
</>
</Modal>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="w-full flex flex-col gap-2">
{renderSearch()}
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import {Tab} from "@headlessui/react";
import clsx from "clsx";
import ExamList from "./ExamList";
import GroupList from "./GroupList";
import PackageList from "./PackageList";
import UserList from "./UserList";
export default function Lists({user}: {user: User}) {
@@ -44,6 +45,19 @@ export default function Lists({user}: {user: User}) {
}>
Group List
</Tab>
{user && ["developer", "admin"].includes(user.type) && (
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}>
Package List
</Tab>
)}
</Tab.List>
<Tab.Panels className="mt-2">
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
@@ -57,6 +71,11 @@ export default function Lists({user}: {user: User}) {
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<GroupList user={user} />
</Tab.Panel>
{user && ["developer", "admin"].includes(user.type) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<PackageList user={user} />
</Tab.Panel>
)}
</Tab.Panels>
</Tab.Group>
);

View File

@@ -37,6 +37,7 @@ export default function ExamPage({page}: Props) {
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [avoidRepeated, setAvoidRepeated] = useState(false);
const [timeSpent, setTimeSpent] = useState(0);
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]);
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
@@ -94,6 +95,7 @@ export default function ExamPage({page}: Props) {
if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded && !showSolutions) {
const newStats: Stat[] = userSolutions.map((solution) => ({
...solution,
id: solution.id || uuidv4(),
timeSpent,
session: sessionId,
exam: solution.exam!,
@@ -111,6 +113,41 @@ export default function ExamPage({page}: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, hasBeenUploaded]);
useEffect(() => {
if (statsAwaitingEvaluation.length === 0) return setIsEvaluationLoading(false);
return setIsEvaluationLoading(true);
}, [statsAwaitingEvaluation]);
useEffect(() => {
if (statsAwaitingEvaluation.length > 0) {
statsAwaitingEvaluation.forEach(checkIfStatHasBeenEvaluated);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statsAwaitingEvaluation]);
const checkIfStatHasBeenEvaluated = (id: string) => {
setTimeout(async () => {
const statRequest = await axios.get<Stat>(`/api/stats/${id}`);
const stat = statRequest.data;
if (stat.solutions.every((x) => x.evaluation !== null)) {
const userSolution: UserSolution = {
id,
exercise: stat.exercise,
score: stat.score,
solutions: stat.solutions,
type: stat.type,
exam: stat.exam,
module: stat.module,
};
setUserSolutions(userSolutions.map((x) => (x.exercise === userSolution.exercise ? userSolution : x)));
return setStatsAwaitingEvaluation((prev) => prev.filter((x) => x !== id));
}
return checkIfStatHasBeenEvaluated(id);
}, 5 * 1000);
};
const updateExamWithUserSolutions = (exam: Exam): Exam => {
if (exam.module === "reading" || exam.module === "listening") {
const parts = exam.parts.map((p) =>
@@ -137,20 +174,19 @@ export default function ExamPage({page}: Props) {
Promise.all(
exam.exercises.map(async (exercise) => {
if (exercise.type === "writing") {
return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!);
}
const evaluationID = uuidv4();
if (exercise.type === "writing")
return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") {
return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!);
}
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking")
return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID);
}),
)
.then((responses) => {
setStatsAwaitingEvaluation((prev) => [...prev, ...responses.filter((x) => !!x).map((r) => (r as any).id)]);
setUserSolutions([...userSolutions, ...responses.filter((x) => !!x)] as any);
})
.finally(() => {
setIsEvaluationLoading(false);
setHasBeenUploaded(false);
});
}

View File

@@ -4,21 +4,22 @@ import Input from "@/components/Low/Input";
import {User} from "@/interfaces/user";
import {sendEmailVerification} from "@/utils/email";
import axios from "axios";
import {useState} from "react";
import {useEffect, useState} from "react";
import {toast} from "react-toastify";
import {KeyedMutator} from "swr";
interface Props {
queryCode?: string;
defaultEmail?: string;
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
mutateUser: KeyedMutator<User>;
sendEmailVerification: typeof sendEmailVerification;
}
export default function RegisterIndividual({queryCode, isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
export default function RegisterIndividual({queryCode, defaultEmail, isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [email, setEmail] = useState(defaultEmail || "");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [code, setCode] = useState(queryCode || "");
@@ -73,7 +74,15 @@ export default function RegisterIndividual({queryCode, isLoading, setIsLoading,
return (
<form className="flex flex-col items-center gap-6 w-full" onSubmit={register}>
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" defaultValue={name} required />
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" defaultValue={email} required />
<Input
type="email"
name="email"
onChange={(e) => setEmail(e)}
placeholder="Enter email address"
value={email}
disabled={!!defaultEmail}
required
/>
<Input
type="password"
name="password"

View File

@@ -5,6 +5,10 @@ import {getFirestore, collection, getDocs, query, where, setDoc, doc} from "fire
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
import { Module } from "@/interfaces";
import { getExams } from "@/utils/exams.be";
import { Exam } from "@/interfaces/exam";
import { flatten } from "lodash";
const db = getFirestore(app);
@@ -34,8 +38,107 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json(docs);
}
async function POST(req: NextApiRequest, res: NextApiResponse) {
await setDoc(doc(db, "assignments", uuidv4()), {assigner: req.session.user?.id, ...req.body});
res.status(200).json({ok: true});
interface ExamWithUser {
module: Module;
id: string;
assignee: string;
}
function getRandomIndex(arr: any[]): number {
const randomIndex = Math.floor(Math.random() * arr.length);
return randomIndex;
}
const generateExams = async (
generateMultiple: Boolean,
selectedModules: Module[],
assignees: string[]
): Promise<ExamWithUser[]> => {
if (generateMultiple) {
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
const allExams = await assignees.map(async (assignee) => {
const selectedModulePromises = await selectedModules.map(
async (module: Module) => {
try {
const exams: Exam[] = await getExams(db, module, "true", assignee);
const exam = exams[getRandomIndex(exams)];
if (exam) {
return { module: exam.module, id: exam.id, assignee };
}
return null;
} catch (e) {
console.error(e);
return null;
}
},
[]
);
const newModules = await Promise.all(selectedModulePromises);
return newModules;
}, []);
const exams = flatten(await Promise.all(allExams)).filter(
(x) => x !== null
) as ExamWithUser[];
return exams;
}
const selectedModulePromises = await selectedModules.map(
async (module: Module) => {
const exams: Exam[] = await getExams(db, module, "false", undefined);
const exam = exams[getRandomIndex(exams)];
if (exam) {
return { module: exam.module, id: exam.id };
}
return null;
}
);
const exams = await Promise.all(selectedModulePromises);
const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[];
return flatten(
assignees.map((assignee) =>
examesFiltered.map((exam) => ({ ...exam, assignee }))
)
);
};
async function POST(req: NextApiRequest, res: NextApiResponse) {
const {
selectedModules,
assignees,
// Generarte multiple true would generate an unique exam for eacah user
// false would generate the same exam for all usersa
generateMultiple = false,
...body
} = req.body as {
selectedModules: Module[];
assignees: string[];
generateMultiple: Boolean;
};
const exams: ExamWithUser[] = await generateExams(
generateMultiple,
selectedModules,
assignees
);
if (exams.length === 0) {
res
.status(400)
.json({ ok: false, error: "No exams found for the selected modules" });
return;
}
await setDoc(doc(db, "assignments", uuidv4()), {
assigner: req.session.user?.id,
assignees,
results: [],
exams,
...body,
});
res.status(200).json({ ok: true });
}

View File

@@ -0,0 +1,23 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
const db = getFirestore(app);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return GET(req, res);
res.status(404).json({ok: false});
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query;
const snapshot = await getDoc(doc(db, "codes", id as string));
res.status(200).json({...snapshot.data(), id: snapshot.id});
}

View File

@@ -48,6 +48,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
await setDoc(codeRef, {type, code, creator: req.session.user!.id, expiryDate});
if (emails && emails.length > index) {
await setDoc(codeRef, {email: emails[index]}, {merge: true});
const transport = prepareMailer();
const mailOptions = prepareMailOptions(
{

View File

@@ -2,12 +2,16 @@
import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import axios from "axios";
import axios, {AxiosResponse} from "axios";
import formidable from "formidable-serverless";
import {getStorage, ref, uploadBytes} from "firebase/storage";
import {ref, uploadBytes} from "firebase/storage";
import fs from "fs";
import {app} from "@/firebase";
import {app, storage} from "@/firebase";
import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore";
import {Stat} from "@/interfaces/user";
import {speakingReverseMarking} from "@/utils/score";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -16,8 +20,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return;
}
const storage = getStorage(app);
const form = formidable({keepExtensions: true});
await form.parse(req, async (err: any, fields: any, files: any) => {
if (err) console.log(err);
@@ -38,20 +40,41 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}),
);
const backendRequest = await axios.post(
`${process.env.BACKEND_URL}/speaking_task_3`,
{answers: uploadingAudios},
res.status(200).json(null);
console.log("🌱 - Still processing");
const backendRequest = await evaluate({answers: uploadingAudios});
console.log("🌱 - Process complete");
const correspondingStat = (await getDoc(doc(db, "stats", fields.id))).data() as Stat;
const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data, solution: uploadingAudios}));
await setDoc(
doc(db, "stats", fields.id),
{
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
solutions,
score: {
correct: speakingReverseMarking[backendRequest.data.overall],
missing: 0,
total: 100,
},
},
{merge: true},
);
res.status(200).json({...backendRequest.data, answer: uploadingAudios});
console.log("🌱 - Updated the DB");
});
}
async function evaluate(body: {answers: object[]}): Promise<AxiosResponse> {
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_3`, body, {
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
});
if (typeof backendRequest.data === "string") return evaluate(body);
return backendRequest;
}
export const config = {
api: {
bodyParser: false,

View File

@@ -2,12 +2,16 @@
import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import axios from "axios";
import axios, {AxiosResponse} from "axios";
import formidable from "formidable-serverless";
import {getStorage, ref, uploadBytes} from "firebase/storage";
import {ref, uploadBytes} from "firebase/storage";
import fs from "fs";
import {app} from "@/firebase";
import {app, storage} from "@/firebase";
import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore";
import {Stat} from "@/interfaces/user";
import {speakingReverseMarking} from "@/utils/score";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -16,8 +20,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return;
}
const storage = getStorage(app);
const form = formidable({keepExtensions: true});
await form.parse(req, async (err: any, fields: any, files: any) => {
if (err) console.log(err);
@@ -28,21 +30,46 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const binary = fs.readFileSync((audioFile as any).path).buffer;
const snapshot = await uploadBytes(audioFileRef, binary);
const backendRequest = await axios.post(
`${process.env.BACKEND_URL}/speaking_task_3`,
{answers: [{question: fields.question, answer: snapshot.metadata.fullPath}]},
res.status(200).json(null);
console.log("🌱 - Still processing");
const backendRequest = await evaluate({answers: [{question: fields.question, answer: snapshot.metadata.fullPath}]});
fs.rmSync((audioFile as any).path);
console.log("🌱 - Process complete");
const correspondingStat = (await getDoc(doc(db, "stats", fields.id))).data() as Stat;
const solutions = correspondingStat.solutions.map((x) => ({
...x,
evaluation: backendRequest.data,
solution: snapshot.metadata.fullPath,
}));
await setDoc(
doc(db, "stats", fields.id),
{
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
solutions,
score: {
correct: speakingReverseMarking[backendRequest.data.overall],
total: 100,
missing: 0,
},
},
{merge: true},
);
fs.rmSync((audioFile as any).path);
res.status(200).json({...backendRequest.data, fullPath: snapshot.metadata.fullPath});
console.log("🌱 - Updated the DB");
});
}
async function evaluate(body: {answers: object[]}): Promise<AxiosResponse> {
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_3`, body, {
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
});
if (typeof backendRequest.data === "string") return evaluate(body);
return backendRequest;
}
export const config = {
api: {
bodyParser: false,

View File

@@ -1,15 +1,20 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {getFirestore, doc, getDoc} from "firebase/firestore";
import {getFirestore, doc, getDoc, setDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import axios from "axios";
import axios, {AxiosResponse} from "axios";
import {app} from "@/firebase";
import {Stat} from "@/interfaces/user";
import {writingReverseMarking} from "@/utils/score";
interface Body {
question: string;
answer: string;
id: string;
}
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -18,11 +23,36 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return;
}
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/writing_task2`, req.body as Body, {
res.status(200).json(null);
console.log("🌱 - Still processing");
const backendRequest = await evaluate(req.body as Body);
console.log("🌱 - Process complete");
const correspondingStat = (await getDoc(doc(db, "stats", req.body.id))).data() as Stat;
const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data}));
await setDoc(
doc(db, "stats", (req.body as Body).id),
{
solutions,
score: {
correct: writingReverseMarking[backendRequest.data.overall],
total: 100,
missing: 0,
},
},
{merge: true},
);
console.log("🌱 - Updated the DB");
}
async function evaluate(body: Body): Promise<AxiosResponse> {
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/writing_task2`, body as Body, {
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
});
res.status(backendRequest.status).json(backendRequest.data);
if (typeof backendRequest.data === "string") return evaluate(body);
return backendRequest;
}

View File

@@ -1,14 +1,11 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where, setDoc, doc} from "firebase/firestore";
import {getFirestore, setDoc, doc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {shuffle} from "lodash";
import {Exam} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user";
import {v4} from "uuid";
import { getExams } from "@/utils/exams.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
@@ -26,31 +23,12 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
return;
}
const {module, avoidRepeated} = req.query as {module: string; avoidRepeated: string};
const moduleRef = collection(db, module);
const q = query(moduleRef, where("isDiagnostic", "==", false));
const snapshot = await getDocs(q);
const exams: Exam[] = shuffle(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
module,
})),
) as Exam[];
if (avoidRepeated === "true") {
const statsQ = query(collection(db, "stats"), where("user", "==", req.session.user.id));
const statsSnapshot = await getDocs(statsQ);
const stats: Stat[] = statsSnapshot.docs.map((doc) => ({id: doc.id, ...doc.data()})) as unknown as Stat[];
const filteredExams = exams.filter((x) => !stats.map((s) => s.exam).includes(x.id));
res.status(200).json(filteredExams.length > 0 ? filteredExams : exams);
return;
}
const {
module,
avoidRepeated,
} = req.query as {module: string; avoidRepeated: string};
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id);
res.status(200).json(exams);
}

View File

@@ -5,6 +5,7 @@ import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Group} from "@/interfaces/user";
import {v4} from "uuid";
const db = getFirestore(app);
@@ -45,6 +46,6 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
async function post(req: NextApiRequest, res: NextApiResponse) {
const body = req.body as Group;
await setDoc(doc(db, "groups", body.id), {name: body.name, admin: body.admin, participants: body.participants});
await setDoc(doc(db, "groups", v4()), {name: body.name, admin: body.admin, participants: body.participants});
res.status(200).json({ok: true});
}

View File

@@ -0,0 +1,89 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, doc, getDoc, deleteDoc, setDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {PERMISSIONS} from "@/constants/userPermissions";
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 === "DELETE") return del(req, res);
if (req.method === "PATCH") return patch(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id} = req.query as {id: string};
const docRef = doc(db, "packages", id);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
res.status(200).json({
id: docSnap.id,
...docSnap.data(),
module,
});
} else {
res.status(404).json(undefined);
}
}
async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id} = req.query as {id: string};
const docRef = doc(db, "packages", id);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
if (!["developer", "admin"].includes(req.session.user.type)) {
res.status(403).json({ok: false});
return;
}
await setDoc(docRef, req.body, {merge: true});
res.status(200).json({ok: true});
} else {
res.status(404).json({ok: false});
}
}
async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id} = req.query as {id: string};
const docRef = doc(db, "packages", id);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
if (!["developer", "admin"].includes(req.session.user.type)) {
res.status(403).json({ok: false});
return;
}
await deleteDoc(docRef);
res.status(200).json({ok: true});
} else {
res.status(404).json({ok: false});
}
}

View File

@@ -34,7 +34,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
}
async function post(req: NextApiRequest, res: NextApiResponse) {
if (!["developer", "owner"].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"});
const body = req.body as Package;

View File

@@ -1,10 +1,12 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {app, storage} from "@/firebase";
import {getFirestore, collection, getDocs, getDoc, doc, deleteDoc, setDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Group} from "@/interfaces/user";
import {Payment} from "@/interfaces/paypal";
import {deleteObject, ref} from "firebase/storage";
const db = getFirestore(app);
@@ -44,11 +46,14 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query as {id: string};
const snapshot = await getDoc(doc(db, "payments", id));
const data = snapshot.data() as Payment;
const user = req.session.user;
if (user.type === "admin" || user.type === "developer") {
await deleteDoc(snapshot.ref);
if (data.commissionTransfer) await deleteObject(ref(storage, data.commissionTransfer));
if (data.corporateTransfer) await deleteObject(ref(storage, data.corporateTransfer));
await deleteDoc(snapshot.ref);
res.status(200).json({ok: true});
return;
}

View File

@@ -0,0 +1,180 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app, storage} from "@/firebase";
import {getFirestore, getDoc, doc, updateDoc, deleteField, setDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {FilesStorage} from "@/interfaces/storage.files";
import {Payment} from "@/interfaces/paypal";
import fs from "fs";
import {ref, uploadBytes, deleteObject, getDownloadURL} from "firebase/storage";
import formidable from "formidable-serverless";
const db = getFirestore(app);
const getPaymentField = (type: FilesStorage) => {
switch (type) {
case "commission":
return "commissionTransfer";
case "corporate":
return "corporateTransfer";
default:
return null;
}
};
const handleDelete = async (paymentId: string, paymentField: "commissionTransfer" | "corporateTransfer") => {
const paymentRef = doc(db, "payments", paymentId);
const paymentDoc = await getDoc(paymentRef);
const {[paymentField]: paymentFieldPath} = paymentDoc.data() as Payment;
// Create a reference to the file to delete
const documentRef = ref(storage, paymentFieldPath);
await deleteObject(documentRef);
await updateDoc(paymentRef, {
[paymentField]: deleteField(),
isPaid: false,
});
};
const handleUpload = async (req: NextApiRequest, paymentId: string, paymentField: "commissionTransfer" | "corporateTransfer") =>
new Promise((resolve, reject) => {
const form = formidable({keepExtensions: true});
form.parse(req, async (err: any, fields: any, files: any) => {
if (err) {
reject(err);
return;
}
try {
const {file} = files;
const fileName = Date.now() + "-" + file.name;
const fileRef = ref(storage, fileName);
const binary = fs.readFileSync(file.path).buffer;
const snapshot = await uploadBytes(fileRef, binary);
fs.rmSync(file.path);
const paymentRef = doc(db, "payments", paymentId);
await updateDoc(paymentRef, {
[paymentField]: snapshot.ref.fullPath,
});
resolve(snapshot.ref.fullPath);
} catch (err) {
reject(err);
}
});
});
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
if (req.method === "GET") return await get(req, res);
if (req.method === "POST") return await post(req, res);
if (req.method === "DELETE") return await del(req, res);
if (req.method === "PATCH") return await patch(req, res);
res.status(404).json(undefined);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
const {type, paymentId} = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
if (paymentField === null) {
res.status(500).json({error: "Failed to identify payment field"});
return;
}
const paymentRef = doc(db, "payments", paymentId);
const {[paymentField]: paymentFieldPath} = (await getDoc(paymentRef)).data() as Payment;
// Create a reference to the file to delete
const documentRef = ref(storage, paymentFieldPath);
const url = await getDownloadURL(documentRef);
res.status(200).json({url, name: documentRef.name});
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const {type, paymentId} = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
if (paymentField === null) {
res.status(500).json({error: "Failed to identify payment field"});
return;
}
try {
const ref = await handleUpload(req, paymentId, paymentField);
const updatedDoc = (await getDoc(doc(db, "payments", paymentId))).data() as Payment;
if (updatedDoc.commissionTransfer && updatedDoc.corporateTransfer) {
await setDoc(doc(db, "payments", paymentId), {isPaid: true}, {merge: true});
}
res.status(200).json({ref});
} catch (error) {
res.status(500).json({error});
}
}
async function del(req: NextApiRequest, res: NextApiResponse) {
const {type, paymentId} = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
if (paymentField === null) {
res.status(500).json({error: "Failed to identify payment field"});
return;
}
try {
await handleDelete(paymentId, paymentField);
res.status(200).json({ok: true});
} catch (err) {
console.error(err);
res.status(500).json({error: "Failed to delete file"});
}
}
async function patch(req: NextApiRequest, res: NextApiResponse) {
const {type, paymentId} = req.query as {
type: FilesStorage;
paymentId: string;
};
const paymentField = getPaymentField(type);
if (paymentField === null) {
res.status(500).json({error: "Failed to identify payment field"});
return;
}
try {
await handleDelete(paymentId, paymentField);
} catch (err) {
console.error(err);
res.status(500).json({error: "Failed to delete file"});
return;
}
try {
const ref = await handleUpload(req, paymentId, paymentField);
res.status(200).json({ref});
} catch (err) {
res.status(500).json({error: "Failed to upload file"});
}
}
export const config = {
api: {
bodyParser: false,
},
};

View File

@@ -65,7 +65,7 @@ async function registerIndividual(req: NextApiRequest, res: NextApiResponse) {
bio: "",
isFirstLogin: codeData ? codeData.type === "student" : true,
focus: "academic",
type: codeData ? codeData.type : "student",
type: email.endsWith("@ecrop.dev") ? "developer" : codeData ? codeData.type : "student",
subscriptionExpirationDate: codeData ? codeData.expiryDate : moment().subtract(1, "days").toISOString(),
registrationDate: new Date(),
status: code ? "active" : "paymentDue",

View File

@@ -3,7 +3,7 @@ import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {getDownloadURL, getStorage, ref} from "firebase/storage";
import {app} from "@/firebase";
import {app, storage} from "@/firebase";
import axios from "axios";
export default withIronSessionApiRoute(handler, sessionOptions);
@@ -14,7 +14,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return;
}
const storage = getStorage(app);
const {path} = req.body as {path: string};
const pathReference = ref(storage, path);

View 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});
}

View File

@@ -42,7 +42,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}
const stats = req.body as Stat[];
await stats.forEach(async (stat) => await addDoc(collection(db, "stats"), stat));
await stats.forEach(async (stat) => await setDoc(doc(db, "stats", stat.id), stat));
const groupedStatsByAssignment = groupBy(
stats.filter((x) => !!x.assignment),

View File

@@ -25,8 +25,8 @@ async function update(req: NextApiRequest, res: NextApiResponse) {
const q = query(collection(db, "stats"), where("user", "==", req.session.user.id));
const stats = (await getDocs(q)).docs.map((doc) => ({
id: doc.id,
...(doc.data() as Stat),
id: doc.id,
})) as Stat[];
const groupedStats = groupBySession(stats);

View File

@@ -1,20 +1,73 @@
// 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, getDoc, doc, setDoc} from "firebase/firestore";
import {app, storage} from "@/firebase";
import {getFirestore, collection, getDocs, getDoc, doc, setDoc, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {User} from "@/interfaces/user";
import {Group, User} from "@/interfaces/user";
import {getDownloadURL, getStorage, ref, uploadBytes} from "firebase/storage";
import {getAuth, signInWithEmailAndPassword, updateEmail, updatePassword} from "firebase/auth";
import {errorMessages} from "@/constants/errors";
import moment from "moment";
import ShortUniqueId from "short-unique-id";
import {Payment} from "@/interfaces/paypal";
import {toFixedNumber} from "@/utils/number";
const db = getFirestore(app);
const storage = getStorage(app);
const auth = getAuth(app);
export default withIronSessionApiRoute(handler, sessionOptions);
// TODO: Data is set as any as data cannot be parsed to Payment
// because the id is not a par of the hash and payment expects date to be of type Date
// but if it is not inserted as a string, some UI components will not work (Invalid Date)
const addPaymentRecord = async (data: any) => {
await setDoc(doc(db, "payments", data.id), data);
};
const managePaymentRecords = async (user: User, userId: string | undefined): Promise<boolean> => {
try {
if (user.type === "corporate" && userId) {
const shortUID = new ShortUniqueId();
const data: Payment = {
id: shortUID.randomUUID(8),
corporate: userId,
agent: user.corporateInformation.referralAgent,
agentCommission: user.corporateInformation.payment!.commission,
agentValue: toFixedNumber((user.corporateInformation.payment!.commission / 100) * user.corporateInformation.payment!.value, 2),
currency: user.corporateInformation.payment!.currency,
value: user.corporateInformation.payment!.value,
isPaid: false,
date: new Date().toISOString(),
};
const corporatePayments = await getDocs(query(collection(db, "payments"), where("corporate", "==", userId)));
if (corporatePayments.docs.length === 0) {
await addPaymentRecord(data);
return true;
}
const hasPaymentPaidAndExpiring = corporatePayments.docs.filter((doc) => {
const data = doc.data();
return (
data.isPaid &&
moment().isAfter(moment(user.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(user.subscriptionExpirationDate))
);
});
if (hasPaymentPaidAndExpiring.length > 0) {
await addPaymentRecord(data);
return true;
}
}
return false;
} catch (e) {
// if this process fails it should not stop the rest of the process
console.log(e);
return false;
}
};
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
@@ -25,7 +78,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const updatedUser = req.body as User & {password?: string; newPassword?: string};
if (!!req.query.id) {
await setDoc(userRef, updatedUser, {merge: true});
const user = await setDoc(userRef, updatedUser, {merge: true});
await managePaymentRecords(updatedUser, updatedUser.id);
res.status(200).json({ok: true});
return;
}
@@ -55,6 +109,23 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const credential = await signInWithEmailAndPassword(auth, req.session.user.email, updatedUser.password);
await updateEmail(credential.user, updatedUser.email);
if (req.session.user.type === "student") {
const corporateAdmins = ((await getDocs(collection(db, "users"))).docs.map((x) => ({...x.data(), id: x.id})) as User[])
.filter((x) => x.type === "corporate")
.map((x) => x.id);
const groups = ((await getDocs(collection(db, "groups"))).docs.map((x) => ({...x.data(), id: x.id})) as Group[]).filter(
(x) => x.participants.includes(req.session.user!.id) && corporateAdmins.includes(x.admin),
);
groups.forEach(async (group) => {
await setDoc(
doc(db, "groups", group.id),
{participants: group.participants.filter((x) => x !== req.session.user!.id)},
{merge: true},
);
});
}
} catch {
res.status(400).json({error: "E002", message: errorMessages.E002});
return;
@@ -74,6 +145,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
await req.session.save();
}
await managePaymentRecords(user, req.query.id);
res.status(200).json({user});
}

View File

@@ -8,11 +8,11 @@ import Layout from "@/components/High/Layout";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import usePayments from "@/hooks/usePayments";
import {Payment} from "@/interfaces/paypal";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import {CellContext, createColumnHelper, flexRender, getCoreRowModel, HeaderGroup, useReactTable} from "@tanstack/react-table";
import {CURRENCIES} from "@/resources/paypal";
import {BsTrash} from "react-icons/bs";
import axios from "axios";
import {useEffect, useState} from "react";
import {useEffect, useState, useMemo} from "react";
import {AgentUser, CorporateUser, User} from "@/interfaces/user";
import UserCard from "@/components/UserCard";
import Modal from "@/components/Modal";
@@ -24,6 +24,9 @@ import Select from "react-select";
import Input from "@/components/Low/Input";
import ReactDatePicker from "react-datepicker";
import moment from "moment";
import PaymentAssetManager from "@/components/PaymentAssetManager";
import {toFixedNumber} from "@/utils/number";
import {CSVLink} from "react-csv";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
@@ -57,23 +60,23 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const columnHelper = createColumnHelper<Payment>();
const PaymentCreator = ({onClose, reload}: {onClose: () => void; reload: () => void}) => {
const PaymentCreator = ({onClose, reload, showComission = false}: {onClose: () => void; reload: () => void; showComission: boolean}) => {
const [corporate, setCorporate] = useState<CorporateUser>();
const [price, setPrice] = useState<number>(0);
const [currency, setCurrency] = useState<string>("EUR");
const [commission, setCommission] = useState<number>(0);
const [referralAgent, setReferralAgent] = useState<AgentUser>();
const [date, setDate] = useState<Date>(new Date());
const {users} = useUsers();
useEffect(() => {
if (!corporate) return setReferralAgent(undefined);
if (!corporate.corporateInformation?.referralAgent) return setReferralAgent(undefined);
const price = corporate?.corporateInformation?.payment?.value || 0;
const commission = corporate?.corporateInformation?.payment?.commission || 0;
const currency = corporate?.corporateInformation?.payment?.currency || "EUR";
const referralAgent = users.find((u) => u.id === corporate.corporateInformation.referralAgent);
setReferralAgent(referralAgent as AgentUser | undefined);
}, [corporate, users]);
const referralAgent = useMemo(() => {
if (corporate?.corporateInformation?.referralAgent) {
return users.find((u) => u.id === corporate.corporateInformation.referralAgent);
}
return undefined;
}, [corporate?.corporateInformation?.referralAgent, users]);
const submit = () => {
axios
@@ -81,7 +84,7 @@ const PaymentCreator = ({onClose, reload}: {onClose: () => void; reload: () => v
corporate: corporate?.id,
agent: referralAgent?.id,
agentCommission: commission,
agentValue: (commission / 100) * price,
agentValue: toFixedNumber((commission! / 100) * price!, 2),
currency,
value: price,
isPaid: false,
@@ -134,18 +137,13 @@ const PaymentCreator = ({onClose, reload}: {onClose: () => void; reload: () => v
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Price *</label>
<div className="w-full grid grid-cols-5 gap-2">
<Input
name="paymentValue"
onChange={(e) => setPrice(e ? parseInt(e) : 0)}
type="number"
defaultValue={0}
className="col-span-3"
/>
<Input name="paymentValue" onChange={() => {}} type="number" value={price} defaultValue={0} className="col-span-3" disabled />
<Select
className="px-4 col-span-2 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={CURRENCIES.map(({label, currency}) => ({value: currency, label}))}
defaultValue={{value: "EUR", label: "Euro"}}
onChange={(value) => setCurrency(value?.value || "EUR")}
defaultValue={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
onChange={() => {}}
value={{value: currency || "EUR", label: CURRENCIES.find((c) => c.currency === currency)?.label || "Euro"}}
styles={{
control: (styles) => ({
...styles,
@@ -162,27 +160,29 @@ const PaymentCreator = ({onClose, reload}: {onClose: () => void; reload: () => v
color: state.isFocused ? "black" : styles.color,
}),
}}
isDisabled
/>
</div>
</div>
<div className="flex gap-4 w-full">
<div className="flex flex-col w-full gap-3">
<label className="font-normal text-base text-mti-gray-dim">Commission *</label>
<Input name="commission" onChange={(e) => setCommission(e ? parseInt(e) : 0)} type="number" defaultValue={0} />
{showComission && (
<div className="flex gap-4 w-full">
<div className="flex flex-col w-full gap-3">
<label className="font-normal text-base text-mti-gray-dim">Commission *</label>
<Input name="commission" onChange={() => {}} type="number" defaultValue={0} value={commission} disabled />
</div>
<div className="flex flex-col w-full gap-3">
<label className="font-normal text-base text-mti-gray-dim">Commission Value*</label>
<Input
name="commissionValue"
value={`${(commission! / 100) * price!} ${CURRENCIES.find((c) => c.currency === currency)?.label}`}
onChange={() => null}
type="text"
defaultValue={0}
disabled
/>
</div>
</div>
<div className="flex flex-col w-full gap-3">
<label className="font-normal text-base text-mti-gray-dim">Commission Value*</label>
<Input
name="commissionValue"
value={`${(commission / 100) * price} ${CURRENCIES.find((c) => c.currency === currency)?.label}`}
onChange={() => null}
type="text"
defaultValue={0}
disabled
/>
</div>
</div>
)}
<div className="flex gap-4">
<div className="flex flex-col w-full gap-3">
@@ -224,13 +224,127 @@ const PaymentCreator = ({onClose, reload}: {onClose: () => void; reload: () => v
);
};
const IS_PAID_OPTIONS = [
{
value: null,
label: "All",
},
{
value: false,
label: "Unpaid",
},
{
value: true,
label: "Paid",
},
];
const IS_FILE_SUBMITTED_OPTIONS = [
{
value: null,
label: "All",
},
{
value: false,
label: "Submitted",
},
{
value: true,
label: "Not Submitted",
},
];
const CSV_WHITELISTED_KEYS = ["corporateId", "corporate", "date", "amount", "agent", "agentCommission", "agentValue", "isPaid"];
interface SimpleCSVColumn {
key: string;
label: string;
index: number;
}
export default function PaymentRecord() {
const [selectedUser, setSelectedUser] = useState<User>();
const [selectedCorporateUser, setSelectedCorporateUser] = useState<User>();
const [selectedAgentUser, setSelectedAgentUser] = useState<User>();
const [isCreatingPayment, setIsCreatingPayment] = useState(false);
const [filters, setFilters] = useState<{filter: (p: Payment) => boolean; id: string}[]>([]);
const [displayPayments, setDisplayPayments] = useState<Payment[]>([]);
const [corporate, setCorporate] = useState<User>();
const [agent, setAgent] = useState<User>();
const {user} = useUser({redirectTo: "/login"});
const {users} = useUsers();
const {payments, reload} = usePayments();
const {users, reload: reloadUsers} = useUsers();
const {payments: originalPayments, reload: reloadPayment} = usePayments();
const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate());
const [endDate, setEndDate] = useState<Date | null>(moment().endOf("day").toDate());
const [paid, setPaid] = useState<Boolean | null>(IS_PAID_OPTIONS[0].value);
const [commissionTransfer, setCommissionTransfer] = useState<Boolean | null>(IS_FILE_SUBMITTED_OPTIONS[0].value);
const [corporateTransfer, setCorporateTransfer] = useState<Boolean | null>(IS_FILE_SUBMITTED_OPTIONS[0].value);
const reload = () => {
reloadUsers();
reloadPayment();
};
const payments = useMemo(() => {
return originalPayments.filter((p: Payment) => {
const date = moment(p.date);
return date.isAfter(startDate) && date.isBefore(endDate);
});
}, [originalPayments, startDate, endDate]);
useEffect(() => {
setDisplayPayments(
filters
.map((f) => f.filter)
.reduce((d, f) => d.filter(f), payments)
.sort((a, b) => moment(b.date).diff(moment(a.date))),
);
}, [payments, filters]);
useEffect(() => {
if (user && user.type === "agent") {
setAgent(user);
}
}, [user]);
useEffect(() => {
setFilters((prev) => [
...prev.filter((x) => x.id !== "agent-filter"),
...(!agent ? [] : [{id: "agent-filter", filter: (p: Payment) => p.agent === agent.id}]),
]);
}, [agent]);
useEffect(() => {
setFilters((prev) => [
...prev.filter((x) => x.id !== "corporate-filter"),
...(!corporate ? [] : [{id: "corporate-filter", filter: (p: Payment) => p.corporate === corporate.id}]),
]);
}, [corporate]);
useEffect(() => {
setFilters((prev) => [
...prev.filter((x) => x.id !== "paid"),
...(typeof paid !== "boolean" ? [] : [{id: "paid", filter: (p: Payment) => p.isPaid === paid}]),
]);
}, [paid]);
useEffect(() => {
setFilters((prev) => [
...prev.filter((x) => x.id !== "commissionTransfer"),
...(typeof commissionTransfer !== "boolean"
? []
: [{id: "commissionTransfer", filter: (p: Payment) => !p.commissionTransfer === commissionTransfer}]),
]);
}, [commissionTransfer]);
useEffect(() => {
setFilters((prev) => [
...prev.filter((x) => x.id !== "corporateTransfer"),
...(typeof corporateTransfer !== "boolean"
? []
: [{id: "corporateTransfer", filter: (p: Payment) => !p.corporateTransfer === corporateTransfer}]),
]);
}, [corporateTransfer]);
const updatePayment = (payment: Payment, key: string, value: any) => {
axios
@@ -261,65 +375,279 @@ export default function PaymentRecord() {
.finally(reload);
};
const getFileAssetsColumns = () => {
if (user) {
const containerClassName = "flex gap-2 text-mti-purple-light hover:text-mti-purple-dark ease-in-out duration-300 cursor-pointer";
switch (user.type) {
case "corporate":
return [
columnHelper.accessor("corporateTransfer", {
header: "Corporate transfer",
id: "corporateTransfer",
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
reload={reload}
permissions={info.row.original.isPaid ? "read" : "write"}
asset={info.row.original.corporateTransfer}
canEdit={!info.row.original.isPaid}
paymentId={info.row.original.id}
type="corporate"
/>
</div>
),
}),
];
case "agent":
return [
columnHelper.accessor("commissionTransfer", {
header: "Commission transfer",
id: "commissionTransfer",
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
reload={reload}
permissions="read"
asset={info.row.original.commissionTransfer}
paymentId={info.row.original.id}
canEdit={!info.row.original.isPaid}
type="commission"
/>
</div>
),
}),
];
case "admin":
return [
columnHelper.accessor("corporateTransfer", {
header: "Corporate transfer",
id: "corporateTransfer",
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
reload={reload}
permissions="read"
asset={info.row.original.corporateTransfer}
paymentId={info.row.original.id}
canEdit={!info.row.original.isPaid}
type="corporate"
/>
</div>
),
}),
columnHelper.accessor("commissionTransfer", {
header: "Commission transfer",
id: "commissionTransfer",
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
reload={reload}
permissions={info.row.original.isPaid ? "read" : "write"}
asset={info.row.original.commissionTransfer}
paymentId={info.row.original.id}
canEdit={!info.row.original.isPaid}
type="commission"
/>
</div>
),
}),
];
case "developer":
return [
columnHelper.accessor("corporateTransfer", {
header: "Corporate transfer",
id: "corporateTransfer",
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
reload={reload}
permissions="write"
asset={info.row.original.corporateTransfer}
paymentId={info.row.original.id}
canEdit={!info.row.original.isPaid}
type="corporate"
/>
</div>
),
}),
columnHelper.accessor("commissionTransfer", {
header: "Commission transfer",
id: "commissionTransfer",
cell: (info) => (
<div className={containerClassName}>
<PaymentAssetManager
reload={reload}
permissions="write"
asset={info.row.original.commissionTransfer}
paymentId={info.row.original.id}
canEdit={!info.row.original.isPaid}
type="commission"
/>
</div>
),
}),
];
default:
return [];
}
}
return [];
};
const columHelperValue = (key: string, info: any) => {
switch (key) {
case "agentCommission": {
const value = info.getValue();
return {value: `${value}%`};
}
case "agent": {
const user = users.find((x) => x.id === info.row.original.agent) as AgentUser;
return {
value: user?.name,
user,
};
}
case "agentValue":
case "amount": {
const value = info.getValue();
const numberValue = toFixedNumber(value, 2);
return {value: numberValue};
}
case "date": {
const value = info.getValue();
return {value: moment(value).format("DD/MM/YYYY")};
}
case "corporate": {
const specificValue = info.row.original.corporate;
const user = users.find((x) => x.id === specificValue) as CorporateUser;
return {
user,
value: user?.corporateInformation.companyInformation.name || user?.name,
};
}
case "currency": {
return {
value: info.row.original.currency,
};
}
case "isPaid":
case "corporateId":
default: {
const value = info.getValue();
return {value};
}
}
};
const commissionColumn = () => {
if (user && user.type !== "corporate")
return [
columnHelper.accessor("agentCommission", {
header: "Commission",
id: "agentCommission",
cell: (info) => {
const {value} = columHelperValue(info.column.id, info);
return <>{value}</>;
},
}),
columnHelper.accessor("agentValue", {
header: "Commission Value",
id: "agentValue",
cell: (info) => {
const {value} = columHelperValue(info.column.id, info);
const currency = CURRENCIES.find((x) => x.currency === info.row.original.currency)?.label;
const finalValue = `${value} ${currency}`;
return <span>{finalValue}</span>;
},
}),
];
return [];
};
const defaultColumns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
columnHelper.accessor("corporate", {
header: "Corporate ID",
id: "corporateId",
cell: (info) => {
const {value} = columHelperValue(info.column.id, info);
return value;
},
}),
columnHelper.accessor("corporate", {
header: "Corporate",
cell: (info) => (
<div
className={clsx("underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer")}
onClick={() => setSelectedUser(users.find((x) => x.id === info.row.original.corporate))}>
{(users.find((x) => x.id === info.row.original.corporate) as CorporateUser)?.corporateInformation.companyInformation.name}
</div>
),
id: "corporate",
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={() => setSelectedCorporateUser(user)}>
{value}
</div>
);
},
}),
columnHelper.accessor("date", {
header: "Date",
cell: (info) => <span>{moment(info.getValue()).format("DD/MM/YYYY")}</span>,
id: "date",
cell: (info) => {
const {value} = columHelperValue(info.column.id, info);
return <span>{value}</span>;
},
}),
columnHelper.accessor("value", {
header: "Amount",
cell: (info) => (
<span>
{info.getValue()} {CURRENCIES.find((x) => x.currency === info.row.original.currency)?.label}
</span>
),
id: "amount",
cell: (info) => {
const {value} = columHelperValue(info.column.id, info);
const currency = CURRENCIES.find((x) => x.currency === info.row.original.currency)?.label;
const finalValue = `${value} ${currency}`;
return <span>{finalValue}</span>;
},
}),
columnHelper.accessor("agent", {
header: "Country Manager",
cell: (info) => (
<div
className={clsx("underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer")}
onClick={() => setSelectedUser(users.find((x) => x.id === info.row.original.agent))}>
{(users.find((x) => x.id === info.row.original.agent) as AgentUser)?.name}
</div>
),
}),
columnHelper.accessor("agentCommission", {
header: "Commission",
cell: (info) => <>{info.getValue()}%</>,
}),
columnHelper.accessor("agentValue", {
header: "Commission Value",
cell: (info) => (
<span>
{info.getValue()} {CURRENCIES.find((x) => x.currency === info.row.original.currency)?.label}
</span>
),
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", {
header: "Paid",
cell: (info) => (
<Checkbox
isChecked={info.getValue()}
onChange={(e) => (user?.type !== "agent" ? updatePayment(info.row.original, "isPaid", e) : null)}>
<span></span>
</Checkbox>
),
id: "isPaid",
cell: (info) => {
const {value} = columHelperValue(info.column.id, info);
return (
<Checkbox
isChecked={value}
onChange={(e) => {
if (user?.type === agent || value) return null;
if (!info.row.original.commissionTransfer || !info.row.original.corporateTransfer)
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;
return updatePayment(info.row.original, "isPaid", e);
}}>
<span></span>
</Checkbox>
);
},
}),
...getFileAssetsColumns(),
{
header: "",
id: "actions",
@@ -338,11 +666,100 @@ export default function PaymentRecord() {
];
const table = useReactTable({
data: (user?.type === "agent" ? payments.filter((p) => p.agent === user.id) : payments).sort((a, b) => moment(b.date).diff(moment(a.date))),
data: displayPayments,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const getUserModal = () => {
if (user) {
if (selectedCorporateUser) {
return (
<Modal isOpen={!!selectedCorporateUser} onClose={() => setSelectedCorporateUser(undefined)}>
<>
{selectedCorporateUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedCorporateUser(undefined);
if (shouldReload) reload();
}}
user={selectedCorporateUser}
disabled
/>
</div>
)}
</>
</Modal>
);
}
if (selectedAgentUser) {
return (
<Modal isOpen={!!selectedAgentUser} onClose={() => setSelectedAgentUser(undefined)}>
<>
{selectedAgentUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedAgentUser(undefined);
if (shouldReload) reload();
}}
user={selectedAgentUser}
/>
</div>
)}
</>
</Modal>
);
}
}
return null;
};
const getCSVData = () => {
const columns = table.getHeaderGroups().reduce((accm: SimpleCSVColumn[], group: HeaderGroup<Payment>) => {
const whitelistedColumns = group.headers.filter((header) => CSV_WHITELISTED_KEYS.includes(header.id));
const data = whitelistedColumns.map((data) => ({
key: data.column.columnDef.id,
label: data.column.columnDef.header,
})) as SimpleCSVColumn[];
return [...accm, ...data];
}, []);
const {rows} = table.getRowModel();
const finalColumns = [
...columns,
{
key: "currency",
label: "Currency",
},
];
return {
columns: finalColumns,
rows: rows.map((row) => {
return finalColumns.reduce((accm, {key}) => {
const {value} = columHelperValue(key, {
row,
getValue: () => row.getValue(key),
});
return {
...accm,
[key]: value,
};
}, {});
}),
};
};
const {rows: csvRows, columns: csvColumns} = getCSVData();
return (
<>
<Head>
@@ -357,35 +774,212 @@ export default function PaymentRecord() {
<ToastContainer />
{user && (
<Layout user={user} className="gap-6">
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{getUserModal()}
<Modal isOpen={isCreatingPayment} onClose={() => setIsCreatingPayment(false)}>
<PaymentCreator onClose={() => setIsCreatingPayment(false)} reload={reload} />
<PaymentCreator
onClose={() => setIsCreatingPayment(false)}
reload={reload}
showComission={user.type === "developer" || user.type === "admin"}
/>
</Modal>
<div className="w-full flex flex-end justify-between p-2">
<h1 className="text-2xl font-semibold">Payment Record</h1>
{(user.type === "developer" || user.type === "admin") && (
<Button className="w-full max-w-[200px]" variant="outline" onClick={() => setIsCreatingPayment(true)}>
New Payment
</Button>
<div className="flex justify-end gap-2">
<Button className="max-w-[200px]" variant="outline">
<CSVLink data={csvRows} headers={csvColumns} filename="payment-records.csv">
Download CSV
</CSVLink>
</Button>
<Button className="max-w-[200px]" variant="outline" onClick={() => setIsCreatingPayment(true)}>
New Payment
</Button>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Corporate account *</label>
<Select
isClearable
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={(users.filter((u) => u.type === "corporate") as CorporateUser[]).map((user) => ({
value: user.id,
meta: user,
label: `${user.corporateInformation.companyInformation.name || user.name} - ${user.email}`,
}))}
onChange={(value) => setCorporate((value as any)?.meta ?? undefined)}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country manager *</label>
<Select
isClearable
isDisabled={user.type === "agent"}
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",
user.type === "agent" ? "bg-mti-gray-platinum/40" : "bg-white",
)}
options={(users.filter((u) => u.type === "agent") as AgentUser[]).map((user) => ({
value: user.id,
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)}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Paid</label>
<Select
isClearable
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",
user.type === "agent" ? "bg-mti-gray-platinum/40" : "bg-white",
)}
options={IS_PAID_OPTIONS}
value={IS_PAID_OPTIONS.find((e) => e.value === paid)}
onChange={(value) => {
if (value) return setPaid(value.value);
setPaid(null);
}}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Date</label>
<ReactDatePicker
dateFormat="dd/MM/yyyy"
className="px-4 py-6 w-full text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
selected={startDate}
startDate={startDate}
endDate={endDate}
selectsRange
showMonthDropdown
filterDate={(date: Date) => moment(date).isSameOrBefore(moment(new Date()))}
onChange={([initialDate, finalDate]: [Date, Date]) => {
setStartDate(initialDate ?? moment("01/01/2023").toDate());
if (finalDate) {
// basicly selecting a final day works as if I'm selecting the first
// minute of that day. this way it covers the whole day
setEndDate(moment(finalDate).endOf("day").toDate());
return;
}
setEndDate(null);
}}
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Commission transfer</label>
<Select
isClearable
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)}
onChange={(value) => {
if (value) return setCommissionTransfer(value.value);
setCommissionTransfer(null);
}}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Corporate transfer</label>
<Select
isClearable
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 === corporateTransfer)}
onChange={(value) => {
if (value) return setCorporateTransfer(value.value);
setCorporateTransfer(null);
}}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
</div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (

View File

@@ -19,6 +19,8 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled";
import moment from "moment";
import {BsCamera, BsCameraFill} from "react-icons/bs";
import {USER_TYPE_LABELS} from "@/resources/user";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
@@ -50,25 +52,36 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
};
}, sessionOptions);
export default function Home() {
const [bio, setBio] = useState("");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
interface Props {
user: User;
mutateUser: Function;
}
function UserProfile({user, mutateUser}: Props) {
const [bio, setBio] = useState(user.bio || "");
const [name, setName] = useState(user.name || "");
const [email, setEmail] = useState(user.email || "");
const [password, setPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [profilePicture, setProfilePicture] = useState("");
const [profilePicture, setProfilePicture] = useState(user.profilePicture);
const [country, setCountry] = useState<string>();
const [phone, setPhone] = useState<string>();
const [gender, setGender] = useState<Gender>();
const [employment, setEmployment] = useState<EmploymentStatus>();
const [position, setPosition] = useState<string>();
const [country, setCountry] = useState<string>(user.demographicInformation?.country || "");
const [phone, setPhone] = useState<string>(user.demographicInformation?.phone || "");
const [gender, setGender] = useState<Gender | undefined>(user.demographicInformation?.gender || undefined);
const [employment, setEmployment] = useState<EmploymentStatus | undefined>(
user.type === "corporate" ? undefined : user.demographicInformation?.employment,
);
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
const [companyName, setCompanyName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyName : undefined);
const [commercialRegistration, setCommercialRegistration] = useState<string | undefined>(
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
);
const {groups} = useGroups();
const {users} = useUsers();
const profilePictureInput = useRef(null);
const {user, mutateUser} = useUser({redirectTo: "/login"});
const expirationDateColor = (date: Date) => {
const momentDate = moment(date);
const today = moment(new Date());
@@ -78,20 +91,6 @@ export default function Home() {
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
};
useEffect(() => {
if (user) {
setName(user.name);
setEmail(user.email);
setBio(user.bio);
setProfilePicture(user.profilePicture);
setCountry(user.demographicInformation?.country);
setPhone(user.demographicInformation?.phone);
setGender(user.demographicInformation?.gender);
setEmployment(user.type === "corporate" ? undefined : user.demographicInformation?.employment);
setPosition(user.type === "corporate" ? user.demographicInformation?.position : undefined);
}
}, [user]);
const convertBase64 = (file: File) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
@@ -127,6 +126,19 @@ export default function Home() {
return;
}
if (email !== user?.email) {
const userAdmins = groups.filter((x) => x.participants.includes(user.id)).map((x) => x.admin);
const message =
users.filter((x) => userAdmins.includes(x.id) && x.type === "corporate").length > 0
? "If you change your e-mail address, you will lose all benefits from your university/institute. Are you sure you want to continue?"
: "Are you sure you want to update your e-mail address?";
if (!confirm(message)) {
setIsLoading(false);
return;
}
}
const request = await axios.post("/api/users/update", {
bio,
name,
@@ -153,6 +165,257 @@ export default function Home() {
setIsLoading(false);
};
return (
<Layout user={user}>
<section className="w-full flex flex-col gap-4 md:gap-8 px-4 py-8">
<h1 className="text-4xl font-bold mb-6 md:hidden">Edit Profile</h1>
<div className="flex -md:flex-col-reverse -md:items-center w-full justify-between">
<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>
<form className="flex flex-col items-center gap-6 w-full">
<div className="flex flex-col md:flex-row gap-8 w-full">
<Input
label="Name"
type="text"
name="name"
onChange={(e) => setName(e)}
placeholder="Enter your name"
defaultValue={name}
required
/>
<Input
label="E-mail Address"
type="email"
name="email"
onChange={(e) => setEmail(e)}
placeholder="Enter email address"
defaultValue={email}
required
/>
</div>
<div className="flex flex-col md:flex-row gap-8 w-full">
<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)"
/>
</div>
{user.type === "agent" && (
<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>
)}
<div className="flex flex-col md:flex-row gap-8 w-full">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} />
</div>
<Input
type="tel"
name="phone"
label="Phone number"
onChange={(e) => setPhone(e)}
placeholder="Enter phone number"
defaultValue={phone}
required
/>
</div>
<div className="flex flex-col md:flex-row gap-8 w-full">
{user.type === "corporate" && (
<Input
name="position"
onChange={setPosition}
defaultValue={position}
type="text"
label="Position"
placeholder="CEO, Head of Marketing..."
required
/>
)}
{user.type !== "corporate" && (
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
<RadioGroup
value={employment}
onChange={setEmployment}
className="grid grid-cols-2 items-center gap-4 place-items-center">
{EMPLOYMENT_STATUS.map(({status, label}) => (
<RadioGroup.Option value={status} key={status}>
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-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>
)}
<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>
</div>
<div className="flex flex-col gap-6 w-48">
<div
className="flex flex-col gap-3 items-center h-fit cursor-pointer group"
onClick={() => (profilePictureInput.current as any)?.click()}>
<div className="relative overflow-hidden h-48 w-48 rounded-full">
<div
className={clsx(
"absolute top-0 left-0 bg-mti-purple-light/60 w-full h-full z-20 flex items-center justify-center opacity-0 group-hover:opacity-100",
"transition ease-in-out duration-300",
)}>
<BsCamera className="text-6xl text-mti-purple-ultralight/80" />
</div>
<img src={profilePicture} alt={user.name} className="aspect-square drop-shadow-xl self-end object-cover" />
</div>
<input type="file" className="hidden" onChange={uploadProfilePicture} accept="image/*" ref={profilePictureInput} />
<span
onClick={() => (profilePictureInput.current as any)?.click()}
className="cursor-pointer text-mti-purple-light text-sm">
Change picture
</span>
<h6 className="font-normal text-base text-mti-gray-taupe">{USER_TYPE_LABELS[user.type]}</h6>
</div>
{user.type === "agent" && (
<div className="flag items-center h-fit">
<img
alt={user.demographicInformation?.country.toLowerCase() + "_flag"}
src={`https://flagcdn.com/w320/${user.demographicInformation?.country.toLowerCase()}.png`}
width="320"
/>
</div>
)}
</div>
</div>
<div className="flex flex-col gap-4 mt-8 mb-20">
<span className="text-lg font-bold">Bio</span>
<textarea
className="w-full h-full min-h-[148px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
onChange={(e) => setBio(e.target.value)}
defaultValue={bio}
placeholder="Write your text here..."
/>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Link href="/" className="max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full">
Back
</Button>
</Link>
<Button color="purple" className="max-w-[200px] self-end w-full" onClick={updateUser} disabled={isLoading}>
Save Changes
</Button>
</div>
</section>
</Layout>
);
}
export default function Home() {
const {user, mutateUser} = useUser({redirectTo: "/login"});
return (
<>
<Head>
@@ -165,241 +428,7 @@ export default function Home() {
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user}>
<section className="w-full flex flex-col gap-4 md:gap-8 px-4 py-8">
<h1 className="text-4xl font-bold mb-6 md:hidden">Edit Profile</h1>
<div className="flex -md:flex-col-reverse -md:items-center w-full justify-between">
<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>
<form className="flex flex-col items-center gap-6 w-full">
<div className="flex flex-col md:flex-row gap-8 w-full">
<Input
label="Name"
type="text"
name="name"
onChange={(e) => setName(e)}
placeholder="Enter your name"
defaultValue={name}
required
/>
<Input
label="E-mail Address"
type="email"
name="email"
onChange={(e) => setEmail(e)}
placeholder="Enter email address"
defaultValue={email}
required
/>
</div>
<div className="flex flex-col md:flex-row gap-8 w-full">
<Input
label="Old 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)"
/>
</div>
{user.type === "agent" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
<Input
label="Company Name"
type="text"
name="companyName"
onChange={() => null}
placeholder="Enter company name"
defaultValue={user.agentInformation.companyName}
disabled
/>
<Input
label="Commercial Registration"
type="text"
name="commercialRegistration"
onChange={() => null}
placeholder="Enter company name"
defaultValue={user.agentInformation.commercialRegistration}
disabled
/>
</div>
)}
<div className="flex flex-col md:flex-row gap-8 w-full">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} />
</div>
<Input
type="tel"
name="phone"
label="Phone number"
onChange={(e) => setPhone(e)}
placeholder="Enter phone number"
defaultValue={phone}
required
/>
</div>
<div className="flex flex-col md:flex-row gap-8 w-full">
{user.type === "corporate" && (
<Input
name="position"
onChange={setPosition}
defaultValue={position}
type="text"
label="Position"
placeholder="CEO, Head of Marketing..."
required
/>
)}
{user.type !== "corporate" && (
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
<RadioGroup
value={employment}
onChange={setEmployment}
className="grid grid-cols-2 items-center gap-4 place-items-center">
{EMPLOYMENT_STATUS.map(({status, label}) => (
<RadioGroup.Option value={status} key={status}>
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-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>
)}
<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>
</div>
<div
className="flex flex-col gap-3 items-center w-48 h-fit cursor-pointer group"
onClick={() => (profilePictureInput.current as any)?.click()}>
<div className="relative overflow-hidden h-48 w-48 rounded-full">
<div
className={clsx(
"absolute top-0 left-0 bg-mti-purple-light/60 w-full h-full z-20 flex items-center justify-center opacity-0 group-hover:opacity-100",
"transition ease-in-out duration-300",
)}>
<BsCamera className="text-6xl text-mti-purple-ultralight/80" />
</div>
<img src={profilePicture} alt={user.name} className="aspect-square drop-shadow-xl self-end object-cover" />
</div>
<input type="file" className="hidden" onChange={uploadProfilePicture} accept="image/*" ref={profilePictureInput} />
<span
onClick={() => (profilePictureInput.current as any)?.click()}
className="cursor-pointer text-mti-purple-light text-sm">
Change picture
</span>
<h6 className="font-normal text-base text-mti-gray-taupe">{USER_TYPE_LABELS[user.type]}</h6>
</div>
</div>
<div className="flex flex-col gap-4 mt-8 mb-20">
<span className="text-lg font-bold">Bio</span>
<textarea
className="w-full h-full min-h-[148px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
onChange={(e) => setBio(e.target.value)}
defaultValue={bio}
placeholder="Write your text here..."
/>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Link href="/" className="max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full">
Back
</Button>
</Link>
<Button color="purple" className="max-w-[200px] self-end w-full" onClick={updateUser} disabled={isLoading}>
Save Changes
</Button>
</div>
</section>
</Layout>
)}
{user && <UserProfile user={user} mutateUser={mutateUser} />}
</>
);
}

View File

@@ -1,6 +1,6 @@
/* eslint-disable @next/next/no-img-element */
import {ToastContainer} from "react-toastify";
import {useState} from "react";
import {useEffect, useState} from "react";
import Head from "next/head";
import useUser from "@/hooks/useUser";
import Link from "next/link";
@@ -11,6 +11,7 @@ import RegisterCorporate from "./(register)/RegisterCorporate";
import EmailVerification from "./(auth)/EmailVerification";
import {sendEmailVerification} from "@/utils/email";
import useUsers from "@/hooks/useUsers";
import axios from "axios";
export const getServerSideProps = (context: any) => {
const {code} = context.query;
@@ -21,8 +22,17 @@ export const getServerSideProps = (context: any) => {
};
export default function Register({code: queryCode}: {code: string}) {
const [defaultEmail, setDefaultEmail] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (queryCode) {
(async () => {
axios.get<{email?: string}>(`/api/code/${queryCode}`).then((result) => setDefaultEmail(result.data.email));
})();
}
}, [queryCode]);
const {user, mutateUser} = useUser({
redirectTo: "/",
redirectIfFound: true,
@@ -79,11 +89,13 @@ export default function Register({code: queryCode}: {code: string}) {
<Tab.Panels>
<Tab.Panel>
<RegisterIndividual
key={defaultEmail || "individual"}
isLoading={isLoading}
setIsLoading={setIsLoading}
mutateUser={mutateUser}
sendEmailVerification={sendEmailVerification}
queryCode={queryCode}
defaultEmail={defaultEmail}
/>
</Tab.Panel>
<Tab.Panel>

View File

@@ -1,20 +1,20 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
import {BsArrowClockwise, BsChevronLeft, BsChevronRight, BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
import {LinearScale, Chart as ChartJS, CategoryScale, PointElement, LineElement, Legend, Tooltip, LineController} from "chart.js";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {useEffect, useState} from "react";
import useStats from "@/hooks/useStats";
import {averageScore, totalExamsByModule, groupBySession, groupByModule} from "@/utils/stats";
import {averageScore, totalExamsByModule, groupBySession, groupByModule, timestampToMoment, groupByDate} from "@/utils/stats";
import useUser from "@/hooks/useUser";
import {ToastContainer} from "react-toastify";
import {capitalize} from "lodash";
import {capitalize, Dictionary} from "lodash";
import {Module} from "@/interfaces";
import ProgressBar from "@/components/Low/ProgressBar";
import Layout from "@/components/High/Layout";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {MODULE_ARRAY, sortByModule} from "@/utils/moduleUtils";
import {Chart} from "react-chartjs-2";
import useUsers from "@/hooks/useUsers";
import Select from "react-select";
@@ -22,10 +22,14 @@ import useGroups from "@/hooks/useGroups";
import DatePicker from "react-datepicker";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import ProfileSummary from "@/components/ProfileSummary";
import moment from "moment";
import {Stat} from "@/interfaces/user";
import {Divider} from "primereact/divider";
import Badge from "@/components/Low/Badge";
ChartJS.register(LinearScale, CategoryScale, PointElement, LineElement, LineController, Legend, Tooltip);
const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8"];
const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"];
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
@@ -59,8 +63,15 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
export default function Stats() {
const [statsUserId, setStatsUserId] = useState<string>();
const [startDate, setStartDate] = useState<Date | null>(null);
const [startDate, setStartDate] = useState<Date | null>(moment(new Date()).subtract(1, "weeks").toDate());
const [endDate, setEndDate] = useState<Date | null>(new Date());
const [initialStatDate, setInitialStatDate] = useState<Date>();
const [monthlyOverallScoreDate, setMonthlyOverallScoreDate] = useState<Date | null>(new Date());
const [monthlyModuleScoreDate, setMonthlyModuleScoreDate] = useState<Date | null>(new Date());
const [dailyScoreDate, setDailyScoreDate] = useState<Date | null>(new Date());
const [intervalDates, setIntervalDates] = useState<Date[]>([]);
const {user} = useUser({redirectTo: "/login"});
const {users} = useUsers();
@@ -72,51 +83,31 @@ export default function Stats() {
if (user) setStatsUserId(user.id);
}, [user]);
// useEffect(() => {
// if (stats && stats.length > 0) {
// const sortedStats = stats.sort((a, b) => a.date - b.date);
// const firstStat = sortedStats.shift()!;
useEffect(() => {
setInitialStatDate(
stats
.filter((s) => s.date)
.sort((a, b) => timestampToMoment(a).diff(timestampToMoment(b)))
.map(timestampToMoment)
.shift()
?.toDate(),
);
}, [stats]);
// setStartDate(moment.unix(firstStat.date).toDate());
// console.log(stats.filter((x) => moment.unix(x.date).isAfter(startDate)));
// console.log(stats.filter((x) => moment.unix(x.date).isBefore(endDate)));
// }
// }, [stats]);
const calculateModuleScore = (stats: Stat[]) => {
const moduleStats = groupByModule(stats);
return Object.keys(moduleStats).map((y) => {
const correct = moduleStats[y].reduce((accumulator, current) => accumulator + current.score.correct, 0);
const total = moduleStats[y].reduce((accumulator, current) => accumulator + current.score.total, 0);
const calculateTotalScorePerSession = () => {
const groupedBySession = groupBySession(stats);
const sessionAverage = Object.keys(groupedBySession).map((x: string) => {
const session = groupedBySession[x];
const moduleStats = groupByModule(session);
const moduleScores = Object.keys(moduleStats).map((y) => {
const correct = moduleStats[y].reduce((accumulator, current) => accumulator + current.score.correct, 0);
const total = moduleStats[y].reduce((accumulator, current) => accumulator + current.score.total, 0);
return {
module: y,
score: calculateBandScore(correct, total, y as Module, user?.focus || "academic"),
};
});
return moduleScores.reduce((acc, curr) => acc + curr.score, 0) / 4;
return {
module: y as Module,
score: calculateBandScore(correct, total, y as Module, user?.focus || "academic"),
};
});
return sessionAverage;
};
const calculateAverageTimePerModule = () => {
const groupedBySession = groupBySession(stats.filter((x) => !!x.timeSpent));
const sessionAverage = Object.keys(groupedBySession).map((x: string) => {
const session = groupedBySession[x];
const timeSpent = session[0].timeSpent!;
return Math.floor(timeSpent / session.length / 60);
});
return sessionAverage;
};
const calculateModularScorePerSession = (module: Module) => {
const calculateModularScorePerSession = (stats: Stat[], module: Module) => {
const groupedBySession = groupBySession(stats);
const sessionAverage = Object.keys(groupedBySession).map((x: string) => {
const session = groupedBySession[x];
@@ -131,6 +122,33 @@ export default function Stats() {
return sessionAverage;
};
const getListOfDateInInterval = (start: Date, end: Date) => {
let currentDate = moment(start);
const dates = [currentDate.toDate()];
while (moment(end).diff(currentDate, "days") > 0) {
currentDate = currentDate.add(1, "days");
dates.push(currentDate.toDate());
}
return dates;
};
useEffect(() => {
if (startDate && endDate) {
setIntervalDates(getListOfDateInInterval(startDate, endDate));
}
}, [startDate, endDate]);
const calculateTotalScore = (stats: Stat[]) => {
const moduleScores = calculateModuleScore(stats);
return moduleScores.reduce((acc, curr) => acc + curr.score, 0) / 4;
};
const calculateScorePerModule = (stats: Stat[], module: Module) => {
const moduleScores = calculateModuleScore(stats);
return moduleScores.find((x) => x.module === module)?.score || -1;
};
return (
<>
<Head>
@@ -167,7 +185,7 @@ export default function Stats() {
/>
<section className="flex flex-col gap-3">
<div className="w-full flex justify-between gap-8 items-center">
<div className="w-full flex justify-between gap-4 items-center">
<>
{(user.type === "developer" || user.type === "admin") && (
<Select
@@ -202,136 +220,570 @@ export default function Stats() {
/>
)}
</>
{/* <DatePicker
dateFormat="dd/MM/yyyy"
startDate={startDate}
endDate={endDate}
selectsRange
filterDate={(date) => !moment(date).isSameOrBefore(moment(startDate))}
onChange={([initialDate, finalDate]) => {
setStartDate(initialDate);
setEndDate(finalDate);
}}
/> */}
</div>
{stats.length > 0 && (
<div className="flex -md:flex-col -md:items-center gap-4 flex-wrap">
{/* Exams per module */}
<div className="flex flex-col gap-10 border w-full h-fit md:h-96 md:max-w-xs border-mti-gray-platinum p-4 pb-12 rounded-xl">
<span className="text-sm font-bold">Exams per Module</span>
<div className="flex flex-col gap-4">
{MODULE_ARRAY.map((module) => (
<div className="flex flex-col gap-2" key={module}>
<div className="flex justify-between items-end">
<span className="text-xs">
<span className="font-medium">{totalExamsByModule(stats, module)}</span> of{" "}
<span className="font-medium">{Object.keys(groupBySession(stats)).length}</span>
</span>
<span className="text-xs">{capitalize(module)}</span>
</div>
<ProgressBar
color={module}
percentage={(totalExamsByModule(stats, module) * 100) / Object.keys(groupBySession(stats)).length}
label=""
className="h-3"
<>
<div className="flex -md:flex-col -md:items-center gap-4 flex-wrap">
{/* Overall Level per Month */}
<div className="flex flex-col items-center gap-4 border w-full h-[420px] overflow-y-scroll scrollbar-hide md:max-w-sm border-mti-gray-platinum p-4 pb-12 rounded-xl">
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-bold">Overall Level per Month</span>
<div className="flex gap-2 items-center">
{monthlyOverallScoreDate && (
<button
onClick={() =>
setMonthlyOverallScoreDate((prev) => moment(prev).subtract(1, "months").toDate())
}>
<BsChevronLeft />
</button>
)}
<DatePicker
dateFormat="MMMM yyyy"
className="border border-mti-gray-dim/40 px-2 py-1.5 rounded-lg text-center w-[200px]"
minDate={initialStatDate}
maxDate={new Date()}
selected={monthlyOverallScoreDate}
showMonthYearPicker
onChange={setMonthlyOverallScoreDate}
/>
{monthlyOverallScoreDate && (
<button
disabled={moment(monthlyOverallScoreDate).add(1, "months").isAfter(moment())}
onClick={() => setMonthlyOverallScoreDate((prev) => moment(prev).add(1, "months").toDate())}
className="disabled:text-neutral-200">
<BsChevronRight />
</button>
)}
<button onClick={() => setMonthlyOverallScoreDate(new Date())}>
<BsArrowClockwise />
</button>
</div>
))}
</div>
<div className="w-full grid grid-cols-3 gap-4 items-center">
{[...Array(31).keys()].map((day) => {
const date = moment(
`${(day + 1).toString().padStart(2, "0")}/${
moment(monthlyOverallScoreDate).get("month") + 1
}/${moment(monthlyOverallScoreDate).get("year")}`,
"DD/MM/yyyy",
);
return date.isValid() && date.isSameOrBefore(moment()) ? (
<div
key={day}
className="flex flex-col gap-1 items-start border border-mti-gray-smoke rounded-lg overflow-hidden">
<span className="bg-mti-purple-ultralight w-full px-2 py-1 font-semibold">
Day {(day + 1).toString().padStart(2, "0")}
</span>
<span className="px-2">
Level{" "}
{calculateTotalScore(stats.filter((s) => timestampToMoment(s).isBefore(date))).toFixed(1)}
</span>
</div>
) : null;
})}
</div>
</div>
{/* Overall Level per Month Graph */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-[420px]">
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-bold">Overall Level per Month</span>
<div className="flex gap-2 items-center">
{monthlyOverallScoreDate && (
<button
onClick={() =>
setMonthlyOverallScoreDate((prev) => moment(prev).subtract(1, "months").toDate())
}>
<BsChevronLeft />
</button>
)}
<DatePicker
dateFormat="MMMM yyyy"
className="border border-mti-gray-dim/40 px-2 py-1.5 rounded-lg text-center w-[200px]"
minDate={initialStatDate}
maxDate={new Date()}
selected={monthlyOverallScoreDate}
showMonthYearPicker
onChange={setMonthlyOverallScoreDate}
/>
{monthlyOverallScoreDate && (
<button
disabled={moment(monthlyOverallScoreDate).add(1, "months").isAfter(moment())}
onClick={() => setMonthlyOverallScoreDate((prev) => moment(prev).add(1, "months").toDate())}
className="disabled:text-neutral-200">
<BsChevronRight />
</button>
)}
<button onClick={() => setMonthlyOverallScoreDate(new Date())}>
<BsArrowClockwise />
</button>
</div>
</div>
<Chart
type="line"
options={{
scales: {
y: {
min: 0,
max: 9,
},
},
}}
data={{
labels: [...Array(31).keys()]
.map((day) => {
const date = moment(
`${(day + 1).toString().padStart(2, "0")}/${
moment(monthlyOverallScoreDate).get("month") + 1
}/${moment(monthlyOverallScoreDate).get("year")}`,
"DD/MM/yyyy",
);
return date.isValid() ? (day + 1).toString().padStart(2, "0") : undefined;
})
.filter((x) => !!x),
datasets: [
{
type: "line",
label: "Total",
fill: false,
borderColor: "#6A5FB1",
backgroundColor: "#7872BF",
borderWidth: 2,
spanGaps: true,
data: [...Array(31).keys()]
.map((day) => {
const date = moment(
`${(day + 1).toString().padStart(2, "0")}/${
moment(monthlyOverallScoreDate).get("month") + 1
}/${moment(monthlyOverallScoreDate).get("year")}`,
"DD/MM/yyyy",
);
return date.isValid()
? calculateTotalScore(
stats.filter((s) => timestampToMoment(s).isBefore(date)),
).toFixed(1)
: undefined;
})
.filter((x) => !!x),
},
],
}}
/>
</div>
{/* Module Level per Day */}
<div className="flex flex-col gap-8 border w-full h-fit md:h-[420px] md:max-w-xs border-mti-gray-platinum p-4 pb-12 rounded-xl">
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-bold">Module Level per Day</span>
<div className="flex gap-2 items-center">
{monthlyModuleScoreDate && (
<button
onClick={() =>
setMonthlyModuleScoreDate((prev) => moment(prev).subtract(1, "days").toDate())
}>
<BsChevronLeft />
</button>
)}
<DatePicker
dateFormat="dd MMMM yyyy"
className="border border-mti-gray-dim/40 px-2 py-1.5 rounded-lg text-center w-[200px]"
minDate={initialStatDate}
maxDate={new Date()}
selected={monthlyModuleScoreDate}
onChange={setMonthlyModuleScoreDate}
/>
{monthlyModuleScoreDate && (
<button
disabled={moment(monthlyModuleScoreDate).add(1, "days").isAfter(moment())}
onClick={() => setMonthlyModuleScoreDate((prev) => moment(prev).add(1, "days").toDate())}
className="disabled:text-neutral-200">
<BsChevronRight />
</button>
)}
<button onClick={() => setMonthlyModuleScoreDate(new Date())}>
<BsArrowClockwise />
</button>
</div>
</div>
<div className="flex flex-col gap-4">
{calculateModuleScore(stats.filter((s) => timestampToMoment(s).isBefore(moment(monthlyModuleScoreDate))))
.sort(sortByModule)
.map(({module, score}) => (
<div className="flex flex-col gap-2" key={module}>
<div className="flex justify-between items-end">
<span className="text-xs">
<span className="font-medium">{score}</span> of <span className="font-medium">9</span>
</span>
<span className="text-xs">{capitalize(module)}</span>
</div>
<ProgressBar
color={module as Module}
percentage={(score * 100) / 9}
label=""
className="h-3"
/>
</div>
))}
</div>
</div>
</div>
{/* Module Score */}
<div className="flex flex-col gap-10 border w-full h-fit md:h-96 md:max-w-xs border-mti-gray-platinum p-4 pb-12 rounded-xl">
<span className="text-sm font-bold">Module Score Bands</span>
<div className="flex flex-col gap-4">
{MODULE_ARRAY.map((module) => (
<div className="flex flex-col gap-2" key={module}>
<div className="flex justify-between items-end">
<span className="text-xs">
<span className="font-medium">{user.levels[module]}</span> of{" "}
<span className="font-medium">{user.desiredLevels[module]}</span>
</span>
<span className="text-xs">{capitalize(module)}</span>
</div>
<ProgressBar
color={module}
percentage={(user.levels[module] * 100) / user.desiredLevels[module]}
label=""
className="h-3"
<Divider />
<div className="flex -md:flex-col -md:items-center gap-4 flex-wrap">
{/* Module Level per Exam */}
<div className="flex flex-col items-center gap-4 border w-full h-[420px] overflow-y-scroll scrollbar-hide md:max-w-sm border-mti-gray-platinum p-4 pb-12 rounded-xl">
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-bold">Module Level per Exam</span>
<div className="flex gap-2 items-center">
{dailyScoreDate && (
<button onClick={() => setDailyScoreDate((prev) => moment(prev).subtract(1, "days").toDate())}>
<BsChevronLeft />
</button>
)}
<DatePicker
dateFormat="dd MMMM yyyy"
className="border border-mti-gray-dim/40 px-2 py-1.5 rounded-lg text-center w-[200px]"
minDate={initialStatDate}
maxDate={new Date()}
selected={dailyScoreDate}
onChange={setDailyScoreDate}
/>
{dailyScoreDate && (
<button
disabled={moment(dailyScoreDate).add(1, "days").isAfter(moment())}
onClick={() => setDailyScoreDate((prev) => moment(prev).add(1, "days").toDate())}
className="disabled:text-neutral-200">
<BsChevronRight />
</button>
)}
<button onClick={() => setDailyScoreDate(new Date())}>
<BsArrowClockwise />
</button>
</div>
))}
</div>
<div className="w-full grid grid-cols-1 gap-6 items-center">
{Object.keys(
groupBySession(
stats.filter(
(s) =>
Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0 &&
timestampToMoment(s).day() === moment(dailyScoreDate).day(),
),
),
).length === 0 && <span className="font-semibold ml-1">No exams performed this day...</span>}
{Object.keys(
groupBySession(
stats.filter(
(s) =>
Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0 &&
timestampToMoment(s).day() === moment(dailyScoreDate).day(),
),
),
).map((session, index) => (
<div key={index} className="flex flex-col gap-2 items-start rounded-lg overflow-hidden">
<span className="bg-mti-purple-ultralight w-full px-2 py-1 font-semibold">
Exam {(index + 1).toString().padStart(2, "0")}
</span>
<div className="flex justify-between w-full">
{MODULE_ARRAY.map((module) => {
const score = calculateScorePerModule(
groupBySession(
stats.filter(
(s) =>
Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0,
),
)[session],
module,
);
return score === -1 ? null : <Badge module={module}>{score.toFixed(1)}</Badge>;
}).filter((m) => !!m)}
</div>
</div>
))}
</div>
</div>
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-[420px]">
<div className="flex flex-col gap-2 w-full mb-2">
<span className="text-sm font-bold">Module Level per Exam</span>
<div className="flex gap-2 items-center">
{dailyScoreDate && (
<button onClick={() => setDailyScoreDate((prev) => moment(prev).subtract(1, "days").toDate())}>
<BsChevronLeft />
</button>
)}
<DatePicker
dateFormat="dd MMMM yyyy"
className="border border-mti-gray-dim/40 px-2 py-1.5 rounded-lg text-center w-[200px]"
minDate={initialStatDate}
maxDate={new Date()}
selected={dailyScoreDate}
onChange={setDailyScoreDate}
/>
{dailyScoreDate && (
<button
disabled={moment(dailyScoreDate).add(1, "days").isAfter(moment())}
onClick={() => setDailyScoreDate((prev) => moment(prev).add(1, "days").toDate())}
className="disabled:text-neutral-200">
<BsChevronRight />
</button>
)}
<button onClick={() => setDailyScoreDate(new Date())}>
<BsArrowClockwise />
</button>
</div>
</div>
<Chart
type="line"
options={{
scales: {
y: {
min: 0,
max: 9,
},
},
}}
data={{
labels: Object.keys(
groupBySession(
stats.filter(
(s) =>
Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0 &&
timestampToMoment(s).day() === moment(dailyScoreDate).day(),
),
),
).map((_, index) => `Exam ${(index + 1).toString().padStart(2, "0")}`),
datasets: [
...MODULE_ARRAY.map((module, index) => ({
type: "line" as const,
label: capitalize(module),
borderColor: COLORS[index],
backgroundColor: COLORS[index],
borderWidth: 2,
data: calculateModularScorePerSession(
stats.filter(
(s) =>
Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0 &&
timestampToMoment(s).day() === moment(dailyScoreDate).day(),
),
module,
),
})),
],
}}
/>
</div>
</div>
{/* Total Score Band per Session */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
<span className="text-sm font-bold">Total Score Band per Session</span>
<Chart
type="line"
data={{
labels: Object.keys(groupBySession(stats)).map((_, index) => index),
datasets: [
{
type: "line",
label: "Total",
fill: false,
borderColor: "#6A5FB1",
backgroundColor: "#7872BF",
borderWidth: 2,
spanGaps: true,
data: calculateTotalScorePerSession(),
},
],
}}
/>
</div>
<Divider />
{/* Module Score Band per Session */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
<span className="text-sm font-bold">Module Score Band per Session</span>
<Chart
type="line"
data={{
labels: Object.keys(groupBySession(stats)).map((_, index) => index),
datasets: [
...MODULE_ARRAY.map((module, index) => ({
type: "line" as const,
label: capitalize(module),
borderColor: COLORS[index],
backgroundColor: COLORS[index],
borderWidth: 2,
data: calculateModularScorePerSession(module),
})),
],
<div className="w-full flex flex-col gap-4">
<DatePicker
dateFormat="dd/MM/yyyy"
className="border border-mti-gray-dim/40 px-4 py-2 rounded-lg text-center w-80"
startDate={startDate}
endDate={endDate}
selectsRange
showMonthDropdown
filterDate={(date) => moment(date).isSameOrBefore(moment(new Date()))}
onChange={([initialDate, finalDate]) => {
setStartDate(initialDate);
setEndDate(finalDate);
}}
/>
</div>
<div className="flex -md:flex-col -md:items-center gap-4 flex-wrap">
{/* Reading Score Band in Interval */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
<span className="text-sm font-bold">Reading Score Band in Interval</span>
<Chart
options={{
scales: {
y: {
min: 0,
max: 9,
},
},
}}
type="line"
data={{
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
datasets: [
{
type: "line",
label: "Reading",
fill: false,
borderColor: COLORS[0],
backgroundColor: COLORS[0],
borderWidth: 2,
spanGaps: true,
data: intervalDates.map((date) => {
return calculateTotalScore(
stats.filter(
(s) => timestampToMoment(s).isBefore(date) && s.module === "reading",
),
).toFixed(1);
}),
},
],
}}
/>
</div>
{/* Average Time per Module */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
<span className="text-sm font-bold">Average Time per Module (in Minutes)</span>
<Chart
type="line"
data={{
labels: Object.keys(groupBySession(stats.filter((s) => !!s.timeSpent))).map((_, index) => index),
datasets: [
{
type: "line",
label: "Average (in minutes)",
fill: false,
borderColor: "#6A5FB1",
backgroundColor: "#7872BF",
borderWidth: 2,
spanGaps: true,
data: calculateAverageTimePerModule(),
},
],
}}
/>
{/* Listening Score Band in Interval */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
<span className="text-sm font-bold">Listening Score Band in Interval</span>
<Chart
options={{
scales: {
y: {
min: 0,
max: 9,
},
},
}}
type="line"
data={{
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
datasets: [
{
type: "line",
label: "Listening",
fill: false,
borderColor: COLORS[1],
backgroundColor: COLORS[1],
borderWidth: 2,
spanGaps: true,
data: intervalDates.map((date) => {
return calculateTotalScore(
stats.filter(
(s) => timestampToMoment(s).isBefore(date) && s.module === "listening",
),
).toFixed(1);
}),
},
],
}}
/>
</div>
{/* Writing Score Band in Interval */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
<span className="text-sm font-bold">Writing Score Band in Interval</span>
<Chart
options={{
scales: {
y: {
min: 0,
max: 9,
},
},
}}
type="line"
data={{
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
datasets: [
{
type: "line",
label: "Writing",
fill: false,
borderColor: COLORS[2],
backgroundColor: COLORS[2],
borderWidth: 2,
spanGaps: true,
data: intervalDates.map((date) => {
return calculateTotalScore(
stats.filter(
(s) => timestampToMoment(s).isBefore(date) && s.module === "writing",
),
).toFixed(1);
}),
},
],
}}
/>
</div>
{/* Speaking Score Band in Interval */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
<span className="text-sm font-bold">Speaking Score Band in Interval</span>
<Chart
options={{
scales: {
y: {
min: 0,
max: 9,
},
},
}}
type="line"
data={{
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
datasets: [
{
type: "line",
label: "Speaking",
fill: false,
borderColor: COLORS[3],
backgroundColor: COLORS[3],
borderWidth: 2,
spanGaps: true,
data: intervalDates.map((date) => {
return calculateTotalScore(
stats.filter(
(s) => timestampToMoment(s).isBefore(date) && s.module === "speaking",
),
).toFixed(1);
}),
},
],
}}
/>
</div>
{/* Level Score Band in Interval */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
<span className="text-sm font-bold">Level Score Band in Interval</span>
<Chart
options={{
scales: {
y: {
min: 0,
max: 9,
},
},
}}
type="line"
data={{
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
datasets: [
{
type: "line",
label: "Level",
fill: false,
borderColor: COLORS[4],
backgroundColor: COLORS[4],
borderWidth: 2,
spanGaps: true,
data: intervalDates.map((date) => {
return calculateTotalScore(
stats.filter((s) => timestampToMoment(s).isBefore(date) && s.module === "level"),
).toFixed(1);
}),
},
],
}}
/>
</div>
</div>
</div>
</div>
</>
)}
</section>
{stats.length === 0 && (

View File

@@ -1,4 +1,4 @@
import {Type} from "@/interfaces/user";
import {Type, User, CorporateUser} from "@/interfaces/user";
export const USER_TYPE_LABELS: {[key in Type]: string} = {
student: "Student",
@@ -8,3 +8,7 @@ export const USER_TYPE_LABELS: {[key in Type]: string} = {
admin: "Admin",
developer: "Developer",
};
export function isCorporateUser(user: User): user is CorporateUser {
return (user as CorporateUser).corporateInformation !== undefined;
}

View File

@@ -11,17 +11,19 @@ import {
import axios from "axios";
import {speakingReverseMarking, writingReverseMarking} from "./score";
export const evaluateWritingAnswer = async (exercise: WritingExercise, solution: UserSolution) => {
export const evaluateWritingAnswer = async (exercise: WritingExercise, solution: UserSolution, id: string): Promise<object | undefined> => {
const response = await axios.post<Evaluation>("/api/evaluate/writing", {
question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""),
answer: solution.solutions[0].solution.trim().replaceAll("\n", " "),
id,
});
if (response.status === 200) {
return {
...solution,
id,
score: {
correct: writingReverseMarking[response.data.overall] || 0,
correct: response.data ? writingReverseMarking[response.data.overall] : 0,
missing: 0,
total: 100,
},
@@ -32,12 +34,12 @@ export const evaluateWritingAnswer = async (exercise: WritingExercise, solution:
return undefined;
};
export const evaluateSpeakingAnswer = async (exercise: SpeakingExercise | InteractiveSpeakingExercise, solution: UserSolution) => {
export const evaluateSpeakingAnswer = async (exercise: SpeakingExercise | InteractiveSpeakingExercise, solution: UserSolution, id: string) => {
switch (exercise?.type) {
case "speaking":
return await evaluateSpeakingExercise(exercise, exercise.id, solution);
return {...(await evaluateSpeakingExercise(exercise, exercise.id, solution, id)), id};
case "interactiveSpeaking":
return await evaluateInteractiveSpeakingExercise(exercise.id, solution);
return {...(await evaluateInteractiveSpeakingExercise(exercise.id, solution, id)), id};
default:
return undefined;
}
@@ -48,7 +50,7 @@ const downloadBlob = async (url: string): Promise<Buffer> => {
return Buffer.from(blobResponse.data, "binary");
};
const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId: string, solution: UserSolution) => {
const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId: string, solution: UserSolution, id: string) => {
const audioBlob = await downloadBlob(solution.solutions[0].solution.trim());
const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"});
@@ -58,6 +60,7 @@ const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId:
const evaluationQuestion =
`${exercise.text.replaceAll("\n", "")}` + (exercise.prompts.length > 0 ? `You should talk about: ${exercise.prompts.join(", ")}` : "");
formData.append("question", evaluationQuestion);
formData.append("id", id);
const config = {
headers: {
@@ -71,18 +74,18 @@ const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId:
return {
...solution,
score: {
correct: speakingReverseMarking[response.data.overall] || 0,
correct: response.data ? speakingReverseMarking[response.data.overall] : 0,
missing: 0,
total: 100,
},
solutions: [{id: exerciseId, solution: response.data.fullPath, evaluation: response.data}],
solutions: [{id: exerciseId, solution: response.data ? response.data.fullPath : null, evaluation: response.data}],
};
}
return undefined;
};
const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution: UserSolution) => {
const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution: UserSolution, id: string) => {
const promiseParts = solution.solutions.map(async (x: {prompt: string; blob: string}) => ({
question: x.prompt,
answer: await downloadBlob(x.blob),
@@ -98,6 +101,7 @@ const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution:
formData.append(`question_${seed}`, question);
formData.append(`answer_${seed}`, audioFile, `${seed}.wav`);
});
formData.append("id", id);
const config = {
headers: {
@@ -111,11 +115,11 @@ const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution:
return {
...solution,
score: {
correct: speakingReverseMarking[response.data.overall] || 0,
correct: response.data ? speakingReverseMarking[response.data.overall] : 0,
missing: 0,
total: 100,
},
solutions: [{id: exerciseId, solution: response.data.answer, evaluation: response.data}],
solutions: [{id: exerciseId, solution: response.data ? response.data.answer : null, evaluation: response.data}],
};
}

52
src/utils/exams.be.ts Normal file
View File

@@ -0,0 +1,52 @@
import {
collection,
getDocs,
query,
where,
setDoc,
doc,
Firestore,
} from "firebase/firestore";
import { shuffle } from "lodash";
import { Exam } from "@/interfaces/exam";
import { Stat } from "@/interfaces/user";
export const getExams = async (
db: Firestore,
module: string,
avoidRepeated: string,
// added userId as due to assignments being set from the teacher to the student
// we need to make sure we are serving exams not executed by the user and not
// by the teacher that performed the request
userId: string | undefined
): Promise<Exam[]> => {
const moduleRef = collection(db, module);
const q = query(moduleRef, where("isDiagnostic", "==", false));
const snapshot = await getDocs(q);
const exams: Exam[] = shuffle(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
module,
}))
) as Exam[];
if (avoidRepeated === "true") {
const statsQ = query(collection(db, "stats"), where("user", "==", userId));
const statsSnapshot = await getDocs(statsQ);
const stats: Stat[] = statsSnapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as unknown as Stat[];
const filteredExams = exams.filter(
(x) => !stats.map((s) => s.exam).includes(x.id)
);
return filteredExams.length > 0 ? filteredExams : exams;
}
return exams;
};

View File

@@ -1,7 +1,7 @@
import {Module} from "@/interfaces";
import {Exercise} from "@/interfaces/exam";
export const MODULE_ARRAY: Module[] = ["reading", "listening", "writing", "speaking"];
export const MODULE_ARRAY: Module[] = ["reading", "listening", "writing", "speaking", "level"];
export const moduleLabels: {[key in Module]: string} = {
listening: "Listening",
@@ -11,7 +11,7 @@ export const moduleLabels: {[key in Module]: string} = {
level: "Level",
};
export const sortByModule = (a: {module: Module}, b: {module: Module}) => {
export const sortByModule = (a: {module: Module; [key: string]: any}, b: {module: Module; [key: string]: any}) => {
return MODULE_ARRAY.findIndex((x) => a.module === x) - MODULE_ARRAY.findIndex((x) => b.module === x);
};

13
src/utils/number.ts Normal file
View File

@@ -0,0 +1,13 @@
export function isDecimal(num: number) {
return num % 1 !== 0;
}
export function toFixedNumber(num: number, decimals: number = 2) {
// Rounds to 2 decimal places
if(isDecimal(num)) {
const multiplier = Math.pow(10, decimals);
return Math.round(num * multiplier) / multiplier;
}
return num;
}

View File

@@ -1,4 +1,5 @@
import {Module} from "@/interfaces";
import { LevelScore } from "@/constants/ielts";
type Type = "academic" | "general";
@@ -94,11 +95,12 @@ const academicMarking: {[key: number]: number} = {
};
const levelMarking: {[key: number]: number} = {
88: 9,
64: 8,
52: 6,
32: 4,
16: 2,
88: 9, // Advanced
64: 8 , // Upper-Intermediate
52: 6, // Intermediate
32: 4, // Pre-Intermediate
16: 2, // Elementary
0: 0, // Beginner
};
const moduleMarkings: {[key in Module]: {[key in Type]: {[key: number]: number}}} = {
@@ -142,3 +144,21 @@ export const calculateBandScore = (correct: number, total: number, module: Modul
export const calculateAverageLevel = (levels: {[key in Module]: number}) => {
return Object.keys(levels).reduce((accumulator, current) => levels[current as Module] + accumulator, 0) / 4;
};
export const getLevelScore = (level: number) => {
switch(level) {
case 0:
return ['Beginner', 'Low A1'];
case 2:
return ['Elementary', 'High A1/Low A2'];
case 4:
return ['Pre-Intermediate', 'High A2/Low B1'];
case 6:
return ['Intermediate', 'High B1/Low B2'];
case 8:
return ['Upper-Intermediate', 'High B2/Low C1'];
case 9:
return ['Advanced', 'C1'];
default: return [];
}
}

View File

@@ -4,6 +4,11 @@ import {convertCamelCaseToReadable} from "@/utils/string";
import {UserSolution} from "@/interfaces/exam";
import {Module} from "@/interfaces";
import {MODULES} from "@/constants/ielts";
import moment from "moment";
export const timestampToMoment = (stat: Stat): moment.Moment => {
return moment.unix(stat.date > Math.pow(10, 11) ? stat.date / 1000 : stat.date);
};
export const totalExams = (stats: Stat[]): number => {
const moduleStats = formatModuleTotalStats(stats);

View File

@@ -1287,6 +1287,13 @@
resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/react-csv@^1.1.10":
version "1.1.10"
resolved "https://registry.yarnpkg.com/@types/react-csv/-/react-csv-1.1.10.tgz#b4e292d7330d2fa12062c579c752f254f559bf56"
integrity sha512-PESAyASL7Nfi/IyBR3ufd8qZkyoS+7jOylKmJxRZUZLFASLo4NZaRsJ8rNP8pCcbIziADyWBbLPD1nPddhsL4g==
dependencies:
"@types/react" "*"
"@types/react-datepicker@^4.15.1":
version "4.15.1"
resolved "https://registry.yarnpkg.com/@types/react-datepicker/-/react-datepicker-4.15.1.tgz#a66fee520f2a31f83b45f4ed7f28af7296e11d0c"
@@ -4519,6 +4526,11 @@ react-chartjs-2@^5.2.0:
resolved "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz"
integrity sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==
react-csv@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/react-csv/-/react-csv-2.2.2.tgz#5bbf0d72a846412221a14880f294da9d6def9bfb"
integrity sha512-RG5hOcZKZFigIGE8LxIEV/OgS1vigFQT4EkaHeKgyuCbUAu9Nbd/1RYq++bJcJJ9VOqO/n9TZRADsXNDR4VEpw==
react-currency-input-field@^3.6.12:
version "3.6.12"
resolved "https://registry.yarnpkg.com/react-currency-input-field/-/react-currency-input-field-3.6.12.tgz#6c59bec50b9a769459c971f94f9a67b7bf9046f7"