Compare commits

...

283 Commits

Author SHA1 Message Date
Joao Ramos
6bcc303b74 Fixed institution print 2024-01-16 22:24:08 +00:00
Joao Ramos
8002c71b91 Fixed issue with 100% being hyphenized 2024-01-16 22:22:55 +00:00
Joao Ramos
31d3232f19 Added passport id to PDF 2024-01-16 19:24:19 +00:00
Joao Ramos
4448c2019e Added some bold text to PDF footer 2024-01-16 18:48:01 +00:00
Tiago Ribeiro
d0b0dfb16f Solved a bug with the UserCard 2024-01-16 16:30:38 +00:00
Tiago Ribeiro
c5007a316f Updated the profile of the Corporate user according to the client's instructions 2024-01-16 16:26:59 +00:00
Tiago Ribeiro
c68e206aae Updated the Group creation modal to use Excel 2024-01-15 21:32:54 +00:00
Tiago Ribeiro
2bad3ad09f Solved: A second “Next” button appears on Listening part transitions 2024-01-15 21:21:08 +00:00
Tiago Ribeiro
f9e037bd7b Updated to "Linked to:" 2024-01-15 21:01:38 +00:00
Tiago Ribeiro
ccde1c84b7 Added a log for the exam for developers 2024-01-15 20:35:11 +00:00
Tiago Ribeiro
367553eb44 Added associated corporate’s name to Students and Teachers 2024-01-15 20:27:20 +00:00
Tiago Ribeiro
576d2ac29d Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-01-15 20:19:21 +00:00
João Ramos
e13af65d88 Merged in bug-missing-radial-performance-sumary (pull request #24)
Fixed Missing radial performance sumary

Approved-by: Tiago Ribeiro
2024-01-15 20:13:29 +00:00
Joao Ramos
294d319ab3 Removed debuggers 2024-01-15 19:47:53 +00:00
Joao Ramos
7572909b13 Removed unnecessary margin ruining percentage centered 2024-01-15 19:33:31 +00:00
Joao Ramos
46b9fe50ef Added the missing radial progress 2024-01-15 19:32:11 +00:00
Tiago Ribeiro
1335c14acc Removed the ability for a Teacher to upload a file for the Group creation 2024-01-15 16:34:06 +00:00
Tiago Ribeiro
e47607597c Solved some bugs related to the payment page 2024-01-15 14:52:17 +00:00
Tiago Ribeiro
b7b2dca2dd Updated the user deletion to work in the backend 2024-01-15 11:02:40 +00:00
Tiago Ribeiro
a14c9f8b3c Updated the label of the cancel button on FillBlanks 2024-01-15 10:20:23 +00:00
Tiago Ribeiro
e59d36e892 Updated the UserCard to not show the Commission for corporate users 2024-01-15 10:11:18 +00:00
Tiago Ribeiro
f5bdedee2f Updated the message of the failed delete payment 2024-01-14 23:35:48 +00:00
Tiago Ribeiro
3f0821eb33 Added the corporate name to the user's top-right profile link 2024-01-14 23:31:50 +00:00
Tiago Ribeiro
31e09c94c7 Added an explanation for the Excel file format requested 2024-01-14 23:29:22 +00:00
Tiago Ribeiro
404e5a8a0c Added a * to required fields 2024-01-14 23:20:12 +00:00
Tiago Ribeiro
b7a3778f01 Solved another bug with the TrueFalse 2024-01-14 23:18:51 +00:00
Tiago Ribeiro
24ec336dca Updated the Record to start with the overall screen 2024-01-14 23:13:12 +00:00
Tiago Ribeiro
e324b37942 Prepared for partial exams 2024-01-14 22:36:39 +00:00
Tiago Ribeiro
066baa9492 Solved a bug with the WriteBlanks warning 2024-01-14 22:27:48 +00:00
Tiago Ribeiro
08aec9b54c Solved some bugs with reading the Excel file 2024-01-14 22:18:15 +00:00
Tiago Ribeiro
10a480aa81 Updated the Code generators select to depend on the type of user 2024-01-14 22:08:17 +00:00
João Ramos
360e6f8f60 Merged in bug-fixing-13-jan-24 (pull request #23)
Editing country manager is now only available for admins/dev
2024-01-14 00:10:25 +00:00
Tiago Ribeiro
eadddbf505 Merged develop into bug-fixing-13-jan-24 2024-01-14 00:09:32 +00:00
Joao Ramos
be03760cb9 Editing country amanger is now only available for admins/dev 2024-01-13 23:58:03 +00:00
Tiago Ribeiro
99758d860d BatchCodeGenerator.tsx edited online with Bitbucket 2024-01-13 18:54:21 +00:00
João Ramos
51dcb69b81 Merged in bug-fixing-12-jan-24 (pull request #21)
Bug fixing 12 jan 24

Approved-by: Tiago Ribeiro
2024-01-12 21:30:28 +00:00
Joao Ramos
580ddfd9e6 Fixed Pending payment page key 2024-01-12 21:27:46 +00:00
Joao Ramos
9e6dc4b4c2 Reviewed all IconCard for non-matching icons 2024-01-12 20:00:31 +00:00
Joao Ramos
72b9e1f11d Added payment done and pending visible to Admin and Developers without filters 2024-01-12 19:55:46 +00:00
Tiago Ribeiro
ad1dbaef27 Formatted the code to accept .xlsx 2024-01-12 13:49:27 +00:00
Tiago Ribeiro
6cdee9b268 Merge branch 'develop' into feature/62/upload-users-with-excel 2024-01-12 13:42:25 +00:00
João Ramos
7f4d82072f Merged in feature-payment-done-pending (pull request #20)
Added payment done and pending

Approved-by: Tiago Ribeiro
2024-01-12 10:15:12 +00:00
João Ramos
e365640620 Merged in bug-fixing-11-jan-24 (pull request #19)
Bug fixing 11 jan 24

Approved-by: Tiago Ribeiro
2024-01-12 07:55:42 +00:00
Joao Ramos
27a4014f63 Added payment done and pending 2024-01-12 01:38:34 +00:00
Joao Ramos
cb91acdded Removed some horizontal margins on PDFs 2024-01-12 00:32:02 +00:00
Joao Ramos
7714854338 Changed all corporate icons 2024-01-12 00:25:14 +00:00
Joao Ramos
5379cdb0d2 Blocked corporate user edit for corporate 2024-01-12 00:23:18 +00:00
Joao Ramos
39ea11bc9b Fixed naming of the table 2024-01-12 00:20:03 +00:00
Joao Ramos
bb1a2e477a Revert "Removed references to Referred corporated"
This reverts commit 21b612eaa4.
2024-01-12 00:19:35 +00:00
Tiago Ribeiro
34c1041182 Hard coded the CORS for the EnCoach website 2024-01-11 23:10:51 +00:00
Tiago Ribeiro
b2690f748b Merge branch 'develop' into feature/62/upload-users-with-excel 2024-01-11 21:55:57 +00:00
João Ramos
edbf405c30 Merged in bug-fixing-10-Jan-24 (pull request #17)
Exported Route for CORS usage

Approved-by: Tiago Ribeiro
2024-01-11 21:35:56 +00:00
Tiago Ribeiro
84c42ccf3e Adapted the BatchCodeGenerator to use an Excel file 2024-01-11 21:35:26 +00:00
Joao Ramos
5e283e358b Added CORS as .env var 2024-01-11 19:32:39 +00:00
Tiago Ribeiro
c9ed3b5a72 Merge branch 'develop' into feature/62/upload-users-with-excel 2024-01-11 14:39:40 +00:00
Tiago Ribeiro
3dfd65e161 Merged develop into bug-fixing-10-Jan-24 2024-01-11 14:31:56 +00:00
João Ramos
040102c835 Merged in feature-export-csv-roles (pull request #18)
Feature export csv roles

Approved-by: Tiago Ribeiro
2024-01-11 14:22:42 +00:00
Joao Ramos
c781c10fe9 Prevented an error that should only happen if the user had the type changed directly on the DB for testing purposes 2024-01-11 14:18:57 +00:00
Joao Ramos
a91539ec61 Download CSV is now also allowed for Agent and Corporates 2024-01-11 14:18:04 +00:00
Tiago Ribeiro
f79857fabe Started trying out reading Excel files 2024-01-11 13:55:37 +00:00
João Ramos
14d8c1e294 Merged in feature-report-export (pull request #16)
Feature report export

Approved-by: Tiago Ribeiro
2024-01-11 12:50:35 +00:00
Tiago Ribeiro
fd1af3efee Updated a bit of the conditions to show the Demographic input 2024-01-11 11:10:08 +00:00
Tiago Ribeiro
0c9f0b3dbd Added a National ID/Passport field to the demographic information of a student 2024-01-11 11:05:14 +00:00
Joao Ramos
93d5015c99 Exported Route for CORS usage 2024-01-11 00:06:09 +00:00
Joao Ramos
356d7e6a9d Merge branch 'feature-report-export' of https://bitbucket.org/ecropdev/ielts-ui into feature-report-export 2024-01-10 21:59:43 +00:00
João Ramos
2a4b7ed82d Merged develop into feature-report-export 2024-01-10 21:58:03 +00:00
Joao Ramos
2ec7e85ace Added page break + Improvement footer behaviour 2024-01-10 21:57:21 +00:00
Tiago Ribeiro
174398b4f7 Updated the text color of the unanswered WriteBlanks solutions 2024-01-10 09:43:16 +00:00
Joao Ramos
b00bf19620 Merge branch 'develop' into feature-report-export 2024-01-09 23:33:57 +00:00
Joao Ramos
744aa1e788 Added missing % on percentage
Removed unnecessary prop
2024-01-09 23:16:39 +00:00
Joao Ramos
cc0f9712d6 Added download option for assignment cards
Export PDF Download to hook
Prevented some NaN's
2024-01-09 23:15:13 +00:00
Joao Ramos
418221427a Added pdf download to record page
Reenabled reuse of PDF
2024-01-09 22:42:42 +00:00
Joao Ramos
6c741f944d Minor improvements on labels 2024-01-09 21:32:07 +00:00
Joao Ramos
1aadc4647c Final improvements for Groups PDF's 2024-01-09 20:17:21 +00:00
Joao Ramos
4e378f0c71 Added level to table 2024-01-09 18:47:32 +00:00
Joao Ramos
f8bf58e57c Removed ID and improved unknown user handling 2024-01-09 18:25:00 +00:00
Tiago Ribeiro
271364a939 Updated the payment records screen for the corporate to make sure they can't see agent related stuff 2024-01-09 17:26:37 +00:00
Tiago Ribeiro
f8f8ee5e13 Hid the country manager from the corporate on the payment records 2024-01-09 14:00:01 +00:00
Tiago Ribeiro
3b35a899e0 Removed an unused console.log 2024-01-09 13:56:31 +00:00
Tiago Ribeiro
59d1a12439 Added a simple spellchecker for the correction of the Writing 2024-01-09 13:55:04 +00:00
Tiago Ribeiro
e100c401e9 Updated the color of the unanswered questions to gray 2024-01-09 13:08:02 +00:00
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
Joao Ramos
bdf65a7215 Added initial group report pdf 2024-01-09 02:22:54 +00:00
Joao Ramos
2540398ab0 Renamed to setup for group testing 2024-01-08 22:19:48 +00:00
Joao Ramos
cd8860f6ac PDF Report titles are now dynamic 2024-01-08 22:15:54 +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
Joao Ramos
647807a07c Separated suggestion from evaluation 2024-01-08 19:27:05 +00:00
Joao Ramos
094fd05df7 Removed unnecessary code 2024-01-08 19:18:10 +00:00
Joao Ramos
1ea9d8e60f Added custom stylesheet 2024-01-08 19:17:22 +00:00
Joao Ramos
63998b50d6 Added more comment 2024-01-08 19:04:44 +00:00
Joao Ramos
0f029a21f7 Added todo notification 2024-01-08 19:00:23 +00:00
Joao Ramos
7328f5c57f Temporarily disabled hasPDF validation 2024-01-08 18:59:51 +00:00
Joao Ramos
12d608879d Added some code comments 2024-01-08 18:58:54 +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
Joao Ramos
e6c82412bf Added integration with backend to fetch skills feedback 2024-01-08 01:01:17 +00:00
Joao Ramos
5e8e46ff09 Added PNGs with partial radial progress 2024-01-07 23:51:05 +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
Joao Ramos
7a297a6f6c Added level report 2024-01-04 22:20:00 +00:00
Joao Ramos
432f4a735f Added QRCode for PDF 2024-01-04 19:49:15 +00:00
Joao Ramos
a4f79d236d Fixed Logo
Modules results display
2024-01-04 19:19:21 +00:00
Joao Ramos
a4771d5d29 Updated export to now work based on session 2024-01-04 15:48:30 +00:00
Joao Ramos
227de4ffc4 Fixed Date String for PDF 2024-01-04 12:18:34 +00:00
Joao Ramos
42fe650ae6 PDF Styling improvements 2024-01-04 12:18:11 +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
Joao Ramos
0b6a66b12d Initial version of PDF export 2023-12-28 23:41:21 +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
Tiago Ribeiro
58c18133ec Finished up the modal to create a payment and added the page to the sidebar 2023-12-05 23:41:55 +00:00
Tiago Ribeiro
03520b650b Merge branch 'develop' into faeture/payment-history 2023-12-05 16:36:16 +00:00
Tiago Ribeiro
556884058b Fixed a bug where the user was not being saved when the expiry date is disabled 2023-12-05 16:35:40 +00:00
Tiago Ribeiro
73b0d5d41d Continued creating the payment page 2023-12-05 16:27:18 +00:00
Tiago Ribeiro
7c589327f7 Merge branch 'develop' into faeture/payment-history 2023-12-04 16:01:30 +00:00
Tiago Ribeiro
5c8867555d Added the option to view both the teachers and students of a corporate as well as the corporate of a student 2023-12-03 00:13:50 +00:00
Tiago Ribeiro
36be5267a2 Set the Part 4 as undefined as well 2023-11-30 16:52:45 +00:00
Tiago Ribeiro
4ebfd49cb9 Merge branch 'develop' into faeture/payment-history 2023-11-30 15:52:16 +00:00
Tiago Ribeiro
96fe83de14 Added the Speaking generation to the project, still WIP 2023-11-30 15:50:24 +00:00
Tiago Ribeiro
1746db3752 Disabled Diagnostics test for all users except students 2023-11-30 10:36:15 +00:00
Tiago Ribeiro
58b4883236 Updated the types of exercises for the Listening Generation 2023-11-29 20:52:08 +00:00
Tiago Ribeiro
a3864eb7d3 Added sound effects to the exam generation 2023-11-29 20:26:48 +00:00
Tiago Ribeiro
1f0e5f4a08 Added the ability to generate Listening exams as well 2023-11-29 17:19:47 +00:00
Tiago Ribeiro
c90234cefc Changed from employment to position for Corporate accounts 2023-11-28 08:21:00 +00:00
Tiago Ribeiro
f354a4f4fe Solved an oopsie 2023-11-27 23:09:37 +00:00
Tiago Ribeiro
7e0c071eee Changed to Number of users 2023-11-27 23:07:40 +00:00
Tiago Ribeiro
9bed726062 Created a list of payments 2023-11-27 22:27:51 +00:00
Tiago Ribeiro
3878d4761e Made it so the listing of a corporate account shows the name of the corporate instead of the person 2023-11-27 13:07:33 +00:00
Tiago Ribeiro
81f5af5629 Added more information for the Agent User 2023-11-27 13:02:19 +00:00
Tiago Ribeiro
5f76e430af Extracted the user types 2023-11-27 11:35:04 +00:00
Tiago Ribeiro
facac33a89 More housekeeping 2023-11-27 11:22:41 +00:00
Tiago Ribeiro
f36c63f1b2 Added a trim 2023-11-27 11:06:05 +00:00
Tiago Ribeiro
b1f07b877c Added the type to the profile page 2023-11-27 10:39:43 +00:00
Tiago Ribeiro
70611305a7 Changed the defaultAvatar 2023-11-27 10:32:54 +00:00
Tiago Ribeiro
fdedc2c5d3 Changed the way the settings is viewable 2023-11-27 10:32:02 +00:00
Tiago Ribeiro
75875b49e6 Removed access to upload/add users from the teachers 2023-11-27 10:23:42 +00:00
Tiago Ribeiro
37e52886b5 Merge branch 'main' into develop 2023-11-27 08:48:52 +00:00
Tiago Ribeiro
a5dfe69220 Removed an unused firebase config variable 2023-11-27 08:48:15 +00:00
Tiago Ribeiro
1c36c7f1e1 Improved a slight detail 2023-11-26 23:21:45 +00:00
Tiago Ribeiro
9de39485de Improved the way the PayPal integration works 2023-11-26 23:16:26 +00:00
Tiago Ribeiro
0fe2e0d393 Merge branch 'develop' into feature/paypal-integration 2023-11-26 23:00:17 +00:00
Tiago Ribeiro
dbb5e131fc Removed the previous stuff 2023-11-26 22:34:59 +00:00
Tiago Ribeiro
ebda1e1717 And another test 2023-11-26 22:34:35 +00:00
Tiago Ribeiro
8cbec131fe Adding it to the build as well 2023-11-26 22:13:02 +00:00
Tiago Ribeiro
472d4a3331 Let's try this one out now 2023-11-26 21:49:39 +00:00
Tiago Ribeiro
c2f83d996a Testing something out 2023-11-26 21:15:09 +00:00
Tiago Ribeiro
43bd6b24c5 Reverted a change 2023-11-26 15:47:37 +00:00
Tiago Ribeiro
ca89261e10 Made it so the admin and agent should also be able to edit the amount each corporate should pay 2023-11-26 15:15:58 +00:00
Tiago Ribeiro
a9bbbe8b52 Turned the code in the register optional 2023-11-26 13:59:14 +00:00
Tiago Ribeiro
fa544bf4e8 Enabled payment for Corporate along with increasing every single one of their students/teachers expiry date as well 2023-11-26 11:01:27 +00:00
Tiago Ribeiro
7e91a989b3 Added packages for students to be able to purchase 2023-11-26 10:08:57 +00:00
Tiago Ribeiro
c312260721 Started working with PayPal 2023-11-24 16:02:55 +00:00
Tiago Ribeiro
23f2bace5d Added the ability to generate Level exams 2023-11-24 00:57:25 +00:00
Tiago Ribeiro
7e2f1fcf9d Merge branch 'develop' into feature/exam-generation 2023-11-21 19:45:30 +00:00
Tiago Ribeiro
6e420a8a82 Created a dashboard for the Agent 2023-11-21 18:01:45 +00:00
Tiago Ribeiro
cd81547022 Created a dashboard of the Agent 2023-11-21 13:59:36 +00:00
Tiago Ribeiro
a2baedb80c Improvement the creation of Agents 2023-11-21 13:37:29 +00:00
Tiago Ribeiro
8072cefbe6 Added the ability to create an agent using the CodeGenerator 2023-11-21 13:24:07 +00:00
Tiago Ribeiro
6bf666d01c It is now possible to generate and save both Reading and Writing exams 2023-11-21 12:17:57 +00:00
Tiago Ribeiro
7672e29063 Merge branch 'develop' into feature/exam-generation 2023-11-21 11:22:02 +00:00
Tiago Ribeiro
51e7c535df Updated the Exercise count for the Interactive Speaking as well 2023-11-21 09:35:54 +00:00
Tiago Ribeiro
d0f89cfe01 Fixed issues related to the exercise/question index in the ModuleTitle 2023-11-21 09:22:32 +00:00
Tiago Ribeiro
8de60aeb32 Merge branch 'develop' into feature/exam-generation 2023-11-21 00:31:51 +00:00
Tiago Ribeiro
0e28473c31 Changed the mobile menu to the correct one 2023-11-21 00:29:58 +00:00
Tiago Ribeiro
52d4b831ae Renamed the Owner to Admin 2023-11-20 23:46:43 +00:00
Tiago Ribeiro
cdc8cfe46e Updated more of the exam generation 2023-11-20 23:30:47 +00:00
Tiago Ribeiro
4c7e8f56d8 Made it so it is currently possible to generate reading passages 2023-11-20 21:03:24 +00:00
Tiago Ribeiro
4753b85ab5 Started creating the page to generate exams 2023-11-20 16:19:05 +00:00
Tiago Ribeiro
13c8459d4b Updated the assignments to work with the level exams 2023-11-18 00:19:26 +00:00
Tiago Ribeiro
19b3bbe139 Added it to the exam list 2023-11-17 15:45:59 +00:00
Tiago Ribeiro
44a89c6645 Added a new module called Level for level testing 2023-11-17 15:32:45 +00:00
Tiago Ribeiro
4a51bd7dfa Turned off the ExamGenerator for everyone except me 2023-11-16 13:56:37 +00:00
Tiago Ribeiro
dc759a368e Added more lists related to the expired accounts 2023-11-16 13:55:43 +00:00
Tiago Ribeiro
c28f7bb024 Added the ability to change the status and type of a user 2023-11-15 19:54:16 +00:00
Tiago Ribeiro
d412c1616f Updated the expiry date to show as red 2023-11-15 11:17:44 +00:00
Tiago Ribeiro
c2a807efc7 Improved the email verification a tiny bit 2023-11-14 15:57:30 +00:00
Tiago Ribeiro
6056735c72 Added more fields to the corporate and showcased them in the UserCard 2023-11-13 19:27:11 +00:00
Tiago Ribeiro
261ba74105 Removed the exercises and exams tab from the sidebar for owners and corporate 2023-11-13 14:43:11 +00:00
Tiago Ribeiro
4328a1d72d Made it so a corporate user is not able to generate more code than they are allowed to 2023-11-10 15:41:26 +00:00
Tiago Ribeiro
82643b51d3 Updated the user card to have the corporate information 2023-11-10 15:27:03 +00:00
194 changed files with 11694 additions and 1650 deletions

3
.gitignore vendored
View File

@@ -37,4 +37,5 @@ next-env.d.ts
.env
.yarn/*
.history*
.history*
__ENV.js

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

@@ -2,6 +2,25 @@
const nextConfig = {
reactStrictMode: true,
output: "standalone",
async headers() {
return [
{
source: "/api/packages",
headers: [
{key: "Access-Control-Allow-Credentials", value: "false"},
{key: "Access-Control-Allow-Origin", value: "https://encoach.com"},
{
key: "Access-Control-Allow-Methods",
value: "GET",
},
{
key: "Access-Control-Allow-Headers",
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
},
],
},
];
},
};
module.exports = nextConfig;

View File

@@ -10,10 +10,14 @@
"prepare": "husky install"
},
"dependencies": {
"@beam-australia/react-env": "^3.1.1",
"@headlessui/react": "^1.7.13",
"@mdi/js": "^7.1.96",
"@mdi/react": "^1.6.1",
"@next/font": "13.1.6",
"@paypal/paypal-js": "^7.1.0",
"@paypal/react-paypal-js": "^8.1.3",
"@react-pdf/renderer": "^3.1.14",
"@tanstack/react-table": "^8.10.1",
"@types/node": "18.13.0",
"@types/react": "18.0.27",
@@ -24,6 +28,7 @@
"clsx": "^1.2.1",
"countries-list": "^3.0.1",
"country-codes-list": "^1.6.11",
"currency-symbol-map": "^5.1.0",
"daisyui": "^3.1.5",
"eslint": "8.33.0",
"eslint-config-next": "13.1.6",
@@ -33,6 +38,7 @@
"formidable": "^3.5.0",
"formidable-serverless": "^1.1.1",
"framer-motion": "^9.0.2",
"howler": "^2.2.4",
"iron-session": "^6.3.1",
"lodash": "^4.17.21",
"moment": "^2.29.4",
@@ -41,9 +47,12 @@
"nodemailer-express-handlebars": "^6.1.0",
"primeicons": "^6.0.1",
"primereact": "^9.2.3",
"qrcode": "^1.5.3",
"random-words": "^2.0.0",
"react": "18.2.0",
"react-chartjs-2": "^5.2.0",
"react-csv": "^2.2.2",
"react-currency-input-field": "^3.6.12",
"react-datepicker": "^4.18.0",
"react-dom": "18.2.0",
"react-firebase-hooks": "^5.1.1",
@@ -56,6 +65,7 @@
"react-string-replace": "^1.1.0",
"react-toastify": "^9.1.2",
"react-xarrows": "^2.0.2",
"read-excel-file": "^5.7.1",
"short-unique-id": "^5.0.2",
"stripe": "^13.10.0",
"swr": "^2.1.3",
@@ -67,10 +77,14 @@
"zustand": "^4.3.6"
},
"devDependencies": {
"@types/blob-stream": "^0.1.33",
"@types/formidable": "^3.4.0",
"@types/howler": "^2.2.11",
"@types/lodash": "^4.14.191",
"@types/nodemailer": "^6.4.11",
"@types/nodemailer-express-handlebars": "^4.0.3",
"@types/qrcode": "^1.5.5",
"@types/react-csv": "^1.1.10",
"@types/react-datepicker": "^4.15.1",
"@types/uuid": "^9.0.1",
"@types/wavesurfer.js": "^6.0.6",
@@ -78,6 +92,7 @@
"autoprefixer": "^10.4.13",
"husky": "^8.0.3",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.4"
"tailwindcss": "^3.2.4",
"types/": "paypal/react-paypal-js"
}
}

BIN
public/audio/check.mp3 Normal file

Binary file not shown.

BIN
public/audio/sent.mp3 Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -10,18 +10,26 @@ import axios from "axios";
import {toast} from "react-toastify";
import {KeyedMutator} from "swr";
import CountrySelect from "./Low/CountrySelect";
import GenderInput from "@/components/High/GenderInput";
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
interface Props {
user: User;
mutateUser: KeyedMutator<User>;
}
export default function DemographicInformationInput({mutateUser}: Props) {
export default function DemographicInformationInput({user, mutateUser}: Props) {
const [country, setCountry] = useState<string>();
const [phone, setPhone] = useState<string>();
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
const [gender, setGender] = useState<Gender>();
const [employment, setEmployment] = useState<EmploymentStatus>();
const [position, setPosition] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const [companyName, setCompanyName] = useState<string>();
const [commercialRegistration, setCommercialRegistration] = useState<string>();
const save = (e?: FormEvent) => {
if (e) e.preventDefault();
setIsLoading(true);
@@ -32,8 +40,11 @@ export default function DemographicInformationInput({mutateUser}: Props) {
country,
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
gender,
employment,
employment: user.type === "corporate" ? undefined : employment,
position: user.type === "corporate" ? position : undefined,
passport_id,
},
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
})
.then((response) => mutateUser((response.data as {user: User}).user))
.catch(() => {
@@ -53,71 +64,41 @@ export default function DemographicInformationInput({mutateUser}: Props) {
about yourself.
</h2>
<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}>
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} />
</div>
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
<RadioGroup value={gender} onChange={setGender} className="flex flex-row justify-between">
<RadioGroup.Option value="male">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Male
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="female">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Female
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="other">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Other
</span>
)}
</RadioGroup.Option>
</RadioGroup>
</div>
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
<RadioGroup value={employment} onChange={setEmployment} className="grid grid-cols-2 items-center gap-4 place-items-center">
{EMPLOYMENT_STATUS.map(({status, label}) => (
<RadioGroup.Option value={status} key={status}>
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-44 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
{label}
</span>
)}
</RadioGroup.Option>
))}
</RadioGroup>
{user.type === "agent" && (
<div className="w-full flex gap-8">
<Input type="text" onChange={setCompanyName} name="companyName" label="Corporate Name" required />
<Input
type="text"
onChange={setCommercialRegistration}
name="commercialRegistration"
label="Commercial Registration"
required
/>
</div>
)}
<div className="w-full grid grid-cols-2 gap-6">
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} />
</div>
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
</div>
{user.type === "student" && (
<Input
type="text"
name="passport_id"
label="Passport/National ID"
onChange={(e) => setPassportID(e)}
value={passport_id}
placeholder="Enter National ID or Passport number"
required
/>
)}
<GenderInput value={gender} onChange={setGender} />
{user.type === "corporate" && (
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
)}
{user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />}
</form>
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
@@ -125,7 +106,14 @@ export default function DemographicInformationInput({mutateUser}: Props) {
className="lg:mt-8 max-w-[400px] w-full self-end"
color="purple"
onClick={save}
disabled={isLoading || !country || !phone || !gender || !employment}>
disabled={
isLoading ||
!country ||
!phone ||
!gender ||
(user.type === "corporate" ? !position : !employment) ||
(user.type === "agent" ? !companyName || !commercialRegistration : false)
}>
{!isLoading && "Save information"}
{isLoading && (
<div className="flex items-center justify-center">

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

@@ -53,7 +53,7 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
</div>
<div className="flex justify-between w-full">
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
Back
Cancel
</Button>
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
Confirm

View File

@@ -11,7 +11,16 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
ssr: false,
});
export default function InteractiveSpeaking({id, title, text, type, prompts, onNext, onBack}: InteractiveSpeakingExercise & CommonProps) {
export default function InteractiveSpeaking({
id,
title,
text,
type,
prompts,
updateIndex,
onNext,
onBack,
}: InteractiveSpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
@@ -20,6 +29,10 @@ export default function InteractiveSpeaking({id, title, text, type, prompts, onN
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (updateIndex) updateIndex(promptIndex);
}, [promptIndex, updateIndex]);
useEffect(() => {
if (hasExamEnded) {
onNext({

View File

@@ -48,7 +48,16 @@ function Question({
);
}
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
export default function MultipleChoice({
id,
prompt,
type,
questions,
userSolutions,
updateIndex,
onNext,
onBack,
}: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const [questionIndex, setQuestionIndex] = useState(0);
@@ -59,6 +68,10 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
const onSelectOption = (option: string) => {
const question = questions[questionIndex];
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);

View File

@@ -17,7 +17,11 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
const calculateScore = () => {
const total = questions.length || 0;
const correct = answers.filter(
(x) => questions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
(x) =>
questions
.find((y) => x.id.toString() === y.id.toString())
?.solution?.toString()
.toLowerCase() === x.solution.toLowerCase() || false,
).length;
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
@@ -62,41 +66,37 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
</div>
<span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span>
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8">
{questions.map((question, index) => (
<div key={question.id.toString()} className="flex flex-col gap-4">
<span>
{index + 1}. {question.prompt}
</span>
<div className="flex gap-4">
<Button
variant={
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "true" ? "solid" : "outline"
}
onClick={() => toggleAnswer("true", question.id.toString())}
className="!py-2">
True
</Button>
<Button
variant={
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "false" ? "solid" : "outline"
}
onClick={() => toggleAnswer("false", question.id.toString())}
className="!py-2">
False
</Button>
<Button
variant={
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "not_given"
? "solid"
: "outline"
}
onClick={() => toggleAnswer("not_given", question.id.toString())}
className="!py-2">
Not Given
</Button>
{questions.map((question, index) => {
const id = question.id.toString();
return (
<div key={question.id.toString()} className="flex flex-col gap-4">
<span>
{index + 1}. {question.prompt}
</span>
<div className="flex gap-4">
<Button
variant={answers.find((x) => x.id.toString() === id)?.solution === "true" ? "solid" : "outline"}
onClick={() => toggleAnswer("true", id)}
className="!py-2">
True
</Button>
<Button
variant={answers.find((x) => x.id.toString() === id)?.solution === "false" ? "solid" : "outline"}
onClick={() => toggleAnswer("false", id)}
className="!py-2">
False
</Button>
<Button
variant={answers.find((x) => x.id.toString() === id)?.solution === "not_given" ? "solid" : "outline"}
onClick={() => toggleAnswer("not_given", id)}
className="!py-2">
Not Given
</Button>
</div>
</div>
</div>
))}
);
})}
</div>
</div>

View File

@@ -27,8 +27,8 @@ function Blank({
const [userInput, setUserInput] = useState(userSolution || "");
useEffect(() => {
const words = userInput.split(" ").filter((x) => x !== "");
if (words.length >= maxWords) {
const words = userInput.split(" ");
if (words.length > maxWords) {
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
setUserInput(words.join(" ").trim());
}

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

@@ -22,11 +22,17 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
export interface CommonProps {
updateIndex?: (internalIndex: number) => void;
onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void;
}
export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void) => {
export const renderExercise = (
exercise: Exercise,
onNext: (userSolutions: UserSolution) => void,
onBack: (userSolutions: UserSolution) => void,
updateIndex?: (internalIndex: number) => void,
) => {
switch (exercise.type) {
case "fillBlanks":
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
@@ -35,7 +41,15 @@ export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserS
case "matchSentences":
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
case "multipleChoice":
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
return (
<MultipleChoice
key={exercise.id}
{...(exercise as MultipleChoiceExercise)}
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}
/>
);
case "writeBlanks":
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "writing":
@@ -43,6 +57,14 @@ export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserS
case "speaking":
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
case "interactiveSpeaking":
return <InteractiveSpeaking key={exercise.id} {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
return (
<InteractiveSpeaking
key={exercise.id}
{...(exercise as InteractiveSpeakingExercise)}
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}
/>
);
}
};

View File

@@ -0,0 +1,32 @@
import {EmploymentStatus, EMPLOYMENT_STATUS} from "@/interfaces/user";
import {RadioGroup} from "@headlessui/react";
import clsx from "clsx";
interface Props {
value?: EmploymentStatus;
onChange: (value?: EmploymentStatus) => void;
}
export default function EmploymentStatusInput({value, onChange}: Props) {
return (
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
<RadioGroup value={value} onChange={onChange} className="grid grid-cols-2 items-center gap-4 place-items-center">
{EMPLOYMENT_STATUS.map(({status, label}) => (
<RadioGroup.Option value={status} key={status}>
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
{label}
</span>
)}
</RadioGroup.Option>
))}
</RadioGroup>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import {Gender} from "@/interfaces/user";
import {RadioGroup} from "@headlessui/react";
import clsx from "clsx";
interface Props {
value?: Gender;
onChange: (value?: Gender) => void;
}
export default function GenderInput({value, onChange}: Props) {
return (
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
<RadioGroup value={value} onChange={onChange} className="flex flex-row gap-4 justify-between">
<RadioGroup.Option value="male">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Male
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="female">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Female
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="other">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Other
</span>
)}
</RadioGroup.Option>
</RadioGroup>
</div>
);
}

View File

@@ -33,7 +33,7 @@ export default function Layout({user, children, className, navDisabled = false,
focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
className="-md:hidden"
showAdmin={user.type !== "student"}
userType={user.type}
/>
<div
className={clsx(

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

@@ -4,7 +4,7 @@ import {BsArrowRepeat} from "react-icons/bs";
interface Props {
children: ReactNode;
color?: "rose" | "purple" | "red" | "green";
color?: "rose" | "purple" | "red" | "green" | "gray";
variant?: "outline" | "solid";
className?: string;
disabled?: boolean;
@@ -39,6 +39,11 @@ export default function Button({
outline:
"bg-transparent text-mti-red-light border border-mti-red-light hover:bg-mti-red-light disabled:text-mti-red disabled:bg-mti-red-ultralight disabled:border-none selection:bg-mti-red-dark hover:text-white selection:text-white",
},
gray: {
solid: "bg-mti-gray-davy text-white border border-mti-gray-davy hover:bg-mti-gray-davy disabled:text-mti-gray-davy disabled:bg-mti-gray-davy selection:bg-mti-gray-davy",
outline:
"bg-transparent text-mti-gray-davy border border-mti-gray-davy hover:bg-mti-gray-davy disabled:text-mti-gray-davy disabled:bg-mti-gray-davy disabled:border-none selection:bg-mti-gray-davy hover:text-white selection:text-white",
},
rose: {
solid: "bg-mti-rose-light text-white border border-mti-rose-light hover:bg-mti-rose disabled:text-mti-rose disabled:bg-mti-rose-ultralight selection:bg-mti-rose-dark",
outline:

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

@@ -3,17 +3,31 @@ import {useState} from "react";
interface Props {
type: "email" | "text" | "password" | "tel" | "number";
roundness?: "full" | "xl";
required?: boolean;
label?: string;
placeholder?: string;
defaultValue?: string | number;
value?: string | number;
className?: string;
disabled?: boolean;
name: string;
onChange: (value: string) => void;
}
export default function Input({type, label, placeholder, name, required = false, defaultValue, className, disabled = false, onChange}: Props) {
export default function Input({
type,
label,
placeholder,
name,
required = false,
value,
defaultValue,
className,
roundness = "full",
disabled = false,
onChange,
}: Props) {
const [showPassword, setShowPassword] = useState(false);
if (type === "password") {
@@ -57,9 +71,15 @@ export default function Input({type, label, placeholder, name, required = false,
type={type}
name={name}
disabled={disabled}
value={value}
onChange={(e) => onChange(e.target.value)}
min={type === "number" ? 0 : undefined}
placeholder={placeholder}
className="px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
className={clsx(
"px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none",
"placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed",
roundness === "full" ? "rounded-full" : "rounded-xl",
)}
required={required}
defaultValue={defaultValue}
/>

View File

@@ -19,6 +19,7 @@ export default function ProgressBar({label, percentage, color, useColor = false,
listening: "bg-ielts-listening",
writing: "bg-ielts-writing",
speaking: "bg-ielts-speaking",
level: "bg-ielts-level",
};
return (

View File

@@ -4,7 +4,7 @@ import {moduleLabels} from "@/utils/moduleUtils";
import clsx from "clsx";
import {motion} from "framer-motion";
import {ReactNode, useEffect, useState} from "react";
import {BsBook, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
import ProgressBar from "../Low/ProgressBar";
import TimerEndedModal from "../TimerEndedModal";
@@ -46,6 +46,7 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
};
return (

View File

@@ -66,22 +66,27 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
)}>
Dashboard
</Link>
<Link
href="/exam"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/exam" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Exams
</Link>
<Link
href="/exercises"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/exercises" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Exercises
</Link>
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
<>
<Link
href="/exam"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/exam" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Exams
</Link>
<Link
href="/exercises"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/exercises" &&
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Exercises
</Link>
</>
)}
<Link
href="/stats"
className={clsx(
@@ -98,14 +103,25 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
)}>
Record
</Link>
{user.type !== "student" && (
{["admin", "developer", "agent", "corporate"].includes(user.type) && (
<Link
href="/admin"
href="/payment-record"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/admin" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
path === "/payment-record" &&
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Admin
Payment Record
</Link>
)}
{["admin", "developer", "corporate", "teacher"].includes(user.type) && (
<Link
href="/settings"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/settings" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Settings
</Link>
)}
<Link

View File

@@ -7,7 +7,11 @@ import {BsList} from "react-icons/bs";
import clsx from "clsx";
import moment from "moment";
import MobileMenu from "./MobileMenu";
import {useState} from "react";
import {useEffect, useState} from "react";
import {Type} from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user";
import useGroups from "@/hooks/useGroups";
import {isUserFromCorporate} from "@/utils/groups";
interface Props {
user: User;
@@ -20,6 +24,7 @@ interface Props {
/* eslint-disable @next/next/no-img-element */
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
const disableNavigation = preventNavigation(navDisabled, focusMode);
const router = useRouter();
@@ -42,6 +47,11 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
return today.add(7, "days").isAfter(momentDate);
};
useEffect(() => {
if (user.type !== "student" && user.type !== "teacher") setDisablePaymentPage(false);
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
}, [user]);
return (
<>
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
@@ -53,7 +63,7 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8">
{showExpirationDate() && (
<Link
href="https://encoach.com/join"
href={disablePaymentPage ? "/payment" : ""}
data-tip="Expiry date"
className={clsx(
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
@@ -69,7 +79,10 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
)}
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden">
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
<span className="text-right -md:hidden">{user.name}</span>
<span className="text-right -md:hidden">
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
{USER_TYPE_LABELS[user.type]}
</span>
</Link>
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
<BsList className="text-mti-purple-light w-8 h-8" />

View File

@@ -0,0 +1,66 @@
import {DurationUnit} from "@/interfaces/paypal";
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OnCancelledActions, OrderResponseBody} from "@paypal/paypal-js";
import {PayPalButtons, PayPalScriptProvider, usePayPalScriptReducer} from "@paypal/react-paypal-js";
import axios from "axios";
import {useEffect, useState} from "react";
import {toast} from "react-toastify";
interface Props {
clientID: string;
currency: string;
price: number;
duration: number;
duration_unit: DurationUnit;
setIsLoading: (isLoading: boolean) => void;
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
}
export default function PayPalPayment({clientID, price, currency, duration, duration_unit, setIsLoading, onSuccess}: Props) {
const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise<string> => {
setIsLoading(true);
return axios
.post<OrderResponseBody>("/api/paypal", {currencyCode: currency, price})
.then((response) => response.data)
.then((data) => data.id);
};
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
const request = await axios.post<{ok: boolean; reason?: string}>("/api/paypal/approve", {id: data.orderID, duration, duration_unit});
if (request.status !== 200) {
toast.error("Something went wrong, please try again later");
return;
}
toast.success("Your account has been credited more time!");
return onSuccess(duration, duration_unit);
};
const onError = async (data: Record<string, unknown>) => {
setIsLoading(false);
};
const onCancel = async (data: Record<string, unknown>, actions: OnCancelledActions) => {
setIsLoading(false);
};
return (
<PayPalScriptProvider
options={{
clientId: clientID,
currency,
intent: "capture",
commit: true,
vault: true,
}}>
<PayPalButtons
className="w-full"
style={{layout: "vertical"}}
createOrder={createOrder}
onApprove={onApprove}
onCancel={onCancel}
onError={onError}></PayPalButtons>
</PayPalScriptProvider>
);
}

View File

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

View File

@@ -1,31 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import {User} from "@/interfaces/user";
import clsx from "clsx";
import LevelLabel from "./LevelLabel";
import LevelProgressBar from "./LevelProgressBar";
import {Avatar} from "primereact/avatar";
interface Props {
user: User;
className: string;
}
export default function ProfileCard({user, className}: Props) {
return (
<div className={clsx("bg-white drop-shadow-xl p-4 md:p-8 rounded-xl w-full flex flex-col gap-6", className)}>
<div className="flex w-full items-center gap-8">
<div className="w-16 md:w-24 h-16 md:h-24 rounded-full border-2 md:border-4 border-white drop-shadow-md md:drop-shadow-xl">
{user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full object-cover" />}
{user.profilePicture.length === 0 && (
<Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" />
)}
</div>
<div className="flex flex-col justify-center">
<span className="text-neutral-600 font-bold text-xl lg:text-2xl">{user.name}</span>
<LevelLabel experience={user.experience} />
</div>
</div>
<LevelProgressBar experience={user.experience} progressBarWidth="w-32 md:w-96" />
</div>
);
}

View File

@@ -1,31 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import {User} from "@/interfaces/user";
import {levelCalculator} from "@/resources/level";
import clsx from "clsx";
import LevelLabel from "./LevelLabel";
import LevelProgressBar from "./LevelProgressBar";
import {Avatar} from "primereact/avatar";
interface Props {
user: User;
className?: string;
}
export default function ProfileLevel({user, className}: Props) {
const levelResult = levelCalculator(user.experience);
return (
<div className={clsx("flex flex-col items-center justify-center gap-4", className)}>
<div className="w-16 md:w-24 h-16 md:h-24 rounded-full">
{user.profilePicture.length > 0 && <img src={user.profilePicture} alt="Profile picture" className="rounded-full object-cover" />}
{user.profilePicture.length === 0 && (
<Avatar size="xlarge" style={{width: "100%", height: "100%"}} label={user.name.slice(0, 1)} shape="circle" />
)}
</div>
<div className="flex flex-col gap-1 items-center">
<LevelLabel experience={user.experience} />
<LevelProgressBar experience={user.experience} className="text-black" />
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
/* eslint-disable @next/next/no-img-element */
import {User} from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user";
import {calculateAverageLevel} from "@/utils/score";
import {capitalize} from "lodash";
import {ReactElement} from "react";
@@ -28,7 +29,7 @@ export default function ProfileSummary({user, items}: Props) {
<div className="flex -md:flex-col justify-between w-full gap-8">
<div className="flex flex-col gap-2 py-2">
<h1 className="font-bold text-2xl md:text-4xl">{user.name}</h1>
<h6 className="font-normal text-base text-mti-gray-taupe">{capitalize(user.type)}</h6>
<h6 className="font-normal text-base text-mti-gray-taupe">{USER_TYPE_LABELS[user.type]}</h6>
</div>
<ProgressBar
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}

View File

@@ -1,7 +1,17 @@
import clsx from "clsx";
import {IconType} from "react-icons";
import {MdSpaceDashboard} from "react-icons/md";
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp, BsChevronBarRight, BsChevronBarLeft, BsShieldFill} from "react-icons/bs";
import {
BsFileEarmarkText,
BsClockHistory,
BsPencil,
BsGraphUp,
BsChevronBarRight,
BsChevronBarLeft,
BsShieldFill,
BsCloudFill,
BsCurrencyDollar,
} from "react-icons/bs";
import {RiLogoutBoxFill} from "react-icons/ri";
import {SlPencil} from "react-icons/sl";
import {FaAward} from "react-icons/fa";
@@ -12,13 +22,14 @@ import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled";
import {useState} from "react";
import usePreferencesStore from "@/stores/preferencesStore";
import {Type} from "@/interfaces/user";
interface Props {
path: string;
navDisabled?: boolean;
focusMode?: boolean;
onFocusLayerMouseEnter?: () => void;
className?: string;
showAdmin?: boolean;
userType?: Type;
}
interface NavProps {
@@ -44,7 +55,7 @@ const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false}
</Link>
);
export default function Sidebar({path, navDisabled = false, focusMode = false, showAdmin = false, onFocusLayerMouseEnter, className}: Props) {
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className}: Props) {
const router = useRouter();
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
@@ -66,12 +77,57 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, s
)}>
<div className="xl:flex -xl:hidden flex-col gap-3">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
{(userType === "student" || userType === "teacher" || userType === "developer") && (
<>
<Nav
disabled={disableNavigation}
Icon={BsFileEarmarkText}
label="Exams"
path={path}
keyPath="/exam"
isMinimized={isMinimized}
/>
<Nav
disabled={disableNavigation}
Icon={BsPencil}
label="Exercises"
path={path}
keyPath="/exercises"
isMinimized={isMinimized}
/>
</>
)}
<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} />
{showAdmin && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Admin" path={path} keyPath="/admin" isMinimized={isMinimized} />
{["admin", "developer", "agent", "corporate"].includes(userType || "") && (
<Nav
disabled={disableNavigation}
Icon={BsCurrencyDollar}
label="Payment Record"
path={path}
keyPath="/payment-record"
isMinimized={isMinimized}
/>
)}
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized={isMinimized}
/>
)}
{userType === "developer" && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={isMinimized}
/>
)}
</div>
<div className="xl:hidden -xl:flex flex-col gap-3">
@@ -80,10 +136,15 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, s
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
{showAdmin && <Nav disabled={disableNavigation} Icon={BsShieldFill} label="Admin" path={path} keyPath="/admin" isMinimized={true} />}
{userType !== "student" && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
)}
{userType === "developer" && (
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
)}
</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

@@ -28,9 +28,9 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
return (
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-red transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-red-light",
"rounded-full hover:text-white hover:bg-mti-gray-davy transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-gray-davy",
)}>
{solution.solution}
{solution?.solution}
</button>
);
}
@@ -99,7 +99,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
Correct
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-red" />
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
Unanswered
</div>
<div className="flex gap-2 items-center">

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

