Compare commits

...

285 Commits

Author SHA1 Message Date
Tiago Ribeiro
74dcccf089 Added a new type of e-mail to send to students when a new assignment is created 2024-01-17 15:55:33 +00:00
Tiago Ribeiro
63d2baf35f Improved the overall redirection of the login page 2024-01-17 14:25:37 +00:00
Tiago Ribeiro
c02a6a01f4 Merge branch 'develop' into feature/writing-diff-viewer 2024-01-17 12:58:44 +00:00
Tiago Ribeiro
a646955493 Solved a bug with calculations of the stats page 2024-01-17 11:59:40 +00:00
Tiago Ribeiro
7a577a7ca2 Solved another stupid bug 2024-01-17 11:50:50 +00:00
Tiago Ribeiro
c26ff48b60 Solved some issues with the Student Dashboard 2024-01-17 11:32:20 +00:00
Tiago Ribeiro
9ee09c8fda Added a diff viewer for the writing correction 2024-01-17 11:22:23 +00:00
João Ramos
d4867fd9a2 Merged in bug-fixing-16-jan-24 (pull request #26)
Report PDF improvements / bugs

Approved-by: Tiago Ribeiro
2024-01-16 23:20:00 +00:00
Tiago Ribeiro
13e52bfce6 Merge branch 'develop' into bug-fixing-16-jan-24 2024-01-16 23:14:05 +00:00
Tiago Ribeiro
5540e4a3e6 Updated the profile page a bit to accommodate recent changes 2024-01-16 23:11:16 +00:00
João Ramos
a18ee93909 Merged in feature-user-timezone-pdf (pull request #25)
Added Date export based on user timezone

Approved-by: Tiago Ribeiro
2024-01-16 23:07:07 +00:00
Tiago Ribeiro
0641d4250c Merged develop into feature-user-timezone-pdf 2024-01-16 23:03:46 +00:00
Tiago Ribeiro
f85a1f5601 Updated part of the correction display for Writing 2024-01-16 23:00:58 +00:00
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
Joao Ramos
01a9da3a5b Added Date export based on user timezone 2024-01-16 18:42:12 +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
183 changed files with 11202 additions and 1589 deletions

1
.gitignore vendored
View File

@@ -38,3 +38,4 @@ next-env.d.ts
.env .env
.yarn/* .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 = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
output: "standalone", output: "standalone",
async headers() {
return [
{
source: "/api/packages",
headers: [
{key: "Access-Control-Allow-Credentials", value: "false"},
{key: "Access-Control-Allow-Origin", value: "https://encoach.com"},
{
key: "Access-Control-Allow-Methods",
value: "GET",
},
{
key: "Access-Control-Allow-Headers",
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
},
],
},
];
},
}; };
module.exports = nextConfig; module.exports = nextConfig;

View File

@@ -10,10 +10,14 @@
"prepare": "husky install" "prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@beam-australia/react-env": "^3.1.1",
"@headlessui/react": "^1.7.13", "@headlessui/react": "^1.7.13",
"@mdi/js": "^7.1.96", "@mdi/js": "^7.1.96",
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
"@next/font": "13.1.6", "@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", "@tanstack/react-table": "^8.10.1",
"@types/node": "18.13.0", "@types/node": "18.13.0",
"@types/react": "18.0.27", "@types/react": "18.0.27",
@@ -24,6 +28,7 @@
"clsx": "^1.2.1", "clsx": "^1.2.1",
"countries-list": "^3.0.1", "countries-list": "^3.0.1",
"country-codes-list": "^1.6.11", "country-codes-list": "^1.6.11",
"currency-symbol-map": "^5.1.0",
"daisyui": "^3.1.5", "daisyui": "^3.1.5",
"eslint": "8.33.0", "eslint": "8.33.0",
"eslint-config-next": "13.1.6", "eslint-config-next": "13.1.6",
@@ -33,18 +38,24 @@
"formidable": "^3.5.0", "formidable": "^3.5.0",
"formidable-serverless": "^1.1.1", "formidable-serverless": "^1.1.1",
"framer-motion": "^9.0.2", "framer-motion": "^9.0.2",
"howler": "^2.2.4",
"iron-session": "^6.3.1", "iron-session": "^6.3.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.4", "moment": "^2.29.4",
"moment-timezone": "^0.5.44",
"next": "13.1.6", "next": "13.1.6",
"nodemailer": "^6.9.5", "nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0", "nodemailer-express-handlebars": "^6.1.0",
"primeicons": "^6.0.1", "primeicons": "^6.0.1",
"primereact": "^9.2.3", "primereact": "^9.2.3",
"qrcode": "^1.5.3",
"random-words": "^2.0.0", "random-words": "^2.0.0",
"react": "18.2.0", "react": "18.2.0",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
"react-csv": "^2.2.2",
"react-currency-input-field": "^3.6.12",
"react-datepicker": "^4.18.0", "react-datepicker": "^4.18.0",
"react-diff-viewer": "^3.1.1",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-firebase-hooks": "^5.1.1", "react-firebase-hooks": "^5.1.1",
"react-icons": "^4.8.0", "react-icons": "^4.8.0",
@@ -56,6 +67,7 @@
"react-string-replace": "^1.1.0", "react-string-replace": "^1.1.0",
"react-toastify": "^9.1.2", "react-toastify": "^9.1.2",
"react-xarrows": "^2.0.2", "react-xarrows": "^2.0.2",
"read-excel-file": "^5.7.1",
"short-unique-id": "^5.0.2", "short-unique-id": "^5.0.2",
"stripe": "^13.10.0", "stripe": "^13.10.0",
"swr": "^2.1.3", "swr": "^2.1.3",
@@ -67,10 +79,14 @@
"zustand": "^4.3.6" "zustand": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@types/blob-stream": "^0.1.33",
"@types/formidable": "^3.4.0", "@types/formidable": "^3.4.0",
"@types/howler": "^2.2.11",
"@types/lodash": "^4.14.191", "@types/lodash": "^4.14.191",
"@types/nodemailer": "^6.4.11", "@types/nodemailer": "^6.4.11",
"@types/nodemailer-express-handlebars": "^4.0.3", "@types/nodemailer-express-handlebars": "^4.0.3",
"@types/qrcode": "^1.5.5",
"@types/react-csv": "^1.1.10",
"@types/react-datepicker": "^4.15.1", "@types/react-datepicker": "^4.15.1",
"@types/uuid": "^9.0.1", "@types/uuid": "^9.0.1",
"@types/wavesurfer.js": "^6.0.6", "@types/wavesurfer.js": "^6.0.6",
@@ -78,6 +94,7 @@
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"husky": "^8.0.3", "husky": "^8.0.3",
"postcss": "^8.4.21", "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 {toast} from "react-toastify";
import {KeyedMutator} from "swr"; import {KeyedMutator} from "swr";
import CountrySelect from "./Low/CountrySelect"; import CountrySelect from "./Low/CountrySelect";
import GenderInput from "@/components/High/GenderInput";
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
interface Props { interface Props {
user: User;
mutateUser: KeyedMutator<User>; mutateUser: KeyedMutator<User>;
} }
export default function DemographicInformationInput({mutateUser}: Props) { export default function DemographicInformationInput({user, mutateUser}: Props) {
const [country, setCountry] = useState<string>(); const [country, setCountry] = useState<string>();
const [phone, setPhone] = useState<string>(); const [phone, setPhone] = useState<string>();
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
const [gender, setGender] = useState<Gender>(); const [gender, setGender] = useState<Gender>();
const [employment, setEmployment] = useState<EmploymentStatus>(); const [employment, setEmployment] = useState<EmploymentStatus>();
const [position, setPosition] = useState<string>();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [companyName, setCompanyName] = useState<string>();
const [commercialRegistration, setCommercialRegistration] = useState<string>();
const save = (e?: FormEvent) => { const save = (e?: FormEvent) => {
if (e) e.preventDefault(); if (e) e.preventDefault();
setIsLoading(true); setIsLoading(true);
@@ -32,8 +40,11 @@ export default function DemographicInformationInput({mutateUser}: Props) {
country, country,
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`, phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
gender, 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)) .then((response) => mutateUser((response.data as {user: User}).user))
.catch(() => { .catch(() => {
@@ -53,71 +64,41 @@ export default function DemographicInformationInput({mutateUser}: Props) {
about yourself. about yourself.
</h2> </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}> <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"> {user.type === "agent" && (
<label className="font-normal text-base text-mti-gray-dim">Country *</label> <div className="w-full flex gap-8">
<CountrySelect value={country} onChange={setCountry} /> <Input type="text" onChange={setCompanyName} name="companyName" label="Corporate Name" required />
</div> <Input
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required /> type="text"
<div className="relative flex flex-col gap-3 w-full"> onChange={setCommercialRegistration}
<label className="font-normal text-base text-mti-gray-dim">Gender *</label> name="commercialRegistration"
<RadioGroup value={gender} onChange={setGender} className="flex flex-row justify-between"> label="Commercial Registration"
<RadioGroup.Option value="male"> required
{({checked}) => ( />
<span </div>
className={clsx( )}
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", <div className="w-full grid grid-cols-2 gap-6">
"transition duration-300 ease-in-out", <div className="relative flex flex-col gap-3 w-full">
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white", <label className="font-normal text-base text-mti-gray-dim">Country *</label>
)}> <CountrySelect value={country} onChange={setCountry} />
Male </div>
</span> <Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
)}
</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>
</div> </div>
{user.type === "student" && (
<Input
type="text"
name="passport_id"
label="Passport/National ID"
onChange={(e) => setPassportID(e)}
value={passport_id}
placeholder="Enter National ID or Passport number"
required
/>
)}
<GenderInput value={gender} onChange={setGender} />
{user.type === "corporate" && (
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
)}
{user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />}
</form> </form>
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8"> <div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
@@ -125,7 +106,14 @@ export default function DemographicInformationInput({mutateUser}: Props) {
className="lg:mt-8 max-w-[400px] w-full self-end" className="lg:mt-8 max-w-[400px] w-full self-end"
color="purple" color="purple"
onClick={save} 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 && "Save information"}
{isLoading && ( {isLoading && (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">

View File

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

View File

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

View File

@@ -11,7 +11,16 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
ssr: false, 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 [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>(); 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); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (updateIndex) updateIndex(promptIndex);
}, [promptIndex, updateIndex]);
useEffect(() => { useEffect(() => {
if (hasExamEnded) { if (hasExamEnded) {
onNext({ 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 [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const [questionIndex, setQuestionIndex] = useState(0); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]); }, [hasExamEnded]);
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
const onSelectOption = (option: string) => { const onSelectOption = (option: string) => {
const question = questions[questionIndex]; const question = questions[questionIndex];
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]); setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);

View File

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

View File

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

View File

@@ -26,6 +26,8 @@ export default function Writing({
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => { useEffect(() => {
if (localStorage.getItem("enable_paste")) return;
const listener = (e: KeyboardEvent) => { const listener = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "v") { if ((e.ctrlKey || e.metaKey) && e.key === "v") {
e.preventDefault(); 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 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"> <div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<span> <span className="whitespace-pre-wrap">{prefix.replaceAll("\\n", "\n")}</span>
{prefix.split("\\n").map((line, index) => ( <span className="font-semibold whitespace-pre-wrap">{prompt.replaceAll("\\n", "\n")}</span>
<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>
{attachment && ( {attachment && (
<img <img
onClick={() => setIsModalOpen(true)} onClick={() => setIsModalOpen(true)}
@@ -120,14 +108,7 @@ export default function Writing({
</div> </div>
<div className="w-full h-full flex flex-col gap-4"> <div className="w-full h-full flex flex-col gap-4">
<span> <span className="whitespace-pre-wrap">{suffix}</span>
{suffix.split("\\n").map((line, index) => (
<React.Fragment key={index}>
{line}
<br />
</React.Fragment>
))}
</span>
<textarea <textarea
onContextMenu={(e) => e.preventDefault()} 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" 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}); const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
export interface CommonProps { export interface CommonProps {
updateIndex?: (internalIndex: number) => void;
onNext: (userSolutions: UserSolution) => void; onNext: (userSolutions: UserSolution) => void;
onBack: (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) { switch (exercise.type) {
case "fillBlanks": case "fillBlanks":
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />; 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": case "matchSentences":
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />; return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
case "multipleChoice": 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": case "writeBlanks":
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />; return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "writing": case "writing":
@@ -43,6 +57,14 @@ export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserS
case "speaking": case "speaking":
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />; return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
case "interactiveSpeaking": 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

@@ -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 { interface Props {
children: ReactNode; children: ReactNode;
color?: "rose" | "purple" | "red" | "green"; color?: "rose" | "purple" | "red" | "green" | "gray";
variant?: "outline" | "solid"; variant?: "outline" | "solid";
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
@@ -39,6 +39,11 @@ export default function Button({
outline: outline:
"bg-transparent text-mti-red-light border border-mti-red-light hover:bg-mti-red-light disabled:text-mti-red disabled:bg-mti-red-ultralight disabled:border-none selection:bg-mti-red-dark hover:text-white selection:text-white", "bg-transparent text-mti-red-light border border-mti-red-light hover:bg-mti-red-light disabled:text-mti-red disabled:bg-mti-red-ultralight disabled:border-none selection:bg-mti-red-dark hover:text-white selection:text-white",
}, },
gray: {
solid: "bg-mti-gray-davy text-white border border-mti-gray-davy hover:bg-mti-gray-davy disabled:text-mti-gray-davy disabled:bg-mti-gray-davy selection:bg-mti-gray-davy",
outline:
"bg-transparent text-mti-gray-davy border border-mti-gray-davy hover:bg-mti-gray-davy disabled:text-mti-gray-davy disabled:bg-mti-gray-davy disabled:border-none selection:bg-mti-gray-davy hover:text-white selection:text-white",
},
rose: { rose: {
solid: "bg-mti-rose-light text-white border border-mti-rose-light hover:bg-mti-rose disabled:text-mti-rose disabled:bg-mti-rose-ultralight selection:bg-mti-rose-dark", solid: "bg-mti-rose-light text-white border border-mti-rose-light hover:bg-mti-rose disabled:text-mti-rose disabled:bg-mti-rose-ultralight selection:bg-mti-rose-dark",
outline: outline:

View File

@@ -6,11 +6,15 @@ interface Props {
isChecked: boolean; isChecked: boolean;
onChange: (isChecked: boolean) => void; onChange: (isChecked: boolean) => void;
children: ReactNode; children: ReactNode;
disabled?: boolean;
} }
export default function Checkbox({isChecked, onChange, children}: Props) { export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
return ( 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" /> <input type="checkbox" className="hidden" />
<div <div
className={clsx( className={clsx(

View File

@@ -3,17 +3,31 @@ import {useState} from "react";
interface Props { interface Props {
type: "email" | "text" | "password" | "tel" | "number"; type: "email" | "text" | "password" | "tel" | "number";
roundness?: "full" | "xl";
required?: boolean; required?: boolean;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
defaultValue?: string | number; defaultValue?: string | number;
value?: string | number;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
name: string; name: string;
onChange: (value: string) => void; 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); const [showPassword, setShowPassword] = useState(false);
if (type === "password") { if (type === "password") {
@@ -57,9 +71,15 @@ export default function Input({type, label, placeholder, name, required = false,
type={type} type={type}
name={name} name={name}
disabled={disabled} disabled={disabled}
value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
min={type === "number" ? 0 : undefined}
placeholder={placeholder} 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} required={required}
defaultValue={defaultValue} defaultValue={defaultValue}
/> />

View File

@@ -0,0 +1,64 @@
import { Fragment, useState } from "react";
import { Combobox, Transition } from "@headlessui/react";
import { BsChevronExpand } from "react-icons/bs";
import moment from "moment-timezone";
interface Props {
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
}
export default function TimezoneSelect({
value,
disabled = false,
onChange,
}: Props) {
const [query, setQuery] = useState("");
const timezones = moment.tz.names();
const filteredTimezones = query === "" ? timezones : timezones.filter((x) => x.toLowerCase().includes(query.toLowerCase()));
return (
<>
<Combobox value={value} onChange={onChange} disabled={disabled}>
<div className="relative mt-1">
<div className="relative w-full cursor-default overflow-hidden ">
<Combobox.Input
className="py-6 w-full px-8 text-sm font-normal placeholder:text-mti-gray-cool bg-white disabled:bg-mti-gray-platinum/40 rounded-full border border-mti-gray-platinum focus:outline-none"
onChange={(e) => setQuery(e.target.value)}
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
<BsChevronExpand />
</Combobox.Button>
</div>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
afterLeave={() => setQuery("")}
>
<Combobox.Options className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-xl bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{filteredTimezones.map((timezone: string) => (
<Combobox.Option
key={timezone}
value={timezone}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active
? "bg-mti-purple-light text-white"
: "text-gray-900"
}`
}
>
{timezone}
</Combobox.Option>
))}
</Combobox.Options>
</Transition>
</div>
</Combobox>
</>
);
}

View File

@@ -103,14 +103,25 @@ export default function MobileMenu({isOpen, onClose, path, user}: Props) {
)}> )}>
Record Record
</Link> </Link>
{user.type !== "student" && ( {["admin", "developer", "agent", "corporate"].includes(user.type) && (
<Link <Link
href="/admin" href="/payment-record"
className={clsx( className={clsx(
"transition ease-in-out duration-300 w-fit", "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>
)} )}
<Link <Link

View File

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

View File

@@ -1,7 +1,17 @@
import clsx from "clsx"; import clsx from "clsx";
import {IconType} from "react-icons"; import {IconType} from "react-icons";
import {MdSpaceDashboard} from "react-icons/md"; 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 {RiLogoutBoxFill} from "react-icons/ri";
import {SlPencil} from "react-icons/sl"; import {SlPencil} from "react-icons/sl";
import {FaAward} from "react-icons/fa"; import {FaAward} from "react-icons/fa";
@@ -89,8 +99,35 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
)} )}
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
{userType !== "student" && ( {["admin", "developer", "agent", "corporate"].includes(userType || "") && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Admin" path={path} keyPath="/admin" isMinimized={isMinimized} /> <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>
<div className="xl:hidden -xl:flex flex-col gap-3"> <div className="xl:hidden -xl:flex flex-col gap-3">
@@ -100,11 +137,14 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" 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} /> <Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
{userType !== "student" && ( {userType !== "student" && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Admin" path={path} keyPath="/admin" isMinimized={true} /> <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>
<div className="flex flex-col gap-0 absolute bottom-12"> <div className="flex flex-col gap-0 bottom-12 fixed">
<div <div
role="button" role="button"
tabIndex={1} tabIndex={1}

View File

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

View File

@@ -6,6 +6,8 @@ import Button from "../Low/Button";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import axios from "axios"; import axios from "axios";
import {speakingReverseMarking} from "@/utils/score"; import {speakingReverseMarking} from "@/utils/score";
import {Tab} from "@headlessui/react";
import clsx from "clsx";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
@@ -47,7 +49,7 @@ export default function InteractiveSpeaking({
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<span className="font-bold">You should talk about the following things:</span> <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) => ( {prompts.map((x, index) => (
<div className="italic flex flex-col gap-2 text-sm" key={index}> <div className="italic flex flex-col gap-2 text-sm" key={index}>
<video key={index} controls className=""> <video key={index} controls className="">
@@ -61,11 +63,11 @@ export default function InteractiveSpeaking({
</div> </div>
<div className="w-full h-full flex flex-col gap-8"> <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) => ( {solutionsURL.map((x, index) => (
<div <div
key={index} 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"> <div className="flex gap-8 items-center justify-center py-8">
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" /> <Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
</div> </div>
@@ -73,7 +75,7 @@ export default function InteractiveSpeaking({
))} ))}
</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 flex-col gap-4 w-full">
<div className="flex gap-4 px-1"> <div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => ( {Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
@@ -82,9 +84,81 @@ export default function InteractiveSpeaking({
</div> </div>
))} ))}
</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 &&
{userSolutions[0].evaluation!.comment} Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? (
</div> <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>
)} )}
</div> </div>

View File

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

View File

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

View File

@@ -6,6 +6,8 @@ import Button from "../Low/Button";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import axios from "axios"; import axios from "axios";
import {speakingReverseMarking} from "@/utils/score"; import {speakingReverseMarking} from "@/utils/score";
import {Tab} from "@headlessui/react";
import clsx from "clsx";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); 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" />} {solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
</div> </div>
</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 flex-col gap-4 w-full">
<div className="flex gap-4 px-1"> <div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => ( {Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
@@ -78,9 +80,52 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
</div> </div>
))} ))}
</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 &&
{userSolutions[0].evaluation!.comment} (userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
</div> <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>
)} )}
</div> </div>

View File

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

View File

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

View File

@@ -1,18 +1,16 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {WritingExercise} from "@/interfaces/exam"; import {WritingExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {CommonProps} from "."; import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify";
import Button from "../Low/Button"; import Button from "../Low/Button";
import {Dialog, Transition} from "@headlessui/react"; import {Dialog, Tab, Transition} from "@headlessui/react";
import {writingReverseMarking} from "@/utils/score"; import {writingReverseMarking} from "@/utils/score";
import clsx from "clsx";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) { export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [showDiff, setShowDiff] = useState(false);
return ( return (
<> <>
@@ -68,18 +66,55 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
</div> </div>
<div className="w-full h-full flex flex-col gap-8"> <div className="w-full h-full flex flex-col gap-8">
{userSolutions && ( {userSolutions && userSolutions.length > 0 && (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full relative">
<span>Your answer:</span> {!showDiff && (
<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" <span>Your answer:</span>
contentEditable={false} <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">
readOnly {userSolutions[0]!.solution.replaceAll("\\n", "\n")}
value={userSolutions[0]!.solution} </div>
/> </>
)}
{showDiff && (
<>
<span>Correction:</span>
<div className="w-full h-full max-h-[320px] overflow-y-scroll scrollbar-hide cursor-text border-2 overflow-x-hidden border-mti-gray-platinum bg-white rounded-3xl">
<ReactDiffViewer
styles={{
contentText: {
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px",
},
marker: {display: "none"},
diffRemoved: {padding: "32px 28px"},
diffAdded: {padding: "32px 28px"},
wordRemoved: {padding: "0px", display: "initial"},
wordAdded: {padding: "0px", display: "initial"},
wordDiff: {padding: "0px", display: "initial"},
}}
oldValue={userSolutions[0].solution.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation!.fixed_text!.replaceAll("\\n", "\n")}
splitView
hideLineNumbers
showDiffOnly={false}
/>
</div>
</>
)}
{userSolutions[0].solution && userSolutions[0].evaluation?.fixed_text && (
<Button
color="green"
variant="outline"
className="w-full max-w-[200px] self-end absolute -top-4 right-0 !py-2"
onClick={() => setShowDiff((prev) => !prev)}>
{showDiff ? "View answer" : "View correction"}
</Button>
)}
</div> </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 flex-col gap-4 w-full">
<div className="flex gap-4 px-1"> <div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => ( {Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
@@ -88,9 +123,48 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
</div> </div>
))} ))}
</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 && userSolutions[0].evaluation.perfect_answer ? (
{userSolutions[0].evaluation!.comment} <Tab.Group>
</div> <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>
)} )}
</div> </div>

View File

@@ -22,27 +22,36 @@ import Writing from "./Writing";
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false}); const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
export interface CommonProps { export interface CommonProps {
updateIndex?: (internalIndex: number) => void;
onNext: (userSolutions: UserSolution) => void; onNext: (userSolutions: UserSolution) => void;
onBack: (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) { switch (exercise.type) {
case "fillBlanks": case "fillBlanks":
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />; return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "trueFalse": case "trueFalse":
return <TrueFalseSolution {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />; return <TrueFalseSolution key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
case "matchSentences": case "matchSentences":
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />; return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
case "multipleChoice": case "multipleChoice":
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />; return (
<MultipleChoice
key={exercise.id}
{...(exercise as MultipleChoiceExercise)}
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}
/>
);
case "writeBlanks": case "writeBlanks":
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />; return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "writing": case "writing":
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />; return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
case "speaking": case "speaking":
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />; return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
case "interactiveSpeaking": case "interactiveSpeaking":
return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />; return <InteractiveSpeaking key={exercise.id} {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
} }
}; };

View File

@@ -6,7 +6,7 @@ import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import moment from "moment"; import moment from "moment";
import {Divider} from "primereact/divider"; import {Divider} from "primereact/divider";
import {useState} from "react"; import {useEffect, useState} from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs"; import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
@@ -17,6 +17,8 @@ import Input from "./Low/Input";
import ProfileSummary from "./ProfileSummary"; import ProfileSummary from "./ProfileSummary";
import Select from "react-select"; import Select from "react-select";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {USER_TYPE_LABELS} from "@/resources/user";
import {CURRENCIES} from "@/resources/paypal";
const expirationDateColor = (date: Date) => { const expirationDateColor = (date: Date) => {
const momentDate = moment(date); const momentDate = moment(date);
@@ -33,22 +35,106 @@ interface Props {
onClose: (reload?: boolean) => void; onClose: (reload?: boolean) => void;
onViewStudents?: () => void; onViewStudents?: () => void;
onViewTeachers?: () => void; onViewTeachers?: () => void;
onViewCorporate?: () => void;
disabled?: boolean;
} }
const UserCard = ({user, loggedInUser, 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 [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
const [referralAgent, setReferralAgent] = useState(user.corporateInformation?.referralAgent);
const [type, setType] = useState(user.type); const [type, setType] = useState(user.type);
const [status, setStatus] = useState(user.status); 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 {stats} = useStats(user.id);
const {users} = useUsers(); 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 = () => { 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; if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
axios axios
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {...user, subscriptionExpirationDate: expiryDate, type, status}) .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(() => { .then(() => {
toast.success("User updated successfully!"); toast.success("User updated successfully!");
onClose(true); onClose(true);
@@ -81,6 +167,164 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
]} ]}
/> />
{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"> <section className="flex flex-col gap-4 justify-between">
<div className="flex flex-col md:flex-row gap-8 w-full"> <div className="flex flex-col md:flex-row gap-8 w-full">
<Input <Input
@@ -119,34 +363,65 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
/> />
</div> </div>
{user.type === "student" && (
<Input
type="text"
name="passport_id"
label="Passport/National ID"
onChange={() => null}
placeholder="Enter National ID or Passport number"
value={user.type === "student" ? user.demographicInformation?.passport_id : undefined}
disabled
required
/>
)}
<div className="flex flex-col md:flex-row gap-8 w-full"> <div className="flex flex-col md:flex-row gap-8 w-full">
<div className="relative flex flex-col gap-3 w-full"> {user.type !== "corporate" && (
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label> <div className="relative flex flex-col gap-3 w-full">
<RadioGroup <label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
value={user.demographicInformation?.employment} <RadioGroup
className="grid grid-cols-2 items-center gap-4 place-items-center"> value={user.demographicInformation?.employment}
{EMPLOYMENT_STATUS.map(({status, label}) => ( className="grid grid-cols-2 items-center gap-4 place-items-center"
<RadioGroup.Option value={status} key={status}> disabled={disabled}>
{({checked}) => ( {EMPLOYMENT_STATUS.map(({status, label}) => (
<span <RadioGroup.Option value={status} key={status}>
className={clsx( {({checked}) => (
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", <span
"transition duration-300 ease-in-out", className={clsx(
!checked "px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
? "bg-white border-mti-gray-platinum" "transition duration-300 ease-in-out",
: "bg-mti-purple-light border-mti-purple-dark text-white", !checked
)}> ? "bg-white border-mti-gray-platinum"
{label} : "bg-mti-purple-light border-mti-purple-dark text-white",
</span> )}>
)} {label}
</RadioGroup.Option> </span>
))} )}
</RadioGroup> </RadioGroup.Option>
</div> ))}
</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="flex flex-col gap-8 w-full">
<div className="relative flex flex-col gap-3 w-full"> <div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender</label> <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"> <RadioGroup.Option value="male">
{({checked}) => ( {({checked}) => (
<span <span
@@ -196,7 +471,8 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label> <label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
<Checkbox <Checkbox
isChecked={!!expiryDate} isChecked={!!expiryDate}
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : undefined)}> onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
disabled={disabled}>
Enabled Enabled
</Checkbox> </Checkbox>
</div> </div>
@@ -229,77 +505,23 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
selected={moment(expiryDate).toDate()} selected={moment(expiryDate).toDate()}
onChange={(date) => setExpiryDate(date)} onChange={(date) => setExpiryDate(date)}
disabled={disabled}
/> />
)} )}
</div> </div>
</div> </div>
</div> </div>
{(loggedInUser.type === "developer" || loggedInUser.type === "owner") && ( {(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"> <div className="flex flex-col md:flex-row gap-8 w-full">
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Status</label> <label className="font-normal text-base text-mti-gray-dim">Status</label>
<select
defaultValue={user.status}
onChange={(e) => setStatus(e.target.value as typeof user.status)}
className="p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
<option value="active">Active</option>
<option value="disabled">Disabled</option>
<option value="paymentDue">Payment Due</option>
</select>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Type</label>
<select
defaultValue={user.type}
onChange={(e) => setType(e.target.value as typeof user.type)}
className="p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
<option value="student">Student</option>
<option value="teacher">Teacher</option>
<option value="corporate">Corporate</option>
<option value="agent">Country Agent</option>
<option value="owner">Owner</option>
<option value="developer">Developer</option>
</select>
</div>
</div>
</>
)}
{user.type === "corporate" && (
<>
<Divider className="w-full" />
<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}
/>
<Input
label="Amount of Users"
type="number"
name="userAmount"
onChange={() => null}
placeholder="Enter amount of users"
defaultValue={user.corporateInformation?.companyInformation.userAmount}
/>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country Agent</label>
<Select <Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none" className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={[ options={USER_STATUS_OPTIONS}
{value: "", label: "No referral"}, value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})), onChange={(value) => setStatus(value?.value as typeof user.status)}
]}
defaultValue={{
value: referralAgent,
label: referralAgent ? users.find((u) => u.id === referralAgent)?.name || "" : "No agent",
}}
onChange={(value) => setReferralAgent(value?.value)}
styles={{ styles={{
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
@@ -316,6 +538,33 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
color: state.isFocused ? "black" : styles.color, 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>
</div> </div>
@@ -325,6 +574,11 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
<div className="flex gap-4 justify-between mt-4 w-full"> <div className="flex gap-4 justify-between mt-4 w-full">
<div className="self-start flex gap-4 justify-start items-center 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 && ( {onViewStudents && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}> <Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
View Students View Students
@@ -340,7 +594,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}:
<Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}> <Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}>
Close Close
</Button> </Button>
<Button onClick={updateUser} className="w-full max-w-[200px]"> <Button disabled={disabled} onClick={updateUser} className="w-full max-w-[200px]">
Update Update
</Button> </Button>
</div> </div>

View File

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

View File

@@ -2,38 +2,38 @@ import {Type} from "@/interfaces/user";
export const PERMISSIONS = { export const PERMISSIONS = {
generateCode: { generateCode: {
student: ["corporate", "developer", "owner"], student: ["corporate", "developer", "admin"],
teacher: ["corporate", "developer", "owner"], teacher: ["corporate", "developer", "admin"],
corporate: ["owner", "developer"], corporate: ["admin", "developer"],
owner: ["developer", "owner"], admin: ["developer", "admin"],
agent: ["developer", "owner"], agent: ["developer", "admin"],
developer: ["developer"], developer: ["developer"],
}, },
deleteUser: { deleteUser: {
student: ["teacher", "corporate", "developer", "owner"], student: ["teacher", "corporate", "developer", "admin"],
teacher: ["corporate", "developer", "owner"], teacher: ["corporate", "developer", "admin"],
corporate: ["owner", "developer"], corporate: ["admin", "developer"],
owner: ["developer", "owner"], admin: ["developer", "admin"],
agent: ["developer", "owner"], agent: ["developer", "admin"],
developer: ["developer"], developer: ["developer"],
}, },
updateUser: { updateUser: {
student: ["teacher", "corporate", "developer", "owner"], student: ["teacher", "corporate", "developer", "admin"],
teacher: ["corporate", "developer", "owner"], teacher: ["corporate", "developer", "admin"],
corporate: ["owner", "developer"], corporate: ["admin", "developer"],
owner: ["developer", "owner"], admin: ["developer", "admin"],
agent: ["developer", "owner"], agent: ["developer", "admin"],
developer: ["developer"], developer: ["developer"],
}, },
updateExpiryDate: { updateExpiryDate: {
student: ["developer", "owner"], student: ["developer", "admin"],
teacher: ["developer", "owner"], teacher: ["developer", "admin"],
corporate: ["owner", "developer"], corporate: ["admin", "developer"],
owner: ["developer", "owner"], admin: ["developer", "admin"],
agent: ["developer", "owner"], agent: ["developer", "admin"],
developer: ["developer"], developer: ["developer"],
}, },
examManagement: { 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 {dateSorter} from "@/utils";
import moment from "moment"; import moment from "moment";
import {useEffect, useState} from "react"; 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 UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import IconCard from "./IconCard"; import IconCard from "./IconCard";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
interface Props { interface Props {
user: User; user: User;
} }
export default function OwnerDashboard({user}: Props) { export default function AdminDashboard({user}: Props) {
const [page, setPage] = useState(""); const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
@@ -24,18 +27,32 @@ export default function OwnerDashboard({user}: Props) {
const {stats} = useStats(user.id); const {stats} = useStats(user.id);
const {users, reload} = useUsers(); const {users, reload} = useUsers();
const {groups} = useGroups(); const {groups} = useGroups();
const { pending, done } = usePaymentStatusUsers();
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
useEffect(() => { useEffect(() => {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
}, [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) => ( const UserDisplay = (displayUser: User) => (
<div <div
onClick={() => setSelectedUser(displayUser)} 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"> className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" /> <img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start"> <div className="flex flex-col gap-1 items-start">
<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> <span className="text-sm opacity-75">{displayUser.email}</span>
</div> </div>
</div> </div>
@@ -48,7 +65,7 @@ export default function OwnerDashboard({user}: Props) {
? groups ? groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id)) .filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id) || false .includes(x.id)
: true); : true);
return ( return (
@@ -63,7 +80,7 @@ export default function OwnerDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2> <h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
</div> </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> <h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
</div> </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> <h2 className="text-2xl font-semibold">Corporate ({users.filter((x) => x.type === "corporate").length})</h2>
</div> </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 InactiveStudentsList = () => {
const filter = (x: User) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)); 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> <h2 className="text-2xl font-semibold">Inactive Students ({users.filter(filter).length})</h2>
</div> </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> <h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(filter).length})</h2>
</div> </div>
<UserList user={user} filter={filter} /> <UserList user={user} filters={[filter]} />
</> </>
); );
}; };
const DefaultDashboard = () => ( 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 <IconCard
Icon={BsPersonFill} Icon={BsPersonFill}
label="Students" label="Students"
@@ -162,19 +237,26 @@ export default function OwnerDashboard({user}: Props) {
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsPersonLinesFill} Icon={BsPencilSquare}
label="Teachers" label="Teachers"
value={users.filter((x) => x.type === "teacher").length} value={users.filter((x) => x.type === "teacher").length}
onClick={() => setPage("teachers")} onClick={() => setPage("teachers")}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsPersonLinesFill} Icon={BsBank}
label="Corporate" label="Corporate"
value={users.filter((x) => x.type === "corporate").length} value={users.filter((x) => x.type === "corporate").length}
onClick={() => setPage("corporate")} onClick={() => setPage("corporate")}
color="purple" color="purple"
/> />
<IconCard
Icon={BsBriefcaseFill}
label="Country Managers"
value={users.filter((x) => x.type === "agent").length}
onClick={() => setPage("agents")}
color="purple"
/>
<IconCard <IconCard
Icon={BsGlobeCentralSouthAsia} Icon={BsGlobeCentralSouthAsia}
label="Countries" label="Countries"
@@ -183,7 +265,7 @@ export default function OwnerDashboard({user}: Props) {
/> />
<IconCard <IconCard
onClick={() => setPage("inactiveStudents")} onClick={() => setPage("inactiveStudents")}
Icon={BsPerson} Icon={BsPersonFill}
label="Inactive Students" label="Inactive Students"
value={ value={
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate))) users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
@@ -191,9 +273,16 @@ export default function OwnerDashboard({user}: Props) {
} }
color="rose" color="rose"
/> />
<IconCard
onClick={() => setPage("inactiveCountryManagers")}
Icon={BsBriefcaseFill}
label="Inactive Country Managers"
value={users.filter(inactiveCountryManagerFilter).length}
color="rose"
/>
<IconCard <IconCard
onClick={() => setPage("inactiveCorporate")} onClick={() => setPage("inactiveCorporate")}
Icon={BsPerson} Icon={BsBank}
label="Inactive Corporate" label="Inactive Corporate"
value={ value={
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate))) users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
@@ -201,6 +290,20 @@ export default function OwnerDashboard({user}: Props) {
} }
color="rose" color="rose"
/> />
<IconCard
onClick={() => setPage("paymentdone")}
Icon={BsCurrencyDollar}
label="Payment Done"
value={done.length}
color="purple"
/>
<IconCard
onClick={() => setPage("paymentpending")}
Icon={BsCurrencyDollar}
label="Pending Payment"
value={pending.length}
color="rose"
/>
</section> </section>
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
@@ -253,7 +356,7 @@ export default function OwnerDashboard({user}: Props) {
</div> </div>
</div> </div>
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">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"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter( .filter(
@@ -297,7 +400,7 @@ export default function OwnerDashboard({user}: Props) {
</div> </div>
</div> </div>
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Teachers</span> <span className="p-4">Expired Country Manager</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter( .filter(
@@ -338,9 +441,65 @@ export default function OwnerDashboard({user}: Props) {
if (shouldReload) reload(); if (shouldReload) reload();
}} }}
onViewStudents={ 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} user={selectedUser}
/> />
</div> </div>
@@ -350,8 +509,12 @@ export default function OwnerDashboard({user}: Props) {
{page === "students" && <StudentsList />} {page === "students" && <StudentsList />}
{page === "teachers" && <TeachersList />} {page === "teachers" && <TeachersList />}
{page === "corporate" && <CorporateList />} {page === "corporate" && <CorporateList />}
{page === "agents" && <AgentsList />}
{page === "inactiveStudents" && <InactiveStudentsList />} {page === "inactiveStudents" && <InactiveStudentsList />}
{page === "inactiveCorporate" && <InactiveCorporateList />} {page === "inactiveCorporate" && <InactiveCorporateList />}
{page === "inactiveCountryManagers" && <InactiveCountryManagerList />}
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
{page === "" && <DefaultDashboard />} {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 useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {Assignment} from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import {Stat} from "@/interfaces/user";
import {calculateBandScore} from "@/utils/score"; import {calculateBandScore} from "@/utils/score";
import clsx from "clsx"; import clsx from "clsx";
import moment from "moment"; import moment from "moment";
import {useState} from "react";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import { usePDFDownload } from "@/hooks/usePDFDownload";
interface Props { interface Props {
onClick?: () => void; onClick?: () => void;
allowDownload?: boolean;
} }
export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick}: Assignment & Props) { export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick, allowDownload}: Assignment & Props) {
const {users} = useUsers(); const {users} = useUsers();
const renderPdfIcon = usePDFDownload("assignments");
const calculateAverageModuleScore = (module: Module) => { const calculateAverageModuleScore = (module: Module) => {
const resultModuleBandScores = results.map((r) => { const resultModuleBandScores = results.map((r) => {
@@ -33,7 +34,10 @@ export default function AssignmentCard({id, name, assigner, startDate, endDate,
onClick={onClick} onClick={onClick}
className="w-[350px] h-fit flex flex-col gap-6 bg-white border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"> className="w-[350px] h-fit flex flex-col gap-6 bg-white border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h3 className="font-semibold text-xl">{name}</h3> <div className="flex flex-row justify-between">
<h3 className="font-semibold text-xl">{name}</h3>
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
</div>
<ProgressBar <ProgressBar
color={results.length / assignees.length < 0.5 ? "red" : "purple"} color={results.length / assignees.length < 0.5 ? "red" : "purple"}
percentage={(results.length / assignees.length) * 100} percentage={(results.length / assignees.length) * 100}

View File

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

View File

@@ -19,7 +19,7 @@ import {
BsPersonFill, BsPersonFill,
BsPersonFillGear, BsPersonFillGear,
BsPersonGear, BsPersonGear,
BsPersonLinesFill, BsPencilSquare,
} from "react-icons/bs"; } from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
@@ -29,6 +29,8 @@ import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats"; import {groupByExam} from "@/utils/stats";
import IconCard from "./IconCard"; import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList"; import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
interface Props { interface Props {
user: User; user: User;
@@ -43,6 +45,9 @@ export default function CorporateDashboard({user}: Props) {
const {users, reload} = useUsers(); const {users, reload} = useUsers();
const {groups} = useGroups(user.id); const {groups} = useGroups(user.id);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
useEffect(() => { useEffect(() => {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
}, [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> <h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
</div> </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> <h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
</div> </div>
<UserList user={user} filter={filter} /> <UserList user={user} filters={[filter]} />
</> </>
); );
}; };
@@ -165,7 +170,7 @@ export default function CorporateDashboard({user}: Props) {
/> />
<IconCard <IconCard
onClick={() => setPage("teachers")} onClick={() => setPage("teachers")}
Icon={BsPersonLinesFill} Icon={BsPencilSquare}
label="Teachers" label="Teachers"
value={users.filter(teacherFilter).length} value={users.filter(teacherFilter).length}
color="purple" color="purple"
@@ -256,9 +261,45 @@ export default function CorporateDashboard({user}: Props) {
if (shouldReload) reload(); if (shouldReload) reload();
}} }}
onViewStudents={ 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} user={selectedUser}
/> />
</div> </div>

View File

@@ -19,7 +19,7 @@ export default function IconCard({Icon, label, value, color, onClick}: Props) {
return ( return (
<div <div
onClick={onClick} 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])} /> <Icon className={clsx("text-6xl", colorClasses[color])} />
<span className="flex flex-col gap-1 items-center text-xl"> <span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">{label}</span> <span className="text-lg">{label}</span>

View File

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

View File

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

View File

@@ -40,3 +40,15 @@ export function prepareMailOptions(context: object, to: string[], subject: strin
context, context,
}; };
} }
export async function sendEmail(template: string, context: object, to: string[], subject: string): Promise<boolean> {
try {
const transport = prepareMailer(template);
const mailOptions = prepareMailOptions(context, to, subject, template);
await transport.sendMail(mailOptions);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
</head>
<div>
<p>Hello {{user.name}},</p>
<br />
<p>You have just been given the assignment <b>"{{assignment.name}}"</b> by your teacher {{assignment.assigner}}!</p>
<br />
<p>It's start date will be on <b>{{assignment.startDate}}</b> and will only last until <b>{{assignment.endDate}}</b>
</p>
<br />
<p>For this assignment, you've been tasked with completing exams of the following modules:
<b>{{assignment.modules}}</b>.
</p>
<br />
<p>Don't forget to do it before its end date!</p>
<p>Click <b><a href="https://platform.encoach.com">here</a></b> to open the EnCoach Platform!</p>
<br />
<p>Thanks,</p>
<p>Your EnCoach team</p>
</div>
</html>

View File

@@ -0,0 +1,13 @@
{
"user": {
"name": "Tiago Ribeiro"
},
"assignment": {
"name": "Final Exam",
"assigner": "Teacher",
"assignees": [],
"modules": "Reading and Writing",
"startDate": "24/12/2023",
"endDate": "27/01/2024"
}
}

View File

@@ -10,6 +10,8 @@ import Link from "next/link";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {Fragment, useEffect, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import {BsArrowCounterclockwise, BsBook, BsClipboard, 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 { interface Score {
module: Module; module: Module;
@@ -66,6 +68,22 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
return exam.exercises.length; 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 ( return (
<> <>
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8"> <div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8">
@@ -136,14 +154,16 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
{isLoading && ( {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"> <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("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> </div>
)} )}
{!isLoading && ( {!isLoading && (
<div className="w-full flex gap-9 mt-32 items-center justify-between mb-20"> <div className="w-full flex gap-9 mt-32 items-center justify-between mb-20">
<span className="max-w-3xl"> <span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
{moduleResultText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))}
</span>
<div className="flex gap-9 px-16"> <div className="flex gap-9 px-16">
<div <div
className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)} className={clsx("radial-progress overflow-hidden", moduleColors[selectedModule].progress)}
@@ -156,9 +176,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
moduleColors[selectedModule].inner, moduleColors[selectedModule].inner,
)}> )}>
<span className="text-xl">Level</span> <span className="text-xl">Level</span>
<span className="text-3xl font-bold"> {showLevel(bandScore)}
{calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus)}
</span>
</div> </div>
</div> </div>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">

View File

@@ -19,15 +19,28 @@ interface Props {
} }
export default function Level({exam, showSolutions = false, onFinish}: Props) { 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 [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam))); const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]); 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) => { const nextExercise = (solution?: UserSolution) => {
if (solution) { if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
} }
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.exercises.length) { if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1); setExerciseIndex((prev) => prev + 1);
@@ -70,7 +83,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
<div className="flex flex-col h-full w-full gap-8 items-center"> <div className="flex flex-col h-full w-full gap-8 items-center">
<ModuleTitle <ModuleTitle
minTimer={exam.minTimer} minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1} exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
module="level" module="level"
totalExercises={countExercises(exam.exercises)} totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions} disableTimer={showSolutions}
@@ -78,11 +91,11 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length && exerciseIndex < exam.exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)} renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length && exerciseIndex < exam.exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)} renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div> </div>
</> </>
); );

View File

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

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

View File

@@ -20,14 +20,21 @@ interface Props {
} }
export default function Speaking({exam, showSolutions = false, onFinish}: 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 [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam))); const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]); const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
if (solution) { if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
} }
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (exerciseIndex + 1 < exam.exercises.length) { if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1); setExerciseIndex((prev) => prev + 1);
@@ -71,7 +78,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
<ModuleTitle <ModuleTitle
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)} label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
minTimer={exam.minTimer} minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1} exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
module="speaking" module="speaking"
totalExercises={countExercises(exam.exercises)} totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions} disableTimer={showSolutions}
@@ -79,11 +86,11 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length && exerciseIndex < exam.exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)} renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length && exerciseIndex < exam.exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)} renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
</div> </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 {initializeApp} from "firebase/app";
import * as admin from "firebase-admin/app"; import * as admin from "firebase-admin/app";
import { getStorage } from "firebase/storage";
const serviceAccount = require("@/constants/serviceAccountKey.json"); const serviceAccount = require("@/constants/serviceAccountKey.json");
@@ -10,7 +11,6 @@ const firebaseConfig = {
storageBucket: process.env.FIREBASE_STORAGE_BUCKET || "", storageBucket: process.env.FIREBASE_STORAGE_BUCKET || "",
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID || "", messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID || "",
appId: process.env.FIREBASE_APP_ID || "", appId: process.env.FIREBASE_APP_ID || "",
measurementId: process.env.FIREBASE_MEASUREMENT_ID || "",
}; };
export const app = initializeApp(firebaseConfig, Math.random().toString()); export const app = initializeApp(firebaseConfig, Math.random().toString());
@@ -20,3 +20,4 @@ export const adminApp = admin.initializeApp(
}, },
Math.random().toString(), 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(() => { useEffect(() => {
setIsLoading(true); setIsLoading(true);
axios axios
.get<Stat[]>(!id ? "/api/stats" : `/api/stats/${id}`) .get<Stat[]>(!id ? "/api/stats" : `/api/stats/user/${id}`)
.then((response) => setStats(response.data)) .then((response) => setStats(response.data))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}, [id]); }, [id]);

View File

@@ -15,10 +15,10 @@ export default function useUser({redirectTo = "", redirectIfFound = false} = {})
if (!redirectTo || !user) return; if (!redirectTo || !user) return;
if ( if (
// If redirectTo is set, redirect if the user was not found.
(redirectTo && !redirectIfFound && (!user || (user && !user.isVerified))) ||
// If redirectIfFound is also set, redirect if the user was found // If redirectIfFound is also set, redirect if the user was found
(redirectIfFound && user && user.isVerified) (redirectIfFound && user && user.isVerified) ||
// If redirectTo is set, redirect if the user was not found.
(redirectTo && !redirectIfFound && (!user || (user && !user.isVerified)))
) { ) {
Router.push(redirectTo); Router.push(redirectTo);
} }

View File

@@ -1,20 +1,24 @@
import {Module} from "."; import {Module} from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
export type Variant = "diagnostic" | "partial";
export interface ReadingExam { export interface ReadingExam {
parts: { parts: ReadingPart[];
text: {
title: string;
content: string;
};
exercises: Exercise[];
}[];
id: string; id: string;
module: "reading"; module: "reading";
minTimer: number; minTimer: number;
type: "academic" | "general"; type: "academic" | "general";
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant;
}
export interface ReadingPart {
text: {
title: string;
content: string;
};
exercises: Exercise[];
} }
export interface LevelExam { export interface LevelExam {
@@ -23,23 +27,28 @@ export interface LevelExam {
exercises: Exercise[]; exercises: Exercise[];
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant;
} }
export interface ListeningExam { export interface ListeningExam {
parts: { parts: ListeningPart[];
audio: {
source: string;
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
};
exercises: Exercise[];
}[];
id: string; id: string;
module: "listening"; module: "listening";
minTimer: number; minTimer: number;
isDiagnostic: boolean; 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 { export interface UserSolution {
id?: string;
solutions: any[]; solutions: any[];
module?: Module; module?: Module;
exam?: string; exam?: string;
@@ -58,6 +67,7 @@ export interface WritingExam {
exercises: Exercise[]; exercises: Exercise[];
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant;
} }
interface WordCounter { interface WordCounter {
@@ -71,6 +81,7 @@ export interface SpeakingExam {
exercises: Exercise[]; exercises: Exercise[];
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant;
} }
export type Exercise = export type Exercise =
@@ -87,7 +98,21 @@ export interface Evaluation {
comment: string; comment: string;
overall: number; overall: number;
task_response: {[key: string]: number}; task_response: {[key: string]: number};
misspelled_pairs?: {correction: string | null; misspelled: string}[];
} }
interface InteractiveSpeakingEvaluation extends Evaluation {
perfect_answer_1?: string;
perfect_answer_2?: string;
perfect_answer_3?: string;
}
interface CommonEvaluation extends Evaluation {
perfect_answer?: string;
perfect_answer_1?: string;
fixed_text?: string;
}
export interface WritingExercise { export interface WritingExercise {
id: string; id: string;
type: "writing"; type: "writing";
@@ -102,7 +127,7 @@ export interface WritingExercise {
userSolutions: { userSolutions: {
id: string; id: string;
solution: string; solution: string;
evaluation?: Evaluation; evaluation?: CommonEvaluation;
}[]; }[];
} }
@@ -116,7 +141,7 @@ export interface SpeakingExercise {
userSolutions: { userSolutions: {
id: string; id: string;
solution: string; solution: string;
evaluation?: Evaluation; evaluation?: CommonEvaluation;
}[]; }[];
} }
@@ -129,7 +154,7 @@ export interface InteractiveSpeakingExercise {
userSolutions: { userSolutions: {
id: string; id: string;
solution: {question: string; answer: string}[]; solution: {question: string; answer: string}[];
evaluation?: Evaluation; evaluation?: InteractiveSpeakingEvaluation;
}[]; }[];
} }

View File

@@ -0,0 +1,22 @@
import {Module} from "@/interfaces";
export interface ModuleScore {
score: number;
total: number;
code: Module;
module: Module | 'Overall';
png?: string,
evaluation?: string,
suggestions?: string,
}
export interface StudentData {
id: string;
name: string;
email: string;
gender: string;
date: string;
result: string;
level?: string;
bandScore: number;
}

37
src/interfaces/paypal.ts Normal file
View File

@@ -0,0 +1,37 @@
export interface TokenSuccess {
scope: string;
access_token: string;
token_type: string;
app_id: string;
expires_in: number;
nonce: string;
}
export interface TokenError {
error: string;
error_description: string;
}
export interface Package {
id: string;
currency: string;
duration: number;
duration_unit: DurationUnit;
price: number;
}
export type DurationUnit = "weeks" | "days" | "months" | "years";
export interface Payment {
id: string;
corporate: string;
agent?: string;
agentCommission: number;
agentValue: number;
currency: string;
value: number;
isPaid: boolean;
date: Date | string;
corporateTransfer?: string;
commissionTransfer?: string;
}

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