@@ -50,7 +50,7 @@ export default function MatchSentencesSolutions({
className={clsx(
"w-8 h-8 rounded-full z-10 text-white",
"transition duration-300 ease-in-out",
!userSolutions.find((x) => x.question.toString() === id.toString()) && "!bg-mti-red",
!userSolutions.find((x) => x.question.toString() === id.toString()) && "!bg-mti-gray-davy",
userSolutions.find((x) => x.question.toString() === id.toString())?.option === solution && "bg-mti-purple",
userSolutions.find((x) => x.question.toString() === id.toString())?.option !== solution && "bg-mti-rose",
)}>
@@ -96,7 +96,7 @@ export default function MatchSentencesSolutions({
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-red" /> Unanswered
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" /> Unanswered
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-rose" /> Wrong

View File

@@ -1,7 +1,7 @@
/* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import clsx from "clsx";
import {useState} from "react";
import {useEffect, useState} from "react";
import {CommonProps} from ".";
import Button from "../Low/Button";
@@ -14,7 +14,7 @@ function Question({
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const optionColor = (option: string) => {
if (option === solution && !userSolution) {
return "!border-mti-red-light !text-mti-red-light";
return "!border-mti-gray-davy !text-mti-gray-davy";
}
if (option === solution) {
@@ -54,7 +54,16 @@ function Question({
);
}
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
export default function MultipleChoice({
id,
type,
prompt,
questions,
userSolutions,
updateIndex,
onNext,
onBack,
}: MultipleChoiceExercise & CommonProps) {
const [questionIndex, setQuestionIndex] = useState(0);
const calculateScore = () => {
@@ -67,6 +76,10 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
return {total, correct, missing};
};
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
const next = () => {
if (questionIndex === questions.length - 1) {
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
@@ -101,7 +114,7 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
Correct
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-red" />
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
Unanswered
</div>
<div className="flex gap-2 items-center">

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

@@ -33,7 +33,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
return "rose";
}
return "red";
return "gray";
};
return (
@@ -67,6 +67,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
{userSolutions &&
questions.map((question, index) => {
const userSolution = userSolutions.find((x) => x.id === question.id.toString());
const solution = question.solution.toString().toLowerCase() as Solution;
return (
<div key={question.id.toString()} className="flex flex-col gap-4">
@@ -75,23 +76,23 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
</span>
<div className="flex gap-4">
<Button
variant={question.solution === "true" || userSolution?.solution === "true" ? "solid" : "outline"}
variant={solution === "true" || userSolution?.solution.toLowerCase() === "true" ? "solid" : "outline"}
className="!py-2"
color={getButtonColor("true", question.solution, userSolution?.solution)}>
color={getButtonColor("true", solution, userSolution?.solution.toLowerCase() as Solution)}>
True
</Button>
<Button
variant={question.solution === "false" || userSolution?.solution === "false" ? "solid" : "outline"}
variant={solution === "false" || userSolution?.solution.toLowerCase() === "false" ? "solid" : "outline"}
className="!py-2"
color={getButtonColor("false", question.solution, userSolution?.solution)}>
color={getButtonColor("false", solution, userSolution?.solution.toLowerCase() as Solution)}>
False
</Button>
<Button
variant={
question.solution === "not_given" || userSolution?.solution === "not_given" ? "solid" : "outline"
solution === "not_given" || userSolution?.solution.toLowerCase() === "not_given" ? "solid" : "outline"
}
className="!py-2"
color={getButtonColor("not_given", question.solution, userSolution?.solution)}>
color={getButtonColor("not_given", solution, userSolution?.solution.toLowerCase() as Solution)}>
Not Given
</Button>
</div>
@@ -105,7 +106,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
Correct
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-red" />
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
Unanswered
</div>
<div className="flex gap-2 items-center">

View File

@@ -38,7 +38,7 @@ function Blank({
const getSolutionStyling = () => {
if (!userSolution) {
return "bg-mti-red-ultralight text-mti-red-light";
return "bg-mti-gray-davy text-white";
}
return "bg-mti-purple-ultralight text-mti-purple-light";
@@ -131,7 +131,7 @@ export default function WriteBlanksSolutions({
Correct
</div>
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-red" />
<div className="w-4 h-4 rounded-full bg-mti-gray-davy" />
Unanswered
</div>
<div className="flex gap-2 items-center">

View File

@@ -1,19 +1,36 @@
/* eslint-disable @next/next/no-img-element */
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {WritingExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify";
import Button from "../Low/Button";
import {Dialog, Transition} from "@headlessui/react";
import {Dialog, Tab, Transition} from "@headlessui/react";
import {writingReverseMarking} from "@/utils/score";
import clsx from "clsx";
import reactStringReplace from "react-string-replace";
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const formatSolution = (solution: string, errors: {correction: string | null; misspelled: string}[]) => {
const errorRegex = new RegExp(errors.map((x) => `(${x.misspelled})`).join("|"));
return (
<>
{reactStringReplace(solution, errorRegex, (match) => {
const correction = errors.find((x) => x.misspelled === match)?.correction;
return (
<span
data-tip={correction ? correction : undefined}
className={clsx("text-mti-red-light font-medium underline underline-offset-2", correction && "tooltip")}>
{match}
</span>
);
})}
</>
);
};
return (
<>
{attachment && (
@@ -68,18 +85,20 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
</div>
<div className="w-full h-full flex flex-col gap-8">
{userSolutions && (
{userSolutions && userSolutions.length > 0 && (
<div className="flex flex-col gap-4 w-full">
<span>Your answer:</span>
<textarea
className="w-full h-full min-h-[320px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
contentEditable={false}
readOnly
value={userSolutions[0]!.solution}
/>
<div className="w-full h-full min-h-[320px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl whitespace-pre-wrap">
{userSolutions[0]!.evaluation && userSolutions[0]!.evaluation.misspelled_pairs
? formatSolution(
userSolutions[0]!.solution.replaceAll("\\n", "\n"),
userSolutions[0]!.evaluation.misspelled_pairs,
)
: userSolutions[0]!.solution.replaceAll("\\n", "\n")}
</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) => (
@@ -88,9 +107,48 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
</div>
))}
</div>
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
{userSolutions[0].evaluation!.comment}
</div>
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
)
}>
Evaluation
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
)
}>
Recommended Answer
</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
</span>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
) : (
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-ielts-writing/10 rounded-3xl">
{userSolutions[0].evaluation!.comment}
</div>
)}
</div>
)}
</div>

View File

@@ -22,11 +22,12 @@ import Writing from "./Writing";
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
export interface CommonProps {
updateIndex?: (internalIndex: number) => void;
onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void;
}
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void) => {
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void) => {
switch (exercise.type) {
case "fillBlanks":
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
@@ -35,7 +36,7 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: (
case "matchSentences":
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
case "multipleChoice":
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} updateIndex={updateIndex} onNext={onNext} onBack={onBack} />;
case "writeBlanks":
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "writing":

View File

@@ -6,7 +6,7 @@ import axios from "axios";
import clsx from "clsx";
import moment from "moment";
import {Divider} from "primereact/divider";
import {useState} from "react";
import {useEffect, useState} from "react";
import ReactDatePicker from "react-datepicker";
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
import {toast} from "react-toastify";
@@ -15,6 +15,10 @@ import Checkbox from "./Low/Checkbox";
import CountrySelect from "./Low/CountrySelect";
import Input from "./Low/Input";
import ProfileSummary from "./ProfileSummary";
import Select from "react-select";
import useUsers from "@/hooks/useUsers";
import {USER_TYPE_LABELS} from "@/resources/user";
import {CURRENCIES} from "@/resources/paypal";
const expirationDateColor = (date: Date) => {
const momentDate = moment(date);
@@ -27,20 +31,110 @@ const expirationDateColor = (date: Date) => {
interface Props {
user: User;
loggedInUser: User;
onClose: (reload?: boolean) => void;
onViewStudents?: () => void;
onViewTeachers?: () => void;
onViewCorporate?: () => void;
disabled?: boolean;
}
const UserCard = ({user, onClose, onViewStudents, onViewTeachers}: Props) => {
const USER_STATUS_OPTIONS = [
{
value: "active",
label: "Active",
},
{
value: "disabled",
label: "Disabled",
},
{
value: "paymentDue",
label: "Payment Due",
},
];
const USER_TYPE_OPTIONS = Object.keys(USER_TYPE_LABELS).map((type) => ({
value: type,
label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS],
}));
const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({value: currency, label}));
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false}: Props) => {
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
const [type, setType] = useState(user.type);
const [status, setStatus] = useState(user.status);
const [referralAgentLabel, setReferralAgentLabel] = useState<string>();
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
const [referralAgent, setReferralAgent] = useState(user.type === "corporate" ? user.corporateInformation?.referralAgent : undefined);
const [companyName, setCompanyName] = useState(
user.type === "corporate"
? user.corporateInformation?.companyInformation.name
: user.type === "agent"
? user.agentInformation?.companyName
: undefined,
);
const [commercialRegistration, setCommercialRegistration] = useState(
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
);
const [userAmount, setUserAmount] = useState(user.type === "corporate" ? user.corporateInformation?.companyInformation.userAmount : undefined);
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : "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();
useEffect(() => {
if (users && users.length > 0) {
if (!referralAgent) {
setReferralAgentLabel("No manager");
return;
}
const agent = users.find((x) => x.id === referralAgent);
setReferralAgentLabel(`${agent?.name} - ${agent?.email}`);
}
}, [users, referralAgent]);
const updateUser = () => {
if (user.type === "corporate" && (!paymentValue || paymentValue < 0))
return toast.error("Please set a price for the user's package before updating!");
if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
axios
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {...user, subscriptionExpirationDate: expiryDate})
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
...user,
subscriptionExpirationDate: expiryDate,
type,
status,
agentInformation:
type === "agent"
? {
name: companyName,
commercialRegistration,
}
: undefined,
corporateInformation:
type === "corporate"
? {
referralAgent,
monthlyDuration,
companyInformation: {
name: companyName,
userAmount,
},
payment: {
value: paymentValue,
currency: paymentCurrency,
...(referralAgent === "" ? {} : {commission: commissionValue}),
},
}
: undefined,
})
.then(() => {
toast.success("User updated successfully!");
onClose(true);
@@ -73,6 +167,164 @@ const UserCard = ({user, onClose, onViewStudents, onViewTeachers}: Props) => {
]}
/>
{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={setCompanyName}
placeholder="Enter corporate name"
defaultValue={companyName}
required
disabled={disabled}
/>
<Input
label="Commercial Registration"
type="text"
name="commercialRegistration"
onChange={setCommercialRegistration}
placeholder="Enter commercial registration"
defaultValue={commercialRegistration}
required
disabled={disabled}
/>
</div>
<Divider className="w-full !m-0" />
</>
)}
{user.type === "corporate" && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
<Input
label="Corporate Name"
type="text"
name="companyName"
onChange={setCompanyName}
placeholder="Enter corporate name"
defaultValue={companyName}
disabled={disabled}
/>
<Input
label="Number of Users"
type="number"
name="userAmount"
onChange={(e) => setUserAmount(e ? parseInt(e) : undefined)}
placeholder="Enter number of users"
defaultValue={userAmount}
disabled={disabled}
/>
<Input
label="Monthly Duration"
type="number"
name="monthlyDuration"
onChange={(e) => setMonthlyDuration(e ? parseInt(e) : undefined)}
placeholder="Enter monthly duration"
defaultValue={monthlyDuration}
disabled={disabled}
/>
<div className="flex flex-col gap-3 w-full lg:col-span-2">
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
<div className="w-full grid grid-cols-5 gap-2">
<Input
name="paymentValue"
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
type="number"
defaultValue={paymentValue || 0}
className="col-span-3"
disabled={disabled}
/>
<Select
className={clsx(
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
)}
options={CURRENCIES_OPTIONS}
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
onChange={(value) => setPaymentCurrency(value?.value)}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
isDisabled={disabled}
/>
</div>
</div>
</div>
<div className="flex gap-3 w-full">
<div className="flex flex-col gap-3 w-8/12">
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
{referralAgentLabel && (
<Select
className={clsx(
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
!["developer", "admin"].includes(loggedInUser.type) &&
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
)}
options={[
{value: "", label: "No referral"},
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
]}
defaultValue={{
value: referralAgent,
label: referralAgentLabel,
}}
onChange={(value) => setReferralAgent(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,
}),
}}
// editing country manager should only be available for dev/admin
isDisabled={!["developer", "admin"].includes(loggedInUser.type)}
/>
)}
</div>
<div className="flex flex-col gap-3 w-4/12">
{referralAgent !== "" && loggedInUser.type !== "corporate" ? (
<>
<label className="font-normal text-base text-mti-gray-dim">Commission</label>
<Input
name="commissionValue"
onChange={(e) => setCommission(e ? parseInt(e) : undefined)}
type="number"
defaultValue={commissionValue || 0}
className="col-span-3"
disabled={disabled}
/>
</>
) : (
<div />
)}
</div>
</div>
<Divider className="w-full !m-0" />
</>
)}
<section className="flex flex-col gap-4 justify-between">
<div className="flex flex-col md:flex-row gap-8 w-full">
<Input
@@ -111,34 +363,65 @@ const UserCard = ({user, onClose, onViewStudents, onViewTeachers}: Props) => {
/>
</div>
{user.type === "student" && (
<Input
type="text"
name="passport_id"
label="Passport/National ID"
onChange={() => null}
placeholder="Enter National ID or Passport number"
value={user.type === "student" ? user.demographicInformation?.passport_id : undefined}
disabled
required
/>
)}
<div className="flex flex-col md:flex-row gap-8 w-full">
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
<RadioGroup
value={user.demographicInformation?.employment}
className="grid grid-cols-2 items-center gap-4 place-items-center">
{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>
{user.type !== "corporate" && (
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
<RadioGroup
value={user.demographicInformation?.employment}
className="grid grid-cols-2 items-center gap-4 place-items-center"
disabled={disabled}>
{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>
)}
{user.type === "corporate" && (
<Input
name="position"
onChange={setPosition}
type="text"
label="Position"
defaultValue={position}
placeholder="CEO, Head of Marketing..."
disabled
required
/>
)}
<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
@@ -188,7 +471,8 @@ const UserCard = ({user, onClose, onViewStudents, onViewTeachers}: Props) => {
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
<Checkbox
isChecked={!!expiryDate}
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : undefined)}>
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
disabled={disabled}>
Enabled
</Checkbox>
</div>
@@ -212,37 +496,77 @@ const UserCard = ({user, onClose, onViewStudents, onViewTeachers}: Props) => {
expirationDateColor(expiryDate),
"transition duration-300 ease-in-out",
)}
filterDate={(date) => moment(date).isAfter(new Date())}
filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(loggedInUser.subscriptionExpirationDate
? moment(date).isBefore(moment(loggedInUser.subscriptionExpirationDate))
: true)
}
dateFormat="dd/MM/yyyy"
selected={moment(expiryDate).toDate()}
onChange={(date) => setExpiryDate(date)}
disabled={disabled}
/>
)}
</div>
</div>
</div>
{user.corporateInformation && (
{(loggedInUser.type === "developer" || loggedInUser.type === "admin") && (
<>
<Divider className="w-full" />
<Divider className="w-full !m-0" />
<div className="flex flex-col md:flex-row gap-8 w-full">
<Input
label="Company Name"
type="text"
name="companyName"
onChange={() => null}
placeholder="Enter company name"
defaultValue={user.corporateInformation.companyInformation.name}
disabled
/>
<Input
label="Amount of Users"
type="number"
name="userAmount"
onChange={() => null}
placeholder="Enter amount of users"
defaultValue={user.corporateInformation.companyInformation.userAmount}
disabled
/>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Status</label>
<Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={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
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>
</>
)}
@@ -250,6 +574,11 @@ const UserCard = ({user, onClose, onViewStudents, onViewTeachers}: Props) => {
<div className="flex gap-4 justify-between mt-4 w-full">
<div className="self-start flex gap-4 justify-start items-center w-full">
{onViewCorporate && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}>
View Corporate
</Button>
)}
{onViewStudents && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
View Students
@@ -265,7 +594,7 @@ const UserCard = ({user, onClose, onViewStudents, onViewTeachers}: Props) => {
<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

@@ -1,31 +0,0 @@
import {SEMI_TRANSPARENT} from "@/resources/colors";
import {Chart as ChartJS, RadialLinearScale, ArcElement, Tooltip, Legend} from "chart.js";
import clsx from "clsx";
import {PolarArea} from "react-chartjs-2";
import {Chart} from "primereact/chart";
interface Props {
data: {label: string; value: number}[];
label?: string;
title: string;
type: string;
colors?: string[];
}
ChartJS.register(RadialLinearScale, ArcElement, Tooltip, Legend);
export default function SingleDatasetChart({data, type, label, title, colors = Object.values(SEMI_TRANSPARENT)}: Props) {
const labels = data.map((x) => x.label);
const chartData = {
labels,
datasets: [
{
label,
data: data.map((x) => x.value),
backgroundColor: colors,
},
],
};
return <Chart type={type} data={chartData} options={{plugins: {title: {text: title, display: true}}}} />;
}

View File

@@ -2,35 +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],
// 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 type LevelScore = "Advanced" | "Upper-Intermediate" | "Intermediate" | "Pre-Intermediate" | "Elementary" | "Beginner";
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.
</>
);
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 LEVEL_TEXT = {
excellent:
"Congratulations on your exam performance! You achieved an impressive {{level}}, demonstrating excellent mastery of the assessed knowledge.\n\nIf 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.\n\nPlease contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your academic journey.",
high: "Congratulations on your exam performance! You achieved a commendable {{level}}, demonstrating a good understanding of the assessed knowledge.\n\nIf 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.\n\nPlease contact us for further information. Congratulations again on your achievement! We are here to support you on your academic journey.",
medium: "Congratulations on your exam performance! You achieved a {{level}}, demonstrating a satisfactory understanding of the assessed knowledge.\n\nIf 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.\n\nPlease contact us for further information. Congratulations again on your achievement! We are here to support you on your academic journey.",
low: "Thank you for taking the exam. You achieved a {{level}}, but unfortunately, it did not meet the required standards.\n\nIf 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.\n\nPlease 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 levelText = (level: number) => {
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()}
</>
);
}
@@ -40,14 +124,7 @@ export const levelText = (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()}
</>
);
}
@@ -57,14 +134,7 @@ export const levelText = (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()}
</>
);
}
@@ -73,14 +143,7 @@ export const levelText = (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

@@ -2,38 +2,38 @@ import {Type} from "@/interfaces/user";
export const PERMISSIONS = {
generateCode: {
student: ["teacher", "corporate", "developer", "owner"],
teacher: ["corporate", "developer", "owner"],
corporate: ["owner", "developer"],
owner: ["developer", "owner"],
agent: ["developer", "owner"],
student: ["corporate", "developer", "admin"],
teacher: ["corporate", "developer", "admin"],
corporate: ["admin", "developer"],
admin: ["developer", "admin"],
agent: ["developer", "admin"],
developer: ["developer"],
},
deleteUser: {
student: ["teacher", "corporate", "developer", "owner"],
teacher: ["corporate", "developer", "owner"],
corporate: ["owner", "developer"],
owner: ["developer", "owner"],
agent: ["developer", "owner"],
student: ["teacher", "corporate", "developer", "admin"],
teacher: ["corporate", "developer", "admin"],
corporate: ["admin", "developer"],
admin: ["developer", "admin"],
agent: ["developer", "admin"],
developer: ["developer"],
},
updateUser: {
student: ["teacher", "corporate", "developer", "owner"],
teacher: ["corporate", "developer", "owner"],
corporate: ["owner", "developer"],
owner: ["developer", "owner"],
agent: ["developer", "owner"],
student: ["teacher", "corporate", "developer", "admin"],
teacher: ["corporate", "developer", "admin"],
corporate: ["admin", "developer"],
admin: ["developer", "admin"],
agent: ["developer", "admin"],
developer: ["developer"],
},
updateExpiryDate: {
student: ["developer", "owner"],
teacher: ["developer", "owner"],
corporate: ["owner", "developer"],
owner: ["developer", "owner"],
agent: ["developer", "owner"],
student: ["developer", "admin"],
teacher: ["developer", "admin"],
corporate: ["admin", "developer"],
admin: ["developer", "admin"],
agent: ["developer", "admin"],
developer: ["developer"],
},
examManagement: {
delete: ["developer", "owner"],
delete: ["developer", "admin"],
},
};

View File

@@ -7,16 +7,19 @@ import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
import {useEffect, useState} from "react";
import {BsArrowLeft, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPersonFillGear, BsPersonGear, BsPersonLinesFill} from "react-icons/bs";
import {BsArrowLeft, BsBriefcaseFill, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPencilSquare, BsBank, BsCurrencyDollar} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import IconCard from "./IconCard";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
interface Props {
user: User;
}
export default function OwnerDashboard({user}: Props) {
export default function AdminDashboard({user}: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
@@ -24,18 +27,32 @@ export default function OwnerDashboard({user}: Props) {
const {stats} = useStats(user.id);
const {users, reload} = useUsers();
const {groups} = useGroups();
const { pending, done } = usePaymentStatusUsers();
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
useEffect(() => {
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)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span>
{displayUser.type === "corporate"
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
: displayUser.name}
</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
@@ -48,7 +65,7 @@ export default function OwnerDashboard({user}: Props) {
? groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id) || false
.includes(x.id)
: true);
return (
@@ -63,7 +80,7 @@ export default function OwnerDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filter={filter} />
<UserList user={user} filters={[filter]} />
</>
);
};
@@ -90,7 +107,27 @@ export default function OwnerDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filter={filter} />
<UserList user={user} filters={[filter]} />
</>
);
};
const AgentsList = () => {
const filter = (x: User) => x.type === "agent";
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">Country Managers ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
);
};
@@ -107,10 +144,48 @@ export default function OwnerDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Corporate ({users.filter((x) => x.type === "corporate").length})</h2>
</div>
<UserList user={user} filter={(x) => x.type === "corporate"} />
<UserList user={user} filters={[(x) => x.type === "corporate"]} />
</>
);
const CorporatePaidStatusList = ({ paid }: {paid: Boolean}) => {
const list = paid ? done : pending;
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
);
};
const InactiveCountryManagerList = () => {
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Inactive Country Managers ({users.filter(inactiveCountryManagerFilter).length})</h2>
</div>
<UserList user={user} filters={[inactiveCountryManagerFilter]} />
</>
);
};
const InactiveStudentsList = () => {
const filter = (x: User) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
@@ -126,7 +201,7 @@ export default function OwnerDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Inactive Students ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filter={filter} />
<UserList user={user} filters={[filter]} />
</>
);
};
@@ -146,14 +221,14 @@ export default function OwnerDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filter={filter} />
<UserList user={user} filters={[filter]} />
</>
);
};
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"
@@ -162,19 +237,26 @@ export default function OwnerDashboard({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")}
color="purple"
/>
<IconCard
Icon={BsBriefcaseFill}
label="Country Managers"
value={users.filter((x) => x.type === "agent").length}
onClick={() => setPage("agents")}
color="purple"
/>
<IconCard
Icon={BsGlobeCentralSouthAsia}
label="Countries"
@@ -183,7 +265,7 @@ export default function OwnerDashboard({user}: Props) {
/>
<IconCard
onClick={() => setPage("inactiveStudents")}
Icon={BsPerson}
Icon={BsPersonFill}
label="Inactive Students"
value={
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
@@ -191,9 +273,16 @@ export default function OwnerDashboard({user}: Props) {
}
color="rose"
/>
<IconCard
onClick={() => setPage("inactiveCountryManagers")}
Icon={BsBriefcaseFill}
label="Inactive Country Managers"
value={users.filter(inactiveCountryManagerFilter).length}
color="rose"
/>
<IconCard
onClick={() => setPage("inactiveCorporate")}
Icon={BsPerson}
Icon={BsBank}
label="Inactive Corporate"
value={
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
@@ -201,6 +290,20 @@ export default function OwnerDashboard({user}: Props) {
}
color="rose"
/>
<IconCard
onClick={() => setPage("paymentdone")}
Icon={BsCurrencyDollar}
label="Payment Done"
value={done.length}
color="purple"
/>
<IconCard
onClick={() => setPage("paymentpending")}
Icon={BsCurrencyDollar}
label="Pending Payment"
value={pending.length}
color="rose"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
@@ -244,7 +347,8 @@ export default function OwnerDashboard({user}: Props) {
(x) =>
x.type === "student" &&
x.subscriptionExpirationDate &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")),
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
@@ -252,14 +356,15 @@ export default function OwnerDashboard({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(
(x) =>
x.type === "teacher" &&
x.subscriptionExpirationDate &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")),
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
@@ -274,7 +379,45 @@ export default function OwnerDashboard({user}: Props) {
(x) =>
x.type === "corporate" &&
x.subscriptionExpirationDate &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")),
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) => x.type === "student" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Country Manager</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) => x.type === "teacher" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
x.type === "corporate" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
@@ -292,14 +435,71 @@ export default function OwnerDashboard({user}: Props) {
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
selectedUser.type === "corporate" || selectedUser.type === "teacher"
? () => {
appendUserFilters({
id: "view-students",
filter: (x: User) => x.type === "student",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewTeachers={
selectedUser.type === "corporate" || selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-teachers",
filter: (x: User) => x.type === "teacher",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewCorporate={
selectedUser.type === "teacher" || selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-corporate",
filter: (x: User) => x.type === "corporate",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.participants.includes(selectedUser.id))
.flatMap((g) => [g.admin, ...g.participants])
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
user={selectedUser}
/>
</div>
@@ -309,8 +509,12 @@ export default function OwnerDashboard({user}: Props) {
{page === "students" && <StudentsList />}
{page === "teachers" && <TeachersList />}
{page === "corporate" && <CorporateList />}
{page === "agents" && <AgentsList />}
{page === "inactiveStudents" && <InactiveStudentsList />}
{page === "inactiveCorporate" && <InactiveCorporateList />}
{page === "inactiveCountryManagers" && <InactiveCountryManagerList />}
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
{page === "" && <DefaultDashboard />}
</>
);

244
src/dashboards/Agent.tsx Normal file
View File

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

View File

@@ -2,19 +2,20 @@ import ProgressBar from "@/components/Low/ProgressBar";
import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces";
import {Assignment} from "@/interfaces/results";
import {Stat} from "@/interfaces/user";
import {calculateBandScore} from "@/utils/score";
import clsx from "clsx";
import moment from "moment";
import {useState} from "react";
import {BsBook, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import { usePDFDownload } from "@/hooks/usePDFDownload";
interface Props {
onClick?: () => void;
allowDownload?: boolean;
}
export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick}: Assignment & Props) {
export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick, allowDownload}: Assignment & Props) {
const {users} = useUsers();
const renderPdfIcon = usePDFDownload("assignments");
const calculateAverageModuleScore = (module: Module) => {
const resultModuleBandScores = results.map((r) => {
@@ -33,7 +34,10 @@ export default function AssignmentCard({id, name, assigner, startDate, endDate,
onClick={onClick}
className="w-[350px] h-fit flex flex-col gap-6 bg-white border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
<div className="flex flex-col gap-3">
<h3 className="font-semibold text-xl">{name}</h3>
<div className="flex flex-row justify-between">
<h3 className="font-semibold text-xl">{name}</h3>
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
</div>
<ProgressBar
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
percentage={(results.length / assignees.length) * 100}
@@ -57,11 +61,13 @@ export default function AssignmentCard({id, name, assigner, startDate, endDate,
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" />}
{calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
)}

View File

@@ -3,7 +3,7 @@ import Modal from "@/components/Modal";
import {Module} from "@/interfaces";
import clsx from "clsx";
import {useState} from "react";
import {BsBook, BsCheckCircle, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {generate} from "random-words";
import {capitalize} from "lodash";
import useUsers from "@/hooks/useUsers";
@@ -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) {
@@ -99,59 +99,96 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
return (
<Modal isOpen={isCreating} onClose={cancelCreation} title="New Assignment">
<div className="w-full flex flex-col gap-4">
<section className="w-full grid -md:grid-cols-1 md:grid-cols-2 place-items-center lg:grid-cols-4 -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
<section className="w-full grid -md:grid-cols-1 md:grid-cols-2 place-items-center lg:grid-cols-6 -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
<div
onClick={() => toggleModule("reading")}
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsBook className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Reading</span>
{!selectedModules.includes("reading") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />}
{!selectedModules.includes("reading") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={() => toggleModule("listening")}
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsHeadphones className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Listening</span>
{!selectedModules.includes("listening") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />}
{!selectedModules.includes("listening") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={() => toggleModule("writing")}
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
className={clsx(
"w-52 relative max-w-xs flex lg:flex-row-reverse items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 -lg:left-0 -lg:-translate-x-1/2 lg:right-0 lg:translate-x-1/2">
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsPen className="text-white w-7 h-7" />
</div>
<span className="lg:mr-8 -lg:ml-8 font-semibold">Writing</span>
{!selectedModules.includes("writing") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />}
<span className="ml-8 font-semibold">Writing</span>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={() => toggleModule("speaking")}
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
className={clsx(
"w-52 relative max-w-xs flex lg:flex-row-reverse items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-3",
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 -lg:left-0 -lg:-translate-x-1/2 lg:right-0 lg:translate-x-1/2">
<BsMegaphone className="text-white w-7 h-7 lg:-scale-x-100" />
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsMegaphone className="text-white w-7 h-7" />
</div>
<span className="lg:mr-8 -lg:ml-8 font-semibold">Speaking</span>
{!selectedModules.includes("speaking") && <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />}
<span className="ml-8 font-semibold">Speaking</span>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
? () => toggleModule("level")
: undefined
}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-3",
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsClipboard className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Level</span>
{!selectedModules.includes("level") && selectedModules.length === 0 && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
</section>
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required />
@@ -247,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

@@ -10,10 +10,10 @@ import {sortByModule} from "@/utils/moduleUtils";
import {calculateBandScore} from "@/utils/score";
import {convertToUserSolutions} from "@/utils/stats";
import clsx from "clsx";
import {uniqBy} from "lodash";
import {capitalize, uniqBy} from "lodash";
import moment from "moment";
import {useRouter} from "next/router";
import {BsBook, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
interface Props {
isOpen: boolean;
@@ -73,6 +73,11 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
correct: 0,
missing: 0,
},
level: {
total: 0,
correct: 0,
missing: 0,
},
};
stats.forEach((x) => {
@@ -153,11 +158,13 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
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">{level.toFixed(1)}</span>
</div>
))}
@@ -232,18 +239,21 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
{assignment?.exams.map(({module}) => (
<div
data-tip={capitalize(module)}
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",
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl tooltip",
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" />}
{calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
)}

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";
@@ -29,6 +29,8 @@ import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats";
import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
interface Props {
user: User;
@@ -43,6 +45,9 @@ export default function CorporateDashboard({user}: Props) {
const {users, reload} = useUsers();
const {groups} = useGroups(user.id);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
useEffect(() => {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
@@ -86,7 +91,7 @@ export default function CorporateDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filter={filter} />
<UserList user={user} filters={[filter]} />
</>
);
};
@@ -113,7 +118,7 @@ export default function CorporateDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filter={filter} />
<UserList user={user} filters={[filter]} />
</>
);
};
@@ -147,7 +152,7 @@ export default function CorporateDashboard({user}: Props) {
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0};
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
@@ -165,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"
@@ -250,14 +255,51 @@ export default function CorporateDashboard({user}: Props) {
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
selectedUser.type === "corporate" || selectedUser.type === "teacher"
? () => {
appendUserFilters({
id: "view-students",
filter: (x: User) => x.type === "student",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewTeachers={
selectedUser.type === "corporate" || selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-teachers",
filter: (x: User) => x.type === "teacher",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
user={selectedUser}
/>
</div>

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

@@ -1,26 +1,35 @@
import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar";
import PayPalPayment from "@/components/PayPalPayment";
import ProfileSummary from "@/components/ProfileSummary";
import useAssignments from "@/hooks/useAssignments";
import useStats from "@/hooks/useStats";
import {Assignment} from "@/interfaces/results";
import {User} from "@/interfaces/user";
import {CorporateUser, User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams";
import {MODULE_ARRAY, sortByModule} from "@/utils/moduleUtils";
import {getUserCorporate} from "@/utils/groups";
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
import {averageScore, groupBySession} from "@/utils/stats";
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
import {PayPalButtons} from "@paypal/react-paypal-js";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import moment from "moment";
import Link from "next/link";
import {useRouter} from "next/router";
import {BsArrowRepeat, BsBook, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
import {useEffect, useState} from "react";
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
import {toast} from "react-toastify";
interface Props {
user: User;
}
export default function StudentDashboard({user}: Props) {
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
const {stats} = useStats(user.id);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
@@ -32,8 +41,12 @@ export default function StudentDashboard({user}: Props) {
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setAssignment = useExamStore((state) => state.setAssignment);
useEffect(() => {
getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]);
const startAssignment = (assignment: Assignment) => {
const examPromises = assignment.exams.map((e) => getExamById(e.module, e.id));
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
@@ -55,6 +68,11 @@ export default function StudentDashboard({user}: Props) {
return (
<>
{corporateUserToShow && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
Linked to: <b>{corporateUserToShow?.corporateInformation.companyInformation.name || corporateUserToShow.name}</b>
</div>
)}
<ProfileSummary
user={user}
items={[
@@ -115,34 +133,29 @@ export default function StudentDashboard({user}: Props) {
</div>
<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">
{MODULE_ARRAY.map((module) => (
<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" &&
(assignment.exams.map((e) => e.module).includes("reading")
? "bg-ielts-reading"
: "bg-mti-black/40"),
module === "listening" &&
(assignment.exams.map((e) => e.module).includes("listening")
? "bg-ielts-listening"
: "bg-mti-black/40"),
module === "writing" &&
(assignment.exams.map((e) => e.module).includes("writing")
? "bg-ielts-writing"
: "bg-mti-black/40"),
module === "speaking" &&
(assignment.exams.map((e) => e.module).includes("speaking")
? "bg-ielts-speaking"
: "bg-mti-black/40"),
)}>
{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" />}
</div>
))}
{assignment.exams
.filter((e) => e.assignee === user.id)
.map((e) => e.module)
.sort(sortByModuleName)
.map((module) => (
<div
key={module}
data-tip={capitalize(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 tooltip",
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" />}
</div>
))}
</div>
{!assignment.results.map((r) => r.user).includes(user.id) && (
<>
@@ -191,11 +204,12 @@ export default function StudentDashboard({user}: Props) {
{module === "listening" && <BsHeadphones className="text-ielts-listening w-4 h-4 md:w-5 md:h-5" />}
{module === "writing" && <BsPen className="text-ielts-writing w-4 h-4 md:w-5 md:h-5" />}
{module === "speaking" && <BsMegaphone className="text-ielts-speaking w-4 h-4 md:w-5 md:h-5" />}
{module === "level" && <BsClipboard className="text-ielts-level w-4 h-4 md:w-5 md:h-5" />}
</div>
<div className="flex justify-between w-full">
<span className="font-bold md:font-extrabold text-sm">{capitalize(module)}</span>
<span className="text-sm font-normal text-mti-gray-dim">
Level {user.levels[module]} / Level {user.desiredLevels[module]}
Level {user.levels[module] || 0} / Level {user.desiredLevels[module] || 9}
</span>
</div>
</div>

View File

@@ -2,7 +2,7 @@
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import {Group, Stat, User} from "@/interfaces/user";
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
@@ -24,7 +24,6 @@ import {
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPersonLinesFill,
BsPlus,
BsRepeat,
BsRepeat1,
@@ -45,6 +44,7 @@ import clsx from "clsx";
import ProgressBar from "@/components/Low/ProgressBar";
import AssignmentCreator from "./AssignmentCreator";
import AssignmentView from "./AssignmentView";
import {getUserCorporate} from "@/utils/groups";
interface Props {
user: User;
@@ -56,6 +56,7 @@ export default function TeacherDashboard({user}: Props) {
const [showModal, setShowModal] = useState(false);
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
const {stats} = useStats();
const {users, reload} = useUsers();
@@ -66,6 +67,10 @@ export default function TeacherDashboard({user}: Props) {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
useEffect(() => {
getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]);
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
@@ -104,7 +109,7 @@ export default function TeacherDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filter={filter} />
<UserList user={user} filters={[filter]} />
</>
);
};
@@ -138,7 +143,7 @@ export default function TeacherDashboard({user}: Props) {
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0};
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
@@ -227,7 +232,7 @@ export default function TeacherDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => (
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} />
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload />
))}
</div>
</section>
@@ -237,7 +242,16 @@ export default function TeacherDashboard({user}: Props) {
const DefaultDashboard = () => (
<>
<section className="flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center">
{corporateUserToShow && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
Linked to: <b>{corporateUserToShow?.corporateInformation.companyInformation.name || corporateUserToShow.name}</b>
</div>
)}
<section
className={clsx(
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
!!corporateUserToShow && "mt-12 xl:mt-6",
)}>
<IconCard
onClick={() => setPage("students")}
Icon={BsPersonFill}
@@ -317,6 +331,7 @@ export default function TeacherDashboard({user}: Props) {
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();

View File

@@ -1,6 +1,6 @@
import Button from "@/components/Low/Button";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import {levelText, LEVEL_TEXT} from "@/constants/ielts";
import {moduleResultText} from "@/constants/ielts";
import {Module} from "@/interfaces";
import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
@@ -9,7 +9,9 @@ import clsx from "clsx";
import Link from "next/link";
import {useRouter} from "next/router";
import {Fragment, useEffect, useState} from "react";
import {BsArrowCounterclockwise, BsBook, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
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;
@@ -51,6 +53,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
progress: "text-ielts-speaking",
inner: "bg-ielts-speaking-light",
},
level: {
progress: "text-ielts-level",
inner: "bg-ielts-level-light",
},
};
const getTotalExercises = () => {
@@ -62,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">
@@ -117,18 +139,31 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<span className="font-semibold">Speaking</span>
</div>
)}
{modules.includes("level") && (
<div
onClick={() => setSelectedModule("level")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-level hover:text-white",
selectedModule === "level" ? "bg-ielts-level text-white" : "bg-mti-gray-smoke text-ielts-level",
)}>
<BsClipboard className="w-6 h-6" />
<span className="font-semibold">Level</span>
</div>
)}
</div>
{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">
{levelText(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)}
@@ -141,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">

102
src/exams/Level.tsx Normal file
View File

@@ -0,0 +1,102 @@
import {renderExercise} from "@/components/Exercises";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {LevelExam, UserSolution, WritingExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify";
interface Props {
exam: LevelExam;
showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
}
export default function Level({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1);
}
}, [hasExamEnded, exerciseIndex]);
const nextExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1);
return;
}
if (exerciseIndex >= exam.exercises.length) return;
setHasExamEnded(false);
if (solution) {
onFinish(
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "level", exam: exam.id})),
);
} else {
onFinish(userSolutions.map((x) => ({...x, module: "level", exam: exam.id})));
}
};
const previousExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex > 0) {
setExerciseIndex((prev) => prev - 1);
}
};
const getExercise = () => {
const exercise = exam.exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
};
return (
<>
<div className="flex flex-col h-full w-full gap-8 items-center">
<ModuleTitle
minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
module="level"
totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions}
/>
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div>
</>
);
}

View File

@@ -17,6 +17,8 @@ interface Props {
}
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
const [partIndex, setPartIndex] = useState(0);
const [timesListened, setTimesListened] = useState(0);
@@ -33,6 +35,10 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
}
}, [hasExamEnded, exerciseIndex]);
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) {
setShowBlankModal(false);
@@ -46,6 +52,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1);
@@ -130,7 +137,10 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
.flatMap((x) => x.exercises)
.findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
) || 0) + (exerciseIndex === -1 ? 0 : 1)
) || 0) +
(exerciseIndex === -1 ? 0 : 1) +
questionIndex +
currentQuestionIndex
}
minTimer={exam.minTimer}
module="listening"
@@ -141,11 +151,11 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
{exerciseIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)}
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div>
{exerciseIndex === -1 && partIndex > 0 && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
@@ -165,7 +175,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
</Button>
</div>
)}
{exerciseIndex === -1 && (
{exerciseIndex === -1 && partIndex === 0 && (
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
Start now
</Button>

View File

@@ -81,6 +81,8 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
}
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1);
const [partIndex, setPartIndex] = useState(0);
const [showTextModal, setShowTextModal] = useState(false);
@@ -105,6 +107,10 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
};
}, []);
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1);
@@ -124,6 +130,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1);
@@ -207,7 +214,10 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
.flatMap((x) => x.exercises)
.findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
) || 0) + (exerciseIndex === -1 ? 0 : 1)
) || 0) +
(exerciseIndex === -1 ? 0 : 1) +
questionIndex +
currentQuestionIndex
}
module="reading"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
@@ -219,11 +229,11 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
{exerciseIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)}
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div>
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
<Button

View File

@@ -4,7 +4,7 @@ import {Module} from "@/interfaces";
import clsx from "clsx";
import {User} from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar";
import {BsBook, BsCheck, BsCheckCircle, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import {BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {totalExamsByModule} from "@/utils/stats";
import useStats from "@/hooks/useStats";
import Button from "@/components/Low/Button";
@@ -57,6 +57,11 @@ export default function Selection({user, page, onStart, disableSelection = false
label: "Speaking",
value: totalExamsByModule(stats, "speaking"),
},
{
icon: <BsClipboard className="text-ielts-level w-6 h-6 md:w-8 md:h-8" />,
label: "Level",
value: totalExamsByModule(stats, "level"),
},
]}
/>
)}
@@ -87,11 +92,11 @@ export default function Selection({user, page, onStart, disableSelection = false
)}
</span>
</section>
<section className="w-full flex -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8">
<section className="w-full flex -lg:flex-col -lg:items-center -lg:gap-12 justify-between gap-8 mt-8">
<div
onClick={!disableSelection ? () => toggleModule("reading") : undefined}
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2">
@@ -101,17 +106,18 @@ export default function Selection({user, page, onStart, disableSelection = false
<p className="text-center text-xs">
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
</p>
{!selectedModules.includes("reading") && !disableSelection && (
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("reading") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
</div>
<div
onClick={!disableSelection ? () => toggleModule("listening") : undefined}
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2">
@@ -121,17 +127,18 @@ export default function Selection({user, page, onStart, disableSelection = false
<p className="text-center text-xs">
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
</p>
{!selectedModules.includes("listening") && !disableSelection && (
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("listening") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
</div>
<div
onClick={!disableSelection ? () => toggleModule("writing") : undefined}
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2">
@@ -141,17 +148,18 @@ export default function Selection({user, page, onStart, disableSelection = false
<p className="text-center text-xs">
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
</p>
{!selectedModules.includes("writing") && !disableSelection && (
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("writing") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
</div>
<div
onClick={!disableSelection ? () => toggleModule("speaking") : undefined}
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2">
@@ -161,13 +169,37 @@ export default function Selection({user, page, onStart, disableSelection = false
<p className="text-center text-xs">
You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings.
</p>
{!selectedModules.includes("speaking") && !disableSelection && (
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("speaking") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
</div>
{!disableSelection && (
<div
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
className={clsx(
"relative w-64 max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-0 -translate-y-1/2">
<BsClipboard className="text-white w-7 h-7" />
</div>
<span className="font-semibold">Level:</span>
<p className="text-center text-xs">You&apos;ll be able to test your english level with multiple choice questions.</p>
{!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("level") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)}
{!selectedModules.includes("level") && selectedModules.length > 0 && (
<BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />
)}
</div>
)}
</section>
<div className="flex w-full -md:flex-col -md:gap-4 -md:justify-center md:justify-between items-center">
<div

View File

@@ -20,14 +20,21 @@ interface Props {
}
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
const [questionIndex, setQuestionIndex] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
const nextExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1);
@@ -71,7 +78,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
<ModuleTitle
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1}
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
module="speaking"
totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions}
@@ -79,11 +86,11 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)}
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div>
</>
);

View File

@@ -0,0 +1,169 @@
import React from "react";
import { View, Text, StyleSheet } from "@react-pdf/renderer";
import { ModuleScore } from "@/interfaces/module.scores";
import { styles } from "../styles";
import { RadialResult } from "./radial.result";
interface Props {
detail: ModuleScore;
title: string;
}
const thresholds = [
{
level: "Low A1",
label: "Begginner",
minValue: 0,
maxValue: 3,
},
{
level: "High A1/Low A2",
label: "Elementary",
minValue: 4,
maxValue: 7,
},
{
level: "High A2/Low B1",
label: "Pre-Intermediate",
minValue: 8,
maxValue: 12,
},
{
level: "High B2/Low C1",
label: "Upper-Intermediate",
minValue: 16,
maxValue: 21,
},
{
level: "C1",
label: "Advanced",
minValue: 22,
maxValue: 25,
},
];
const customStyles = StyleSheet.create({
container: {
display: "flex",
flexDirection: "row",
gap: 30,
justifyContent: "space-between",
},
tableContainer: {
display: "flex",
flex: 1,
flexDirection: "column",
},
tableLabel: {
display: "flex",
alignItems: "center",
},
tableBody: { display: "flex", flex: 1, flexDirection: "row" },
tableRow: {
display: "flex",
flexDirection: "column",
},
});
export const LevelExamDetails = ({ detail, title }: Props) => {
const updatedThresholds = thresholds.map((t) => ({
...t,
match: detail.score >= t.minValue && detail.score <= t.maxValue,
}));
const getBackgroundColor = (match: boolean, base: boolean) => {
if (match) return "#c2bfdd";
return base ? "#553b25" : "#ea7c7b";
};
const getTextColor = (match: boolean, base: boolean) => {
if (match) return "#9e7936";
return base ? "white" : "#553b25";
};
return (
<View style={[styles.textFont, customStyles.container]}>
<RadialResult {...detail} />
<View style={customStyles.tableContainer}>
<View style={customStyles.tableLabel}>
<Text
style={[styles.textBold, styles.textColor, { fontSize: "10px" }]}
>
{title}
</Text>
</View>
<View style={customStyles.tableBody}>
{updatedThresholds.map(
({ level, label, minValue, maxValue, match }, index, arr) => (
<View
key={label}
style={[
customStyles.tableRow,
{
width: `calc(100% / ${arr.length})`,
},
]}
>
<View
style={{
backgroundColor: getBackgroundColor(match, true),
paddingVertical: "8px",
alignItems: "center",
}}
>
<Text
style={[
styles.textBold,
{
color: getTextColor(match, true),
fontSize: "6px",
},
]}
>
{level}
</Text>
</View>
<View
style={{
backgroundColor: getBackgroundColor(match, false),
paddingVertical: "8px",
alignItems: "center",
}}
>
<Text
style={[
styles.textBold,
{
color: getTextColor(match, false),
fontSize: "6px",
},
]}
>
{label}
</Text>
</View>
<View
style={{
backgroundColor: getBackgroundColor(match, true),
paddingVertical: "24px",
alignItems: "center",
}}
>
<Text
style={[
styles.textBold,
{
color: getTextColor(match, true),
fontSize: "10px",
},
]}
>
{minValue}-{maxValue}
</Text>
</View>
</View>
)
)}
</View>
</View>
</View>
);
};

View File

@@ -0,0 +1,24 @@
/* eslint-disable jsx-a11y/alt-text */
import React from "react";
import { View, Text, Image } from "@react-pdf/renderer";
import { styles } from "../styles";
import { ModuleScore } from "@/interfaces/module.scores";
export const RadialResult = ({
module,
score,
total,
png,
}: ModuleScore) => (
<View style={[styles.textFont, styles.radialContainer]}>
<Text style={[styles.textColor, styles.textBold, { fontSize: 10 }]}>
{module}
</Text>
<Image src={png} style={styles.image64}></Image>
<View style={[styles.textColor, styles.radialResultContainer]}>
<Text style={styles.textBold}>{score}</Text>
<Text style={{ fontSize: 8 }}>out of {total}</Text>
</View>
</View>
);

View File

@@ -0,0 +1,20 @@
import React from "react";
import { View, StyleSheet } from "@react-pdf/renderer";
import { ModuleScore } from "@/interfaces/module.scores";
import { RadialResult } from "./radial.result";
interface Props {
testDetails: ModuleScore[];
}
const customStyles = StyleSheet.create({
container: { display: "flex", flexDirection: "row", gap: 30 },
});
export const SkillExamDetails = ({ testDetails }: Props) => (
<View style={customStyles.container}>
{testDetails.map((detail) => {
const { module } = detail;
return <RadialResult key={module} {...detail} />;
})}
</View>
);

View File

@@ -0,0 +1,301 @@
/* eslint-disable jsx-a11y/alt-text */
import React from "react";
import {
Document,
Page,
View,
Text,
Image,
StyleSheet,
Font,
} from "@react-pdf/renderer";
import { styles } from "./styles";
import TestReportFooter from "./test.report.footer";
import { StudentData } from "@/interfaces/module.scores";
import ProgressBar from "./progress.bar";
Font.registerHyphenationCallback((word) => [word]);
interface Props {
date: string;
name: string;
email: string;
id: string;
gender?: string;
summary: string;
logo: string;
qrcode: string;
renderDetails: React.ReactNode;
title: string;
numberOfStudents: number;
institution: string;
studentsData: StudentData[];
showLevel: boolean;
summaryPNG: string;
summaryScore: string;
groupScoreSummary: any[];
passportId: string;
}
const customStyles = StyleSheet.create({
tableCellHighlight: {
backgroundColor: "#4f4969",
color: "#bc9970",
},
table: {
display: "flex",
flexDirection: "column",
// maxWidth: "600px",
// margin: "0 auto",
// borderCollapse: 'collapse',
},
tableRow: {
display: "flex",
flexDirection: "row",
},
tableHeader: {
fontWeight: "bold",
backgroundColor: "#f2f2f2",
},
tableCell: {
flex: 1,
padding: "8px",
textAlign: "left",
wordBreak: "break-all",
},
});
const GroupTestReport = ({
title,
date,
name,
email,
id,
gender,
summary,
logo,
qrcode,
renderDetails,
numberOfStudents,
institution,
studentsData,
showLevel,
summaryPNG,
summaryScore,
groupScoreSummary,
passportId,
}: Props) => {
const defaultTextStyle = [styles.textFont, { fontSize: 8 }];
return (
<Document>
<Page style={styles.body}>
<View style={styles.alignRightRow}>
<Image src={logo} fixed style={styles.image64} />
</View>
<View style={styles.titleView}>
<Text
style={[
styles.textFont,
styles.textBold,
styles.textColor,
styles.textUnderline,
styles.title,
{ fontSize: 14 },
]}
>
{title}
</Text>
</View>
<View style={styles.textMargin}>
<Text style={defaultTextStyle}>Date of Test: {date}</Text>
</View>
<Text style={[styles.textFont, styles.textBold, { fontSize: 11 }]}>
Candidate Information:
</Text>
<View style={styles.textMargin}>
<Text style={defaultTextStyle}>Name: {name}</Text>
<Text style={defaultTextStyle}>ID: {id}</Text>
<Text style={defaultTextStyle}>Email: {email}</Text>
<Text style={defaultTextStyle}>Gender: {gender}</Text>
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
<Text style={defaultTextStyle}>
Total Number of Students: {numberOfStudents}
</Text>
<Text style={defaultTextStyle}>Institution: {institution}</Text>
</View>
<View style={{ flex: 1 }}>
<Text
style={[
styles.textFont,
styles.textBold,
styles.textColor,
{ fontSize: 12 },
]}
>
Group Test Details:
</Text>
<View>{renderDetails}</View>
</View>
<View>
<Text
style={[
styles.textFont,
styles.textBold,
styles.textColor,
{ fontSize: 12 },
]}
>
Group Overall Performance Summary
</Text>
<View style={{ display: "flex", flexDirection: "row", gap: 16 }}>
<View style={{ flex: 1 }}>
<Text style={[styles.textFont, { fontSize: 8 }]}>{summary}</Text>
</View>
<View style={[styles.textFont, styles.radialContainer]}>
<Image src={summaryPNG} style={styles.image64}></Image>
<View style={[styles.textColor, styles.radialResultContainer]}>
<Text style={styles.textBold}>{summaryScore}</Text>
</View>
</View>
</View>
</View>
<View style={[{ paddingTop: 30 }, styles.separator]}></View>
<View>
<Text
style={[
styles.textFont,
styles.textBold,
styles.textColor,
styles.textUnderline,
{ fontSize: 12, paddingTop: 10 },
]}
>
Group Score Summary
</Text>
<View
style={{
paddingTop: 10,
gap: 8,
}}
>
<View
style={[
customStyles.table,
styles.textFont,
{ width: "100%", fontSize: "8px" },
]}
>
{groupScoreSummary.map(({ label, percent, description }) => (
<View
style={[
customStyles.tableRow,
{
width: "100%",
alignItems: "center",
justifyContent: "center",
},
]}
key={label}
>
<Text style={customStyles.tableCell}>{label}</Text>
<View style={[customStyles.tableCell, { flex: 2 }]}>
<ProgressBar
width={200}
height={18}
backgroundColor="#fab7b0"
progressColor="#cc5b55"
percentage={percent}
/>
</View>
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
{percent}%
</Text>
<Text style={customStyles.tableCell}>{description}</Text>
</View>
))}
</View>
</View>
<View style={styles.alignRightRow}>
<Image src={qrcode} style={styles.qrcode} />
</View>
</View>
<View style={[{ paddingBottom: 30 }, styles.separator]}></View>
<View style={{ flexGrow: 1 }}></View>
<TestReportFooter />
</Page>
<Page style={styles.body}>
<View
style={[
customStyles.table,
styles.textFont,
{ border: "1px solid #ccc", width: "100%", fontSize: "8px" },
]}
>
<View
style={[
customStyles.tableRow,
customStyles.tableHeader,
customStyles.tableCellHighlight,
{ borderBottom: "1px solid #ccc" },
]}
>
<Text style={[customStyles.tableCell, { maxWidth: "24px" }]}>
Sr
</Text>
<Text style={customStyles.tableCell}>Candidate Name</Text>
<Text style={customStyles.tableCell}>Email ID</Text>
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
Gender
</Text>
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
Date of test
</Text>
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
Result
</Text>
{showLevel && <Text style={customStyles.tableCell}>Level</Text>}
</View>
{studentsData.map(
({ id, name, email, gender, date, result, level }, index) => (
<View
style={[
customStyles.tableRow,
{ borderBottom: "1px solid #ccc" },
]}
key={id}
>
<Text
style={[
customStyles.tableCell,
customStyles.tableCellHighlight,
{ maxWidth: "24px" },
]}
>
{index + 1}
</Text>
<Text style={customStyles.tableCell}>{name}</Text>
<Text style={customStyles.tableCell}>{email}</Text>
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
{gender}
</Text>
<Text style={[customStyles.tableCell, { maxWidth: "64px" }]}>
{date}
</Text>
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
{result}
</Text>
{showLevel && (
<Text style={customStyles.tableCell}>{level}</Text>
)}
</View>
)
)}
</View>
<View style={{ flexGrow: 1 }}></View>
<TestReportFooter />
</Page>
</Document>
);
};
export default GroupTestReport;

View File

@@ -0,0 +1,51 @@
import React from "react";
import { View, StyleSheet } from "@react-pdf/renderer";
const styles = StyleSheet.create({
progressBar: {
borderRadius: 16,
overflow: "hidden",
},
progressBarPerc: {
height: "100%",
zIndex: 1,
},
});
interface Props {
width: number;
height: number;
backgroundColor: string;
progressColor: string;
percentage: number;
}
const ProgressBar = ({
width,
height,
backgroundColor,
progressColor,
percentage,
}: Props) => {
return (
<View
style={[
{
width,
height,
backgroundColor,
},
styles.progressBar,
]}
>
<View
style={[
{ width: `${percentage}%`, backgroundColor: progressColor },
styles.progressBarPerc,
]}
></View>
</View>
);
};
export default ProgressBar;

75
src/exams/pdf/styles.ts Normal file
View File

@@ -0,0 +1,75 @@
import { StyleSheet } from "@react-pdf/renderer";
export const styles = StyleSheet.create({
body: {
paddingTop: 10,
paddingBottom: 20,
paddingHorizontal: 35,
},
titleView: {
display: "flex",
// flex: 1,
alignItems: "center",
},
title: {
textTransform: "uppercase",
},
textMargin: {
marginVertical: "8px",
},
separator: {
width: "100%",
borderBottom: "1px solid #89b0c2",
},
textFont: {
fontFamily: "Helvetica",
},
textBold: {
fontFamily: "Helvetica-Bold",
fontWeight: "bold",
},
textNormal: {
fontWeight: "normal",
},
textColor: {
color: "#4e4969",
},
textUnderline: {
textDecoration: "underline",
},
spacedRow: {
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
},
alignRightRow: {
display: "flex",
flexDirection: "row-reverse",
},
image64: {
height: "64px",
width: "64px",
},
qrcode: {
width: "80px",
height: "80px",
},
radialContainer: {
display: "flex",
flexDirection: "column",
alignItems: "center",
position: "relative",
},
radialResultContainer: {
display: "flex",
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
fontSize: 10,
gap: 8,
},
});

View File

@@ -0,0 +1,60 @@
import React from "react";
import { styles } from "./styles";
import { View, Text } from "@react-pdf/renderer";
const TestReportFooter = () => (
<View
style={[
{
paddingTop: 30,
fontSize: 5,
position: "absolute",
bottom: 30,
left: 35,
right: 35,
},
styles.textFont,
]}
>
<View style={[styles.spacedRow, styles.textMargin]}>
<View>
<Text style={styles.textBold}>Validity</Text>
<Text>
This report remains valid for a duration of three months from the test
date.
</Text>
</View>
<View>
<Text style={styles.textBold}>Confidential <Text style={[styles.textFont, styles.textNormal]}>circulated for concern people</Text></Text>
</View>
</View>
<View style={{ paddingTop: 10 }}>
<Text style={styles.textBold}>Declaration</Text>
<Text style={{ paddingTop: 5 }}>
We hereby declare that exam results on our platform, assessed by AI, are
not the sole determinants of candidates&apos; English proficiency
levels. While EnCoach provides feedback based on assessments, we
recognize that language proficiency encompasses practical application,
cultural understanding, and real-life communication. We urge users to
consider exam results as a measure of progress and improvement, and we
continuously enhance our system to ensure accuracy and reliability.
</Text>
</View>
<View style={[styles.textColor, { paddingTop: 5 }]}>
<Text style={styles.textUnderline}>info@encoach.com</Text>
<Text>https://encoach.com</Text>
<View style={styles.spacedRow}>
<Text>Group ID: TRI64BNBOIU5043</Text>
<Text
// style={styles.pageNumber}
render={({ pageNumber, totalPages }) =>
`${pageNumber} / ${totalPages}`
}
fixed
/>
</View>
</View>
</View>
);
export default TestReportFooter;

View File

@@ -0,0 +1,174 @@
/* eslint-disable jsx-a11y/alt-text */
import React from "react";
import { Document, Page, View, Text, Image } from "@react-pdf/renderer";
import { ModuleScore } from "@/interfaces/module.scores";
import { styles } from "./styles";
import { StyleSheet } from "@react-pdf/renderer";
import TestReportFooter from "./test.report.footer";
const customStyles = StyleSheet.create({
testDetails: {
display: "flex",
gap: 4,
},
});
interface Props {
date: string;
name: string;
email: string;
id: string;
gender?: string;
testDetails: ModuleScore[];
summary: string;
logo: string;
qrcode: string;
renderDetails: React.ReactNode;
title: string;
summaryPNG: string;
summaryScore: string;
passportId: string;
}
const TestReport = ({
title,
date,
name,
email,
id,
gender,
testDetails,
summary,
logo,
qrcode,
renderDetails,
summaryPNG,
summaryScore,
passportId,
}: Props) => {
const defaultTextStyle = [styles.textFont, { fontSize: 8 }];
const defaultSkillsTextStyle = [styles.textFont, { fontSize: 8 }];
const defaultSkillsTitleStyle = [
styles.textFont,
styles.textColor,
styles.textBold,
{ fontSize: 7 },
];
return (
<Document>
<Page style={styles.body}>
<View style={styles.alignRightRow}>
<Image src={logo} fixed style={styles.image64} />
</View>
<View style={styles.titleView}>
<Text
style={[
styles.textFont,
styles.textBold,
styles.textColor,
styles.textUnderline,
styles.title,
{ fontSize: 14 },
]}
>
{title}
</Text>
</View>
<View style={styles.textMargin}>
<Text style={defaultTextStyle}>Date of Test: {date}</Text>
</View>
<Text style={[styles.textFont, styles.textBold, { fontSize: 11 }]}>
Candidate Information:
</Text>
<View style={styles.textMargin}>
<Text style={defaultTextStyle}>Name: {name}</Text>
<Text style={defaultTextStyle}>ID: {id}</Text>
<Text style={defaultTextStyle}>Email: {email}</Text>
<Text style={defaultTextStyle}>Gender: {gender}</Text>
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
</View>
<View style={{ height: "120px" }}>
<Text
style={[
styles.textFont,
styles.textBold,
styles.textColor,
{ fontSize: 12 },
]}
>
Test Details:
</Text>
<View>{renderDetails}</View>
</View>
<View>
<Text
style={[
styles.textFont,
styles.textBold,
styles.textColor,
{ fontSize: 12 },
]}
>
Performance Summary
</Text>
<View style={{ display: "flex", flexDirection: "row", gap: 16 }}>
<View style={{ flex: 1 }}>
<Text style={[styles.textFont, { fontSize: 8 }]}>{summary}</Text>
</View>
<View style={[styles.textFont, styles.radialContainer]}>
<Image src={summaryPNG} style={styles.image64}></Image>
<View style={[styles.textColor, styles.radialResultContainer]}>
<Text style={styles.textBold}>{summaryScore}</Text>
</View>
</View>
</View>
</View>
<View style={[{ paddingTop: 30 }, styles.separator]}></View>
<TestReportFooter />
</Page>
<Page style={styles.body}>
<View>
<Text
style={[
styles.textFont,
styles.textBold,
styles.textColor,
styles.textUnderline,
{ fontSize: 12, paddingTop: 10 },
]}
>
Skills Feedback
</Text>
<View
style={{
paddingTop: 10,
gap: 8,
}}
>
{testDetails
.filter(
({ suggestions, evaluation }) => suggestions || evaluation
)
.map(({ module, suggestions, evaluation }) => (
<View key={module} style={customStyles.testDetails}>
<Text style={[...defaultSkillsTitleStyle, styles.textBold]}>
{module}
</Text>
<Text style={defaultSkillsTextStyle}>{evaluation}</Text>
<Text style={defaultSkillsTextStyle}>{suggestions}</Text>
</View>
))}
</View>
<View style={styles.alignRightRow}>
<Image src={qrcode} style={styles.qrcode} />
</View>
</View>
<View style={[{ paddingBottom: 30 }, styles.separator]}></View>
<View style={{ flexGrow: 1 }}></View>
<TestReportFooter />
</Page>
</Document>
);
};
export default TestReport;

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");
@@ -10,7 +11,6 @@ const firebaseConfig = {
storageBucket: process.env.FIREBASE_STORAGE_BUCKET || "",
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID || "",
appId: process.env.FIREBASE_APP_ID || "",
measurementId: process.env.FIREBASE_MEASUREMENT_ID || "",
};
export const app = initializeApp(firebaseConfig, Math.random().toString());
@@ -20,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

@@ -0,0 +1,60 @@
import React from "react";
import axios from "axios";
import { toast } from "react-toastify";
import { BsFilePdf } from "react-icons/bs";
type DownloadingPdf = {
[key: string]: boolean;
};
type PdfEndpoint = "stats" | "assignments";
export const usePDFDownload = (endpoint: PdfEndpoint) => {
const [downloadingPdf, setDownloadingPdf] = React.useState<DownloadingPdf>(
{}
);
const triggerDownload = async (id: string) => {
try {
setDownloadingPdf((prev) => ({ ...prev, [id]: true }));
const res = await axios.post(`/api/${endpoint}/${id}/export`);
toast.success("Report ready!");
const link = document.createElement("a");
link.href = res.data;
// download should have worked but there are some CORS issues
// https://firebase.google.com/docs/storage/web/download-files#cors_configuration
// link.download="report.pdf";
link.target = "_blank";
link.rel = "noreferrer";
link.click();
setDownloadingPdf((prev) => ({ ...prev, [id]: false }));
} catch (err) {
toast.error("Failed to display the report!");
console.error(err);
setDownloadingPdf((prev) => ({ ...prev, [id]: false }));
}
};
const renderIcon = (
id: string,
downloadClasses: string,
loadingClasses: string
) => {
if (downloadingPdf[id]) {
return (
<span className={`${loadingClasses} loading loading-infinity w-6`} />
);
}
return (
<BsFilePdf
className={`${downloadClasses} text-2xl cursor-pointer`}
onClick={(e) => {
e.stopPropagation();
triggerDownload(id);
}}
/>
);
};
return renderIcon;
};

22
src/hooks/usePackages.tsx Normal file
View File

@@ -0,0 +1,22 @@
import {Exam} from "@/interfaces/exam";
import {Package} from "@/interfaces/paypal";
import axios from "axios";
import {useEffect, useState} from "react";
export default function usePackages() {
const [packages, setPackages] = useState<Package[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Package[]>("/api/packages")
.then((response) => setPackages(response.data))
.finally(() => setIsLoading(false));
};
useEffect(getData, []);
return {packages, isLoading, isError, reload: getData};
}

View File

@@ -0,0 +1,20 @@
import axios from "axios";
import { useEffect, useState } from "react";
import { PaymentsStatus } from "@/interfaces/user.payments";
export default function usePaymentStatusUsers() {
const [{ pending, done }, setStatus] = useState<PaymentsStatus>({
pending: [],
done: [],
});
const getData = () => {
axios.get<PaymentsStatus>("/api/payments/assigned").then((response) => {
setStatus(response.data);
});
};
useEffect(getData, []);
return { pending, done };
}

24
src/hooks/usePayments.tsx Normal file
View File

@@ -0,0 +1,24 @@
import {Payment} from "@/interfaces/paypal";
import {Group, User} from "@/interfaces/user";
import axios from "axios";
import {useEffect, useState} from "react";
export default function usePayments() {
const [payments, setPayments] = useState<Payment[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Payment[]>("/api/payments")
.then((response) => {
return setPayments(response.data);
})
.finally(() => setIsLoading(false));
};
useEffect(getData, []);
return {payments, isLoading, isError, reload: getData};
}

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

@@ -10,7 +10,7 @@ export default function useUsers() {
const getData = () => {
setIsLoading(true);
axios
.get<User[]>("/api/users/list")
.get<User[]>("/api/users/list", {headers: {page: "register"}})
.then((response) => setUsers(response.data))
.finally(() => setIsLoading(false));
};

View File

@@ -1,37 +1,54 @@
import {Module} from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam;
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
export type Variant = "diagnostic" | "partial";
export interface ReadingExam {
parts: {
text: {
title: string;
content: string;
};
exercises: Exercise[];
}[];
parts: ReadingPart[];
id: string;
module: "reading";
minTimer: number;
type: "academic" | "general";
isDiagnostic: boolean;
variant?: Variant;
}
export interface ReadingPart {
text: {
title: string;
content: string;
};
exercises: Exercise[];
}
export interface LevelExam {
module: "level";
id: string;
exercises: Exercise[];
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
}
export interface ListeningExam {
parts: {
audio: {
source: string;
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
};
exercises: Exercise[];
}[];
parts: ListeningPart[];
id: string;
module: "listening";
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
}
export interface ListeningPart {
audio: {
source: string;
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
};
exercises: Exercise[];
}
export interface UserSolution {
id?: string;
solutions: any[];
module?: Module;
exam?: string;
@@ -50,6 +67,7 @@ export interface WritingExam {
exercises: Exercise[];
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
}
interface WordCounter {
@@ -63,6 +81,7 @@ export interface SpeakingExam {
exercises: Exercise[];
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
}
export type Exercise =
@@ -79,7 +98,20 @@ export interface Evaluation {
comment: string;
overall: number;
task_response: {[key: string]: number};
misspelled_pairs?: {correction: string | null; misspelled: string}[];
}
interface InteractiveSpeakingEvaluation extends Evaluation {
perfect_answer_1?: string;
perfect_answer_2?: string;
perfect_answer_3?: string;
}
interface CommonEvaluation extends Evaluation {
perfect_answer?: string;
perfect_answer_1?: string;
}
export interface WritingExercise {
id: string;
type: "writing";
@@ -94,7 +126,7 @@ export interface WritingExercise {
userSolutions: {
id: string;
solution: string;
evaluation?: Evaluation;
evaluation?: CommonEvaluation;
}[];
}
@@ -108,7 +140,7 @@ export interface SpeakingExercise {
userSolutions: {
id: string;
solution: string;
evaluation?: Evaluation;
evaluation?: CommonEvaluation;
}[];
}
@@ -121,7 +153,7 @@ export interface InteractiveSpeakingExercise {
userSolutions: {
id: string;
solution: {question: string; answer: string}[];
evaluation?: Evaluation;
evaluation?: InteractiveSpeakingEvaluation;
}[];
}

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