Compare commits

...

208 Commits

Author SHA1 Message Date
Tiago Ribeiro
720597e916 Merged develop into ENCOA-83_MasterStatistical 2024-08-21 16:18:37 +00:00
carlos.mesquita
e74ded676e Merged in feature/level-file-upload (pull request #77)
Forgot to check if exam had shuffled enabled

Approved-by: Tiago Ribeiro
2024-08-21 16:18:23 +00:00
carlos.mesquita
ee60eedd0d Merged develop into feature/level-file-upload 2024-08-21 12:03:57 +00:00
Carlos Mesquita
c37a1becbf Forgot to check if exam had shuffled enabled 2024-08-21 13:02:30 +01:00
João Ramos
b9cca483ec Merged develop into ENCOA-83_MasterStatistical 2024-08-20 23:39:57 +00:00
Joao Ramos
c758bdaf9e Merge branch 'ENCOA-83_MasterStatistical' of https://bitbucket.org/ecropdev/ielts-ui into ENCOA-83_MasterStatistical 2024-08-21 00:39:22 +01:00
Joao Ramos
5ada588b16 Reverted minor change 2024-08-21 00:38:26 +01:00
Joao Ramos
eec1bb0c30 Added final touches 2024-08-21 00:37:33 +01:00
Joao Ramos
65f8368708 Initial draft of level test report 2024-08-20 23:40:54 +01:00
Tiago Ribeiro
806e621c5b Updated the record for students 2024-08-20 22:43:34 +01:00
carlos.mesquita
ce35b23714 Merged in feature/level-file-upload (pull request #75)
Part intro's, modals between parts and some fixes

Approved-by: Tiago Ribeiro
2024-08-20 21:18:22 +00:00
Carlos Mesquita
2cd025b118 Patched part divider, forgot to check if the exam had field intro 2024-08-20 20:14:42 +01:00
Carlos Mesquita
2e699d7e25 Forgot to save TextComponent, and cleanup some warnings 2024-08-20 20:02:59 +01:00
Carlos Mesquita
30da295c60 Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-20 19:37:06 +01:00
Carlos Mesquita
a82a399d52 Merge branch 'feature/level-file-upload' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-20 18:53:25 +01:00
Carlos Mesquita
505df31d6b Part intro's, modals between parts and some fixes 2024-08-20 18:52:38 +01:00
Tiago Ribeiro
a4d8ba72af Disallowed the name to be equal to Corporate 2024-08-20 17:41:06 +01:00
Tiago Ribeiro
2bfd0cb502 More stuff related to the groups 2024-08-20 17:38:48 +01:00
Tiago Ribeiro
5ee071028c Removed a WIP page 2024-08-20 17:14:54 +01:00
Tiago Ribeiro
23b9452a3a Another bug solved 2024-08-20 17:12:10 +01:00
Tiago Ribeiro
0ce3a16d3a Resolved another bug related to master corporate groups 2024-08-20 16:29:55 +01:00
Tiago Ribeiro
4315a7b17c Corrected a bug related to groups of a master corporate 2024-08-20 14:37:33 +01:00
Tiago Ribeiro
247f192a0a Added a scroll to the selection 2024-08-20 12:43:34 +01:00
João Ramos
9c944ae3d2 Merged in ENCOA-83_MasterStatistical (pull request #74)
ENCOA-83 MasterStatistical

Approved-by: Tiago Ribeiro
2024-08-20 10:14:19 +00:00
Tiago Ribeiro
a390aa429d Merged develop into ENCOA-83_MasterStatistical 2024-08-20 10:13:42 +00:00
Joao Ramos
3367384791 Merge branch 'ENCOA-83_MasterStatistical' of https://bitbucket.org/ecropdev/ielts-ui into ENCOA-83_MasterStatistical 2024-08-20 11:12:05 +01:00
Joao Ramos
158324a705 Added second excel for master corporate export 2024-08-20 11:10:51 +01:00
Tiago Ribeiro
f9286d1793 Updated the Dockerfile after having upgraded NextJS 2024-08-20 10:47:56 +01:00
João Ramos
2e376c37dd Merged in ENCOA-83_MasterStatistical (pull request #73)
ENCOA-83 MasterStatistical

Approved-by: Tiago Ribeiro
2024-08-20 09:33:42 +00:00
Tiago Ribeiro
5bda9ed227 Merge branch 'develop' into ENCOA-83_MasterStatistical 2024-08-20 10:32:19 +01:00
Tiago Ribeiro
97b533bd3a Took care of some warnings 2024-08-20 10:07:18 +01:00
Tiago Ribeiro
75a45108a2 Upgraded @types/react and @types/react-dom 2024-08-20 09:52:22 +01:00
Tiago Ribeiro
bfc0def20f Updated the exam list to be visible 2024-08-20 09:35:13 +01:00
Joao Ramos
9db33e6a51 Fixed date usage 2024-08-20 09:17:48 +01:00
Joao Ramos
ba5d926659 Merge branch 'develop' into ENCOA-83_MasterStatistical 2024-08-20 01:16:46 +01:00
Joao Ramos
1cd4dfc397 Added download option to master Corporate and teacher 2024-08-20 01:15:43 +01:00
Joao Ramos
bf5dd62b35 Updated Excel document 2024-08-20 01:01:50 +01:00
Tiago Ribeiro
4e583d11b6 Improved the bug on the teachers 2024-08-20 00:10:26 +01:00
Tiago Ribeiro
688505b4eb Updated the permissions access 2024-08-20 00:01:59 +01:00
carlos.mesquita
81b8ceb2b3 Merged in feature/level-file-upload (pull request #72)
Fixed question numbers for fillBlanks exercises, reverted multipleChoice underline prompt, added part label to module title, and changed some styles
2024-08-19 22:48:26 +00:00
carlos.mesquita
d93d36c392 Merged develop into feature/level-file-upload 2024-08-19 22:47:48 +00:00
Carlos Mesquita
3299acee36 Forgot to add a key in Level 2024-08-19 23:46:51 +01:00
Carlos Mesquita
abddead402 Fixed question numbers for fillBlanks exercises, reverted multipleChoice underline prompt, added part label to module title, and changed some styles 2024-08-19 23:43:08 +01:00
Joao Ramos
2d69fdac3c Added missing updated lockfile 2024-08-19 23:39:38 +01:00
Joao Ramos
506ff2503e Merge branch 'develop' into ENCOA-83_MasterStatistical 2024-08-19 23:38:46 +01:00
Tiago Ribeiro
5d191730d2 Added the total of assignments to the Master Corporate 2024-08-19 23:04:13 +01:00
Tiago Ribeiro
346b131388 Improved the sorting and filtering for the Student Performance page 2024-08-19 19:46:43 +01:00
carlos.mesquita
aba49e385f Merged in feature/level-file-upload (pull request #71)
Previously commented a required line
2024-08-19 16:25:52 +00:00
Carlos Mesquita
5789688eab Previously commented a required line 2024-08-19 17:24:10 +01:00
Tiago Ribeiro
f7da11bc69 Updated the way groups work there 2024-08-19 17:03:33 +01:00
carlos.mesquita
10802f6bb5 Merged in feature/level-file-upload (pull request #70)
Feature/level file upload
2024-08-19 15:55:10 +00:00
Carlos Mesquita
37e356572b Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-19 16:53:39 +01:00
Carlos Mesquita
8669ef462d Commented all related to shuffle 2024-08-19 16:42:14 +01:00
Tiago Ribeiro
df1c0bad4d Created a student performance page 2024-08-19 16:41:35 +01:00
Carlos Mesquita
bcb1a0f914 If someone else wants to join in on the fun be my guest 2024-08-19 01:24:55 +01:00
Joao Ramos
bf1bdd935c Improvements on excel rendering 2024-08-18 21:25:18 +01:00
Carlos Mesquita
edc9d4de2a Fill Blanks changes 2024-08-18 08:07:16 +01:00
Tiago Ribeiro
229275aaee Created a groups page for students and teachers 2024-08-17 20:18:28 +01:00
Tiago Ribeiro
f0ff6ac691 ENCOA-87: Allow MasterCorporate & Corporate to change the type of students and teachers 2024-08-17 19:15:20 +01:00
Tiago Ribeiro
878c7c2ef0 Updated the Groups List to allow teachers to view their corporate's students 2024-08-16 11:50:27 +01:00
Tiago Ribeiro
0a28c2bd41 Added a "last login" to the users 2024-08-15 23:55:08 +01:00
Tiago Ribeiro
38e48c90bb Updated the label for Admin 2024-08-15 23:37:34 +01:00
Tiago Ribeiro
c6f35d7750 Enable the option to not have only full exams 2024-08-15 19:25:39 +01:00
Tiago Ribeiro
85f684dff5 Updated the user balance to be based not only on the amount of codes 2024-08-15 19:24:08 +01:00
Tiago Ribeiro
d94a9bb88a Quick little fix 2024-08-15 18:39:17 +01:00
Joao Ramos
1950d5f15d Added initial Excel changes 2024-08-15 14:56:14 +01:00
Joao Ramos
e84cc8ddd8 Cleaned up some code 2024-08-15 13:39:42 +01:00
Joao Ramos
cf2fd06d39 Added Icon ofr consolidate highest student 2024-08-15 10:38:56 +01:00
Joao Ramos
b6015b6433 Added any to error to prevent an error 2024-08-15 10:35:08 +01:00
Joao Ramos
fea58a7b40 Added initial implementation for master statistical 2024-08-15 10:34:31 +01:00
João Ramos
13284eab75 Merged in ENCOA-82_Permissions (pull request #69)
Updated permissions to have a key to group them
2024-08-14 19:38:30 +00:00
Tiago Ribeiro
dd4e3a4694 Merged develop into ENCOA-82_Permissions 2024-08-14 19:38:16 +00:00
Tiago Ribeiro
eb55e65d91 Solved some issues related to the BatchCreateUser 2024-08-14 20:36:47 +01:00
Joao Ramos
cb75ba6056 Updated permissions to have a key to group them 2024-08-14 18:44:35 +01:00
João Ramos
859d9283a7 Merged in ENCOA-77_GenerationTitle (pull request #64)
Added title to the exam generate

Approved-by: Tiago Ribeiro
2024-08-13 21:40:04 +00:00
Tiago Ribeiro
1a3437b333 Merged develop into ENCOA-77_GenerationTitle 2024-08-13 21:38:56 +00:00
João Ramos
bbbf17daa0 Merged in ENCOA-79_PaymentRecordsFilters (pull request #61)
ENCOA-79 PaymentRecordsFilters

Approved-by: Tiago Ribeiro
2024-08-13 21:38:38 +00:00
Joao Ramos
ae79aef132 Merge branch 'develop' into ENCOA-79_PaymentRecordsFilters 2024-08-13 21:32:33 +01:00
Joao Ramos
c3e71b4389 Merge branch 'develop' into ENCOA-77_GenerationTitle 2024-08-13 21:30:50 +01:00
Tiago Ribeiro
2784117862 Updated the MasterCorporate and Corporate pages to allow to have Assignments 2024-08-13 10:02:40 +01:00
Tiago Ribeiro
8162567e12 Updated the Batch Create User to also have an expiry date 2024-08-12 19:49:18 +01:00
Tiago Ribeiro
58300e32ff Solved an issue where, for developers, because of the amount of permissions, the cookie was too big, so I separated the permissions logic into a hook 2024-08-12 19:35:11 +01:00
carlos.mesquita
cb489bf0ca Merged in feature/training-content (pull request #67)
Tooltips for assessment criteria on Writing and Speaking

Approved-by: Tiago Ribeiro
2024-08-12 14:13:28 +00:00
Tiago Ribeiro
91bc91e725 Merged develop into feature/training-content 2024-08-12 14:12:55 +00:00
João Ramos
ce086a8b22 Merged in ENCOA-81_CodeListPermissions (pull request #68)
ENCOA-81: Fixed issue with the users that each role could create

Approved-by: Tiago Ribeiro
2024-08-12 14:12:25 +00:00
Joao Ramos
6e71ee7cb0 ENCOA-81: Fixed issue with the users that each role could create 2024-08-08 09:17:33 +01:00
Carlos Mesquita
21e58e3b9c Tooltips for assessment criteria on Writing and Speaking 2024-08-07 13:04:42 +01:00
João Ramos
b885dd46b5 Merged in ENCOA-80_Permissions (pull request #66)
ENCOA-80 Permissions

Approved-by: Tiago Ribeiro
2024-08-07 09:30:04 +00:00
Joao Ramos
0fc2df1070 Added permission to codes 2024-08-07 10:03:17 +01:00
Joao Ramos
cf91f1812d Added group permissions 2024-08-07 09:36:08 +01:00
Tiago Ribeiro
3289f27cd5 Merged in settings-import-users (pull request #65)
Settings import users
2024-08-07 06:48:37 +00:00
Tiago Ribeiro
80939d16a5 Merged develop into settings-import-users 2024-08-07 06:47:59 +00:00
carlos.mesquita
11b5490af4 Merged in feature/training-content (pull request #62)
Feature/training content

Approved-by: Tiago Ribeiro
2024-08-06 18:46:43 +00:00
João Ramos
a31070d4a3 Merged in ENCOA-78_ExamList (pull request #63)
Added more columns to exam list

Approved-by: Tiago Ribeiro
2024-08-06 18:46:20 +00:00
Joao Ramos
95c3f89911 Added title to the exam generate 2024-08-06 19:24:43 +01:00
Joao Ramos
2a58e0d33f Added more columns to exam list 2024-08-06 18:40:49 +01:00
mohammedzeglam-pg
afe59f5a3a add group creation. 2024-08-06 18:03:00 +02:00
Carlos Mesquita
7fd56357e0 UI changes to Solutions (Writing, Speaking and Interactive Speaking) as requested 2024-08-06 17:02:17 +01:00
Carlos Mesquita
a4a40b9145 Bug fixes to training, added a spinner to record while it loads, made changes to speaking as requested 2024-08-05 21:44:14 +01:00
Joao Ramos
48faee07f6 Added missing fussy search fileds and support for number 2024-08-05 19:32:53 +01:00
Joao Ramos
f0d7d7644b Added date filter 2024-08-05 19:26:50 +01:00
Carlos Mesquita
309dfba583 Fixed the training content view 2024-08-05 10:41:11 +01:00
carlos.mesquita
cf64a91651 Merged in feature/training-content (pull request #60)
Patched the module badges on the training view
2024-08-03 14:23:05 +00:00
Carlos Mesquita
0f47a8af70 Merge branch 'feature/training-content' of https://bitbucket.org/ecropdev/ielts-ui into feature/training-content 2024-08-03 14:33:55 +01:00
Carlos Mesquita
d0310f7c2b Patched the module badges on the training view 2024-08-03 14:32:54 +01:00
carlos.mesquita
f6a0a391b9 Merged in feature/training-content (pull request #59)
Dropdown on training view
2024-08-03 10:13:44 +00:00
Tiago Ribeiro
8dd4dad096 Merged develop into feature/training-content 2024-08-03 10:13:37 +00:00
Carlos Mesquita
96baa2a6e0 Dropdown on training view 2024-08-03 11:10:01 +01:00
carlos.mesquita
8dd557a29b Merged in feature/training-content (pull request #58)
Feature/training content

Approved-by: Tiago Ribeiro
2024-08-03 09:49:00 +00:00
mzerone
4e30eda06f make user verified by default. 2024-08-03 01:37:41 +02:00
Carlos Mesquita
12bb124d91 Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into feature/training-content 2024-08-02 15:01:05 +01:00
Carlos Mesquita
a71e6632d6 Hooked training content to backend, did refactors to training components and record.tsx 2024-08-01 20:40:31 +01:00
mzerone
36f518afca add create user in settings. 2024-08-01 00:41:35 +02:00
Carlos Mesquita
a534126c61 training.tsx still a bit messy, all that is left is to retrieve data from firestore /training and /walkthrough and render it 2024-07-31 20:44:46 +01:00
Tiago Ribeiro
752a46b247 Updated the LevelGeneration to be enabled by default 2024-07-31 19:15:36 +01:00
Tiago Ribeiro
663b1aae4f Updated the yarn.lock 2024-07-31 10:42:47 +01:00
Tiago Ribeiro
9b37b60be0 Improved the table 2024-07-30 23:22:30 +01:00
Tiago Ribeiro
4347d0cabb Merge branch 'develop' into feature/update-permission-ui 2024-07-30 23:20:16 +01:00
Tiago Ribeiro
0403773b8e Changed the IDs to now be words and allows the assignment to be like chosen 2024-07-30 23:18:50 +01:00
mzerone
8d99a6b03c ui updates for permissions. 2024-07-29 16:16:14 +02:00
Tiago Ribeiro
02320b9484 Tiny issue 2024-07-28 11:22:46 +01:00
Tiago Ribeiro
fb077fd8cc Updated the multiple choice 2024-07-27 17:43:26 +01:00
Tiago Ribeiro
b5a305485f Finalized the reading generation 2024-07-27 14:55:48 +01:00
Tiago Ribeiro
8f5b27e9ce Updated the FillBlanks to the new format 2024-07-27 14:38:45 +01:00
Tiago Ribeiro
9ef04b822a Updated the design of the feedback 2024-07-27 00:04:52 +01:00
Tiago Ribeiro
a6160c3cf0 Updated and fixed the level generation 2024-07-26 23:43:23 +01:00
Tiago Ribeiro
8f6639b7fc Finalized the Level Generation 2024-07-26 14:09:08 +01:00
Tiago Ribeiro
6a803fe137 Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-07-26 11:51:03 +01:00
Carlos Mesquita
d7f6a4dde7 Undefined was causing record page to crash 2024-07-26 11:00:18 +01:00
Tiago Ribeiro
6058e510de Added the ability to generate custom level exams, still WIP in some parts 2024-07-26 10:29:36 +01:00
carlos.mesquita
7208530879 Merged in feature/ai-detection (pull request #57)
Used main branch as base branch in the last time

Approved-by: Tiago Ribeiro
2024-07-25 21:01:56 +00:00
Tiago Ribeiro
9b6c545932 Merged develop into feature/ai-detection 2024-07-25 21:00:40 +00:00
João Ramos
afb9071758 Merged in ENCOA-56_Permissions (pull request #55)
ENCOA-56 Permissions

Approved-by: Tiago Ribeiro
2024-07-25 21:00:28 +00:00
Tiago Ribeiro
d50393930e Merged develop into ENCOA-56_Permissions 2024-07-25 21:00:19 +00:00
João Ramos
03e1f2cfa3 Merged in master-corporate (pull request #54)
Master corporate

Approved-by: Tiago Ribeiro
2024-07-25 21:00:04 +00:00
Carlos Mesquita
877d2f359f Used main branch as base branch in the last time 2024-07-25 16:59:15 +01:00
Joao Ramos
45df9837e7 Added permissions to filter out the user update 2024-07-25 11:23:11 +01:00
Joao Ramos
923319051c Added code role validation 2024-07-25 09:43:11 +01:00
Joao Ramos
f6b4d6ad52 Initial approach where I replaced all the entries for checkAccess 2024-07-25 09:13:13 +01:00
Joao Ramos
19d16c9cef Added new permission system 2024-07-24 18:52:02 +01:00
Joao Ramos
daa27e41b3 Merge branch 'develop' into master-corporate 2024-07-24 09:50:53 +01:00
Joao Ramos
916fa66446 Added linked account 2024-07-24 09:25:29 +01:00
Tiago Ribeiro
10a3243756 Updated it to work with the new canges 2024-07-23 14:43:24 +01:00
Tiago Ribeiro
a1c7f70329 Solved some small issues 2024-07-22 23:35:22 +01:00
Tiago Ribeiro
bd2efb0ef5 Updated the color of the task response 2024-07-22 23:30:22 +01:00
Joao Ramos
34065f1f6e Merge branch 'develop' into master-corporate 2024-07-20 12:38:52 +01:00
Tiago Ribeiro
41873f80d7 ENCOA-66: Payment record not sorting 2024-07-18 15:45:28 +01:00
João Ramos
a1b67c017d Merged in ENCOA-57_EditExamsGenerate (pull request #53)
ENCOA-57 EditExamsGenerate

Approved-by: Tiago Ribeiro
2024-07-18 09:32:05 +00:00
Joao Ramos
13fd7e1ee5 Updated Level Generation 2024-07-17 23:44:53 +01:00
Joao Ramos
4996417218 Fixed sentence header mapping 2024-07-17 23:00:16 +01:00
Joao Ramos
60d436b5b9 Fixed wrong count 2024-07-13 17:25:10 +01:00
Joao Ramos
8d39a20267 Fixed broken list 2024-07-13 17:22:55 +01:00
Joao Ramos
5d46d7e453 Added initial support for "mastercorporate" 2024-07-13 17:19:42 +01:00
Joao Ramos
15f9fb320d Changed approach on available type selection 2024-07-09 23:42:16 +01:00
Joao Ramos
494fc9bab6 Changed approach on available type selection 2024-07-07 16:59:49 +01:00
Joao Ramos
0c5c024098 Added Match Sentences to Reading Generation 2024-07-07 16:43:14 +01:00
Joao Ramos
903a567805 Fixed error with update part 2024-07-04 01:35:10 +01:00
Joao Ramos
df3929d5e6 Added parse for speaking generation 2024-07-04 01:34:07 +01:00
Joao Ramos
6d62500596 Added write the blanks for Listening 2024-07-04 01:23:59 +01:00
Joao Ramos
e5e4e87752 Added Listening Multiple Choice Edit 2024-07-04 01:15:33 +01:00
Joao Ramos
0b3e686f3f Match sentence log 2024-07-04 00:25:04 +01:00
Joao Ramos
3da87cce60 Added write blanks edit 2024-07-04 00:19:07 +01:00
Joao Ramos
c9daba17e1 Added Fill Blanks Edits + True False Edit 2024-07-03 11:13:13 +01:00
João Ramos
5cfd6d56a6 Merged in bug-fixing-19-Jun-24 (pull request #52)
Bug fixing 19 Jun 24

Approved-by: Tiago Ribeiro
2024-06-25 11:47:24 +00:00
Joao Ramos
ec8c06ca94 Filtered out the fields from the UserCard in the list wouldn't have anything to display 2024-06-24 11:12:16 +01:00
Joao Ramos
77a22b3ab3 Added a screen to display the desired levels for the students of a specific corporate 2024-06-22 00:16:30 +01:00
Joao Ramos
e79139174b Added an initial filter for corporate on records 2024-06-21 23:22:49 +01:00
Joao Ramos
61a86394ed Added persistance to the selected user record 2024-06-20 23:20:52 +01:00
Joao Ramos
f6741dd80e Added date filter to code list 2024-06-20 22:57:34 +01:00
Joao Ramos
ce6708be6e Minor fixing on a duplicated key on table 2024-06-20 22:34:02 +01:00
Joao Ramos
b62cae2e3a Filtered done ticket out of the view 2024-06-19 23:14:05 +01:00
Joao Ramos
d73b6d9d12 Added badges for students 2024-06-19 23:00:43 +01:00
Joao Ramos
c11906a395 Standardized the access to the list of users 2024-06-19 21:59:35 +01:00
Joao Ramos
a29b0b56d9 Changed to the correct format for CSV 2024-06-19 21:39:27 +01:00
Tiago Ribeiro
53dbf99fba Fixed some more issues with the Speaking 2024-06-18 22:16:31 +01:00
Tiago Ribeiro
cb49e15cb0 Updated the speaking and interactive speaking to the new format 2024-06-18 10:02:03 +01:00
Tiago Ribeiro
0eddded560 Updated part of the speaking accordingly 2024-06-13 18:17:07 +01:00
Tiago Ribeiro
11c6f70576 Updated a simple bug 2024-06-11 22:44:00 +01:00
Tiago Ribeiro
6712e89c47 Updated the format of the interactive speaking 2024-06-11 11:20:54 +01:00
Tiago Ribeiro
9959cf4294 Removed the persistence for the Speaking exam for now 2024-06-08 10:39:26 +01:00
Tiago Ribeiro
daec246835 Updated the Level Exam to work based on Parts 2024-06-07 13:25:18 +01:00
Tiago Ribeiro
8ea97ee944 Added a new feature to check for and register inactivity during an exam 2024-06-04 22:18:45 +01:00
Tiago Ribeiro
975f4c8285 Updated Firebase to use a service account depending on the environment 2024-05-29 09:06:46 +01:00
Tiago Ribeiro
f0b85409c9 Merge branch 'main' into develop 2024-05-27 16:37:09 +01:00
Tiago Ribeiro
bdd862c633 Updated the state to be active on payment 2024-05-27 13:08:11 +01:00
Tiago Ribeiro
4166781f7e Improved some issues with the payment 2024-05-27 13:05:38 +01:00
Tiago Ribeiro
1f8e9106de Updated the code to set the expiry date to the end of the day 2024-05-27 12:19:25 +01:00
Tiago Ribeiro
9e651358d5 Updated the code to return a 400 when it is not a success 2024-05-27 12:16:08 +01:00
Tiago Ribeiro
5aed336c96 Added a log 2024-05-27 11:02:11 +01:00
Tiago Ribeiro
85b94512e9 Merge branch 'develop' into ENCOA-38/add-validity-date-for-discounts 2024-05-23 19:22:31 +01:00
Tiago Ribeiro
906646ebce Created the validity dates for discounts 2024-05-23 19:21:52 +01:00
Tiago Ribeiro
96108a4958 Reverted to have checks 2024-05-23 17:22:57 +01:00
Tiago Ribeiro
fb449f2054 Updated the status when the transaction is not successful 2024-05-21 15:40:18 +01:00
Tiago Ribeiro
d5ee3d9519 Added a log for debugging 2024-05-21 15:35:57 +01:00
Tiago Ribeiro
4e20ec6575 Removed a check from the webhook 2024-05-21 12:04:31 +01:00
Tiago Ribeiro
836b674076 Added some changes to the propagate corporate changes 2024-05-21 11:21:14 +01:00
Tiago Ribeiro
5086c6fb09 Solved a visual bug 2024-05-21 11:09:36 +01:00
Tiago Ribeiro
489c9c3b7e Possibly solved part of the issue with speaking 2024-05-20 21:28:45 +01:00
Tiago Ribeiro
e3ded29e77 Merge branch 'develop' 2024-05-20 21:09:43 +01:00
Tiago Ribeiro
16419a5584 Fixed a bug introduced on the last one 2024-05-20 11:23:52 +01:00
Tiago Ribeiro
3e3b24cc30 Solved a bug for level test 2024-05-20 11:18:46 +01:00
Tiago Ribeiro
841698ba10 Updated the profile to also have the focus in it 2024-05-20 11:13:09 +01:00
Tiago Ribeiro
d50904611c Added a missing space 2024-05-16 15:42:13 +01:00
Tiago Ribeiro
e77fd16d26 Added a space to it 2024-05-16 15:03:31 +01:00
Tiago Ribeiro
649f24e4ae Updated the showcase 2024-05-16 14:51:19 +01:00
Tiago Ribeiro
2f0cbfe74e Removed the billing details modal 2024-05-16 14:30:44 +01:00
Tiago Ribeiro
d022bd078a Updated the currencies to have OMR as well 2024-05-16 13:44:27 +01:00
Tiago Ribeiro
c18afee9ad Updated the packages 2024-05-16 13:34:18 +01:00
177 changed files with 28920 additions and 8703 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
src/constants/test_firebase.json
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies

View File

@@ -54,4 +54,4 @@ EXPOSE 3000
ENV PORT 3000 ENV PORT 3000
ENV HOSTNAME localhost ENV HOSTNAME localhost
CMD ["node", "server.js"] CMD HOSTNAME="0.0.0.0" node server.js

17
components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

11175
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,27 +12,34 @@
"dependencies": { "dependencies": {
"@beam-australia/react-env": "^3.1.1", "@beam-australia/react-env": "^3.1.1",
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@headlessui/react": "^1.7.13", "@dnd-kit/sortable": "^8.0.0",
"@firebase/util": "^1.9.7",
"@headlessui/react": "^2.1.2",
"@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",
"@paypal/paypal-js": "^7.1.0", "@paypal/paypal-js": "^7.1.0",
"@paypal/react-paypal-js": "^8.1.3", "@paypal/react-paypal-js": "^8.1.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.1.1",
"@react-pdf/renderer": "^3.1.14", "@react-pdf/renderer": "^3.1.14",
"@react-spring/web": "^9.7.4",
"@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.3.3",
"@types/react-dom": "18.0.10", "@types/react-dom": "^18.3.0",
"@use-gesture/react": "^10.3.1",
"axios": "^1.3.5", "axios": "^1.3.5",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"chart.js": "^4.2.1", "chart.js": "^4.2.1",
"clsx": "^1.2.1", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.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", "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",
"exceljs": "^4.4.0",
"express-handlebars": "^7.1.2", "express-handlebars": "^7.1.2",
"firebase": "9.19.1", "firebase": "9.19.1",
"firebase-admin": "^11.10.1", "firebase-admin": "^11.10.1",
@@ -44,10 +51,9 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.4", "moment": "^2.29.4",
"moment-timezone": "^0.5.44", "moment-timezone": "^0.5.44",
"next": "13.1.6", "next": "^14.2.5",
"nodemailer": "^6.9.5", "nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0", "nodemailer-express-handlebars": "^6.1.0",
"paymob-react": "git+https://github.com/tiago-ecrop/paymob-react-oman.git",
"primeicons": "^6.0.1", "primeicons": "^6.0.1",
"primereact": "^9.2.3", "primereact": "^9.2.3",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
@@ -68,12 +74,15 @@
"react-select": "^5.7.5", "react-select": "^5.7.5",
"react-string-replace": "^1.1.0", "react-string-replace": "^1.1.0",
"react-toastify": "^9.1.2", "react-toastify": "^9.1.2",
"react-tooltip": "^5.27.1",
"react-xarrows": "^2.0.2", "react-xarrows": "^2.0.2",
"read-excel-file": "^5.7.1", "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",
"tailwind-merge": "^2.5.2",
"tailwind-scrollbar-hide": "^1.1.7", "tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss-animate": "^1.0.7",
"typescript": "4.9.5", "typescript": "4.9.5",
"use-file-picker": "^2.1.0", "use-file-picker": "^2.1.0",
"uuid": "^9.0.0", "uuid": "^9.0.0",
@@ -96,7 +105,6 @@
"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"
} }
} }

1
public/mat-icon-info.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 535 B

View File

@@ -0,0 +1,193 @@
import Image from "next/image";
import clsx from "clsx";
import RadialProgressBar from "./RadialProgressBar";
import { AIDetectionAttributes } from "@/interfaces/exam";
import { Tooltip } from 'react-tooltip';
import SegmentedProgressBar from "./SegmentedProgressBar";
// Colors and texts scrapped from gpt's zero react bundle
const AIDetection: React.FC<AIDetectionAttributes> = ({ predicted_class, confidence_category, class_probabilities, sentences }) => {
const probabilityTooltipContent = `
Encoach's deep learning model predicts the <br/>
probability this text has been entirely <br/>
generated by AI. For instance, a 40% AI <br/>
probability does not indicate that the text<br/>
contains 40% AI-written content. Rather, it<br/>
indicates the text is more likely to be partially<br/>
human written than be entirely AI-written.
`;
const confidenceTooltipContent = `
Confidence scores are a safeguard to better<br/>
understand AI identification results. Encoach<br/>
trained it's deep learning model on a diverse<br/>
dataset of millions of human and AI-written<br/>
documents. Green scores indicate that you can scan<br/>
with confidence that the model has classified<br/>
many similar documents with high accuracy.<br/>
Red scores indicate that this text is dissimilar<br/>
to the ones in their training set, which can impact<br/>
the model's accuracy, and to proceed with caution.
`;
const confidenceKeywords = ["moderately", "highly", "confident", "uncertain"];
var confidence = {
low: {
ai: "Encoach is uncertain about this text. If Encoach had to classify it, it would be considered",
human: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be considered",
mixed: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be a"
},
medium: {
ai: "Encoach is moderately confident this text was",
human: "Encoach is moderately confident this text is entirely",
mixed: "Encoach is moderately confident this text is a"
},
high: {
ai: "Encoach is highly confident this text was",
human: "Encoach is highly confident this text is entirely",
mixed: "Encoach is highly confident this text is a"
}
}
var classPrediction = {
ai: {
background: "bg-ai-detection-result-ai-bg",
color: "text-ai-detection-result-ai",
text: "ai generated"
},
mixed: {
background: "bg-ai-detection-result-mixed-bg",
color: "text-ai-detection-result-mixed",
text: "mix of ai and human"
},
human: {
background: "bg-ai-detection-result-human-bg",
color: "text-ai-detection-result-human",
text: "human"
}
}
const segments = [
{ percentage: Math.round(class_probabilities["human"] * 100), subtitle: 'human', color: "ai-detection-result-human" },
{ percentage: Math.round(class_probabilities["mixed"] * 100), subtitle: 'mixed', color: "ai-detection-result-mixed" },
{ percentage: Math.round(class_probabilities["ai"] * 100), subtitle: 'ai', color: "ai-detection-result-ai" }
];
const styleConfidenceText = (text: string): [string, string[]] => {
const keywords: string[] = [];
const styledText = text.split(" ").map(word => {
if (confidenceKeywords.includes(word)) {
keywords.push(word);
return `<span style="font-weight: 500; text-decoration: underline;">${word}</span>`;
}
return word
}).join(" ");
return [styledText, keywords];
};
const confidenceText = confidence[confidence_category][predicted_class];
const [styledText, keywords] = styleConfidenceText(confidenceText);
const tooltipStyle = {
"backgroundColor": "rgb(255, 255, 255)",
"color": "#8992B1",
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
borderRadius: '0.125rem'
}
const highestProbability = Math.max(class_probabilities["ai"], class_probabilities["human"], class_probabilities["mixed"]);
const spanTextColor = highestProbability === class_probabilities["ai"]
? "#f4bf4f"
: highestProbability === class_probabilities["human"]
? "#50c08a"
: "#93aafb";
let spanClassName = highestProbability === class_probabilities["ai"]
? "text-ai-detection-result-ai"
: highestProbability === class_probabilities["human"]
? "text-ai-detection-result-human"
: "text-ai-detection-result-mixed";
spanClassName = `${spanClassName} font-bold text-lg`
const percentage = Math.round(highestProbability * 100)
const hasHighlightedForAI = sentences.some(item => item.highlight_sentence_for_ai);
return (
<>
<Tooltip id="probability-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
<Tooltip id="confidence-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
<div className="flex flex-col bg-white p-6 rounded-lg shadow-lg gap-16">
<h1 className="text-lg font-semibold">Encoach Detection Results</h1>
<div className="flex flex-row -md:flex-col -lg:gap-0 -xl:gap-10 gap-20 items-stretch -md:items-center">
<div className="flex -md:w-5/6 w-1/2 justify-center">
<div className="flex flex-col border rounded-xl">
<h1 className="border-b p-6 font-medium">Text Classification</h1>
<div className="flex flex-row gap-8 items-center p-6">
<RadialProgressBar
percentage={percentage}
text={predicted_class}
color={spanTextColor}
spanClassName={spanClassName}
/>
<div className="flex flex-col gap-1 text-sm">
<div className="flex flex-row items-center">
<span className="mr-2 text-ai-detection-result-ai-text font-semibold text-xl">
{`${Math.round(class_probabilities["ai"] * 100)}%`}
</span>
<span className="text-sm -md:text-xs text-ai-detection-text">Probability AI generated</span>
<a data-tooltip-id="probability-tooltip" data-tooltip-html={probabilityTooltipContent} className='ml-1 flex items-center justify-center'>
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
</a>
</div>
<div className="flex flex-row items-center gap-1">
<div className={clsx(
"rounded-full w-3 h-3",
confidence_category == 'low' ?
"bg-ai-detection-confidence-low border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-low-transparent"
)}></div>
<div className={clsx(
"rounded-full w-3 h-3",
confidence_category == 'medium' ?
"bg-ai-detection-confidence-medium border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-medium-transparent"
)}></div>
<div className={clsx(
"rounded-full w-3 h-3 mr-2",
confidence_category == 'high' ?
"bg-ai-detection-confidence-high border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-high-transparent"
)}></div>
<span className="text-sm -md:text-xs text-ai-detection-text">{keywords.join(' ')}</span>
<a data-tooltip-id="confidence-tooltip" data-tooltip-html={confidenceTooltipContent} className='ml-1 flex items-center justify-center'>
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
</a>
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col border rounded-xl -md:w-5/6 w-2/6">
<h1 className="border-b p-6 font-medium">Probability Breakdown</h1>
<div className="flex items-center w-full h-full">
<SegmentedProgressBar segments={segments} className="w-full px-8 -md:py-8 text-ai-detection-text" />
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-row items-center">
<div dangerouslySetInnerHTML={{ __html: styledText }} className="mr-2"></div>
<div className={clsx(
"flex items-center justify-center p-2 rounded",
classPrediction[predicted_class]['color'],
classPrediction[predicted_class]['background']
)}>
<span className="text-sm">{classPrediction[predicted_class]['text']}</span>
</div>
</div>
{(hasHighlightedForAI && <div>
Sentences that are likely written by AI are <span className="font-semibold bg-ai-detection-highlight">highlighted</span>.
</div>)}
</div>
</div >
<div>
{sentences.map((item, index) => (
<span
key={index}
className={item.highlight_sentence_for_ai ? 'bg-ai-detection-highlight' : ''}
>
{item.sentence}{' '}
</span>
))}
</div>
</>
)
}
export default AIDetection;

View File

@@ -1,56 +0,0 @@
import {Dialog, Transition} from "@headlessui/react";
import {Fragment} from "react";
import Button from "./Low/Button";
interface Props {
isOpen: boolean;
onClose: (next?: boolean) => void;
}
export default function BlankQuestionsModal({isOpen, onClose}: Props) {
return (
<Transition show={isOpen} as={Fragment}>
<Dialog onClose={() => onClose(false)} className="relative z-50">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black/30" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
<span>
Please note that you are finishing the current module and once you proceed to the next module, you will no longer be
able to change the answers in the current one, including your unanswered questions. <br />
<br />
Are you sure you want to continue without completing those questions?
</span>
<div className="w-full flex justify-between mt-8">
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
Go Back
</Button>
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
Continue
</Button>
</div>
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
);
}

View File

@@ -0,0 +1,84 @@
import React, { useState, ReactNode, useRef, useEffect } from 'react';
import { animated, useSpring } from '@react-spring/web';
interface DropdownProps {
title: ReactNode;
open?: boolean;
className?: string;
contentWrapperClassName?: string;
bottomPadding?: number;
children: ReactNode;
}
const Dropdown: React.FC<DropdownProps> = ({
title,
open = false,
className = "w-full text-left font-semibold flex justify-between items-center p-4",
contentWrapperClassName = "px-6",
bottomPadding = 12,
children
}) => {
const [isOpen, setIsOpen] = useState<boolean>(open);
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState<number>(0);
useEffect(() => {
let resizeObserver: ResizeObserver | null = null;
if (contentRef.current) {
resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
const height = entry.borderBoxSize[0].blockSize;
setContentHeight(height + bottomPadding);
} else {
// Fallback for browsers that don't support borderBoxSize
const height = entry.contentRect.height;
setContentHeight(height + bottomPadding);
}
}
});
resizeObserver.observe(contentRef.current);
}
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
};
}, [bottomPadding]);
const springProps = useSpring({
height: isOpen ? contentHeight : 0,
opacity: isOpen ? 1 : 0,
config: { tension: 300, friction: 30 }
});
return (
<>
<button
onClick={() => setIsOpen(!isOpen)}
className={className}
>
{title}
<svg
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<animated.div style={springProps} className="overflow-hidden">
<div ref={contentRef} className={contentWrapperClassName} style={{paddingBottom: bottomPadding}}>
{children}
</div>
</animated.div>
</>
);
};
export default Dropdown;

View File

@@ -1,186 +0,0 @@
import {FillBlanksExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import {Fragment, useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import Button from "../Low/Button";
interface WordsDrawerProps {
words: {word: string; isDisabled: boolean}[];
isOpen: boolean;
blankId?: string;
previouslySelectedWord?: string;
onCancel: () => void;
onAnswer: (answer: string) => void;
}
function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}: WordsDrawerProps) {
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
return (
<>
<div
className={clsx(
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
isOpen ? "visible opacity-10" : "invisible opacity-0",
)}
/>
<div
className={clsx(
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
isOpen ? "visible opacity-100" : "invisible opacity-0",
)}>
<div className="w-full flex gap-2">
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
<span> Choose the correct word:</span>
</div>
<div className="grid grid-cols-6 gap-6" key="word-array">
{words.map(({word, isDisabled}) => (
<button
key={`${word}_${blankId}`}
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
className={clsx(
"rounded-full py-3 text-center transition duration-300 ease-in-out",
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
!isDisabled && "hover:text-white hover:bg-mti-purple",
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
)}
disabled={isDisabled}>
{word}
</button>
))}
</div>
<div className="flex justify-between w-full">
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
Cancel
</Button>
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
Confirm
</Button>
</div>
</div>
</>
);
}
export default function FillBlanks({
id,
allowRepetition,
type,
prompt,
solutions,
text,
words,
userSolutions,
onNext,
onBack,
}: FillBlanksExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
const [currentBlankId, setCurrentBlankId] = useState<string>();
const [isDrawerShowing, setIsDrawerShowing] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const allBlanks = Array.from(text.match(/({{\d+}})/g) || []).map((x) => x.replaceAll("{", "").replaceAll("}", ""));
useEffect(() => {
setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100);
}, [currentBlankId]);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers.filter(
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
).length;
const missing = total - answers.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing};
};
const renderLines = (line: string) => {
return (
<span className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id);
return (
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
!userSolution && "w-6 h-6 text-center text-mti-purple-light bg-mti-purple-ultralight",
currentBlankId === id && "text-white !bg-mti-purple-light ",
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)}
onClick={() => setCurrentBlankId(id)}>
{userSolution ? userSolution.solution : id}
</button>
);
})}
</span>
);
};
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
{(!!currentBlankId || isDrawerShowing) && (
<WordsDrawer
key={currentBlankId}
blankId={currentBlankId}
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : answers.map((x) => x.solution).includes(word)}))}
previouslySelectedWord={currentBlankId ? answers.find((x) => x.id === currentBlankId)?.solution : undefined}
isOpen={isDrawerShowing}
onCancel={() => setCurrentBlankId(undefined)}
onAnswer={(solution: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
if (allBlanks.findIndex((x) => x === currentBlankId) + 1 < allBlanks.length) {
setCurrentBlankId(allBlanks[allBlanks.findIndex((x) => x === currentBlankId) + 1]);
return;
}
setCurrentBlankId(undefined);
}}
/>
)}
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<p key={index}>
{renderLines(line)}
<br />
</p>
))}
</span>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,64 @@
import Button from "@/components/Low/Button";
import clsx from "clsx";
import { useState } from "react";
interface WordsDrawerProps {
words: {word: string; isDisabled: boolean}[];
isOpen: boolean;
blankId?: string;
previouslySelectedWord?: string;
onCancel: () => void;
onAnswer: (answer: string) => void;
}
const WordsDrawer: React.FC<WordsDrawerProps> = ({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}) => {
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
return (
<>
<div
className={clsx(
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
isOpen ? "visible opacity-10" : "invisible opacity-0",
)}
/>
<div
className={clsx(
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
isOpen ? "visible opacity-100" : "invisible opacity-0",
)}>
<div className="w-full flex gap-2">
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
<span> Choose the correct word:</span>
</div>
<div className="grid grid-cols-6 gap-6" key="word-array">
{words.map(({word, isDisabled}) => (
<button
key={`${word}_${blankId}`}
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
className={clsx(
"rounded-full py-3 text-center transition duration-300 ease-in-out",
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
!isDisabled && "hover:text-white hover:bg-mti-purple",
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
)}
disabled={isDisabled}>
{word}
</button>
))}
</div>
<div className="flex justify-between w-full">
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
Cancel
</Button>
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
Confirm
</Button>
</div>
</div>
</>
);
}
export default WordsDrawer;

View File

@@ -0,0 +1,243 @@
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import { Fragment, useEffect, useState } from "react";
import reactStringReplace from "react-string-replace";
import { CommonProps } from "..";
import Button from "../../Low/Button";
import { v4 } from "uuid";
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
id,
type,
prompt,
solutions,
text,
words,
userSolutions,
variant,
onNext,
onBack,
}) => {
const { shuffleMaps, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const [currentMCSelection, setCurrentMCSelection] = useState<{ id: string, selection: FillBlanksMCOption }>();
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every(
word => word && typeof word === 'object' && 'id' in word && 'options' in word
);
}
const excludeWordMCType = (x: any) => {
return typeof x === "string" ? x : x as { letter: string; word: string };
}
useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
let correctWords: any;
if (exam && exam.module === "level" && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
}
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers!.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
if (!solution) return false;
const option = correctWords!.find((w: any) => {
if (typeof w === "string") {
return w.toLowerCase() === x.solution.toLowerCase();
} else if ('letter' in w) {
return w.word.toLowerCase() === x.solution.toLowerCase();
} else {
return w.id.toString() === x.id.toString();
}
});
if (!option) return false;
if (typeof option === "string") {
return solution.toLowerCase() === option.toLowerCase();
} else if ('letter' in option) {
return solution.toLowerCase() === option.word.toLowerCase();
} else if ('options' in option) {
return option.options[solution as keyof typeof option.options] == x.solution;
}
return false;
}).length;
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing };
};
const renderLines = (line: string) => {
return (
<div className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id);
const styles = clsx(
"rounded-full hover:text-white focus:ring-0 focus:outline-none focus:!text-white focus:bg-mti-purple transition duration-300 ease-in-out my-1 px-5 py-2 text-center",
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
)
return (
variant === "mc" ? (
<>
{/*<span className="mr-2">{`(${id})`}</span>*/}
<button
className={styles}
onClick={() => {
setCurrentMCSelection(
{
id: id,
selection: words.find((x) => {
if (typeof x !== "string" && 'id' in x) {
return (x as FillBlanksMCOption).id.toString() == id.toString();
}
return false;
}) as FillBlanksMCOption
}
);
}}
>
{userSolution?.solution === undefined ? <span className="text-transparent select-none">placeholder</span> : <span> {userSolution.solution} </span>}
</button>
</>
) : (
<input
className={styles}
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
value={userSolution?.solution} />
)
);
})}
</div>
);
};
const onSelection = (id: string, value: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id: id, solution: value }]);
}
const getShuffles = () => {
let shuffle = {};
if (shuffleMaps.length !== 0) {
shuffle = {
shuffleMaps: shuffleMaps.filter((map) =>
answers.some(answer => answer.id === map.id)
)
}
}
return shuffle;
}
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
{false && <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>}
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
{renderLines(line)}
<br />
</p>
))}
</span>
{variant === "mc" && typeCheckWordsMC(words) ? (
<>
{currentMCSelection && (
<div className="bg-mti-gray-smoke rounded-xl flex flex-col gap-4 px-16 py-8">
<span className="font-medium text-lg text-mti-purple-dark mb-4 px-2">{`${currentMCSelection.id} - Select the appropriate word.`}</span>
<div className="flex gap-4 flex-wrap justify-between">
{currentMCSelection.selection?.options && Object.entries(currentMCSelection.selection.options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => {
return <div
key={v4()}
onClick={() => onSelection(currentMCSelection.id, value)}
className={clsx(
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base",
!!answers.find((x) => x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id) &&
"border-mti-purple-light",
)}>
<span className="font-semibold">{key}.</span>
<span>{value}</span>
</div>
/*<button
className={clsx(
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
!!answers.find((x) => x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id) &&
"bg-mti-purple-dark text-white",
)}
key={v4()}
onClick={() => onSelection(currentMCSelection.id, value)}
>
{value}
</button>;*/
})}
</div>
</div>
)}
</>
) : (
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
<span className="font-medium text-mti-purple-dark">Options</span>
<div className="flex gap-4 flex-wrap">
{words.map((v) => {
v = excludeWordMCType(v);
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`;
return (
<span
className={clsx(
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
!!answers.find((x) => x.solution.toLowerCase() === (typeof v === "string" ? v : ("letter" in v ? v.letter : "")).toLowerCase()) &&
"bg-mti-purple-dark text-white",
)}
key={v4()}
>
{text}
</span>
)
})}
</div>
</div >
)}
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })}
className="max-w-[200px] w-full"
disabled={
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
typeof exam.parts[0].intro === "string" && questionIndex === 0}
>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}
export default FillBlanks;

View File

@@ -16,12 +16,12 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
export default function InteractiveSpeaking({ export default function InteractiveSpeaking({
id, id,
title, title,
first_title,
second_title,
examID, examID,
text,
type, type,
prompts, prompts,
userSolutions, userSolutions,
updateIndex,
onNext, onNext,
onBack, onBack,
}: InteractiveSpeakingExercise & CommonProps) { }: InteractiveSpeakingExercise & CommonProps) {
@@ -36,31 +36,6 @@ export default function InteractiveSpeaking({
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const saveToStorage = async (previousURL?: string) => {
if (mediaBlob && mediaBlob.startsWith("blob")) {
const blobBuffer = await downloadBlob(mediaBlob);
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
const seed = Math.random().toString().replace("0.", "");
const formData = new FormData();
formData.append("audio", audioFile, `${seed}.wav`);
formData.append("root", "speaking_recordings");
const config = {
headers: {
"Content-Type": "audio/wav",
},
};
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
if (previousURL && !previousURL.startsWith("blob")) await axios.post("/api/storage/delete", {path: previousURL});
return response.data.path;
}
return undefined;
};
const back = async () => { const back = async () => {
setIsLoading(true); setIsLoading(true);
@@ -91,7 +66,9 @@ export default function InteractiveSpeaking({
return; return;
} }
setIsLoading(false); setIsLoading(false);
setQuestionIndex(0);
onNext({ onNext({
exercise: id, exercise: id,
@@ -111,10 +88,6 @@ export default function InteractiveSpeaking({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [userSolutions, mediaBlob, answers]); }, [userSolutions, mediaBlob, answers]);
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
useEffect(() => { useEffect(() => {
if (hasExamEnded) { if (hasExamEnded) {
const answer = { const answer = {
@@ -154,13 +127,10 @@ export default function InteractiveSpeaking({
}, [answers, questionIndex]); }, [answers, questionIndex]);
const saveAnswer = async (index: number) => { const saveAnswer = async (index: number) => {
const previousURL = answers.find((x) => x.questionIndex === questionIndex)?.blob;
const audioPath = await saveToStorage(previousURL);
const answer = { const answer = {
questionIndex, questionIndex,
prompt: prompts[questionIndex].text, prompt: prompts[questionIndex].text,
blob: audioPath ? audioPath : mediaBlob!, blob: mediaBlob!,
}; };
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]); setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
@@ -185,7 +155,7 @@ export default function InteractiveSpeaking({
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16"> <div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<span className="font-semibold">{title}</span> <span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
</div> </div>
{prompts && prompts.length > 0 && ( {prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4 w-full items-center"> <div className="flex flex-col gap-4 w-full items-center">
@@ -204,7 +174,7 @@ export default function InteractiveSpeaking({
<div 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="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<p className="text-base font-normal">Record your answer:</p> <p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8"> <div className="flex gap-8 items-center justify-center py-8">
{status === "idle" && !mediaBlob && ( {status === "idle" && (
<> <>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" /> <div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && ( {status === "idle" && (
@@ -283,9 +253,9 @@ export default function InteractiveSpeaking({
</div> </div>
</> </>
)} )}
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && ( {status === "stopped" && mediaBlobUrl && (
<> <>
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" /> <Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<BsTrashFill <BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5" className="text-mti-gray-cool cursor-pointer w-5 h-5"

View File

@@ -87,10 +87,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
return {total, correct, missing}; return {total, correct, missing};
}; };
useEffect(() => {
console.log(answers);
}, [answers]);
useEffect(() => { useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -1,10 +1,12 @@
/* 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 useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {CommonProps} from "."; import reactStringReplace from "react-string-replace";
import { CommonProps } from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
import { v4 } from "uuid";
function Question({ function Question({
id, id,
@@ -13,20 +15,38 @@ function Question({
options, options,
userSolution, userSolution,
onSelectOption, onSelectOption,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) { }: MultipleChoiceQuestion & {
userSolution: string | undefined;
onSelectOption?: (option: string) => void;
showSolution?: boolean,
}) {
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u>{word}</u> : null;
});
};
return ( return (
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-8">
<span className=""> {isNaN(Number(id)) ? (
{id} - {prompt} <span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
</span> ) : (
<span className="text-lg">
<>
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
</>
</span>
)}
<div className="flex flex-wrap gap-4 justify-between"> <div className="flex flex-wrap gap-4 justify-between">
{variant === "image" && {variant === "image" &&
options.map((option) => ( options.map((option) => (
<div <div
key={option.id.toString()} key={v4()}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)} onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
className={clsx( className={clsx(
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative", "flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
userSolution === option.id.toString() && "border-mti-purple-light", userSolution === option.id.toString() && "border-mti-purple-light",
)}> )}>
<span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span> <span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span>
@@ -36,10 +56,10 @@ function Question({
{variant === "text" && {variant === "text" &&
options.map((option) => ( options.map((option) => (
<div <div
key={option.id.toString()} key={v4()}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)} onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
className={clsx( className={clsx(
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", "flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
userSolution === option.id.toString() && "border-mti-purple-light", userSolution === option.id.toString() && "border-mti-purple-light",
)}> )}>
<span className="font-semibold">{option.id.toString()}.</span> <span className="font-semibold">{option.id.toString()}.</span>
@@ -51,66 +71,84 @@ function Question({
); );
} }
export default function MultipleChoice({ export default function MultipleChoice({ id, prompt, type, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) {
id, const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions);
prompt,
type,
questions,
userSolutions,
updateIndex,
onNext,
onBack,
}: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state); const {
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state); questionIndex,
const hasExamEnded = useExamStore((state) => state.hasExamEnded); exam,
shuffleMaps,
hasExamEnded,
userSolutions: storeUserSolutions,
setQuestionIndex,
setUserSolutions
} = useExamStore((state) => state);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => { useEffect(() => {
setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]); setUserSolutions(
[...storeUserSolutions.filter((x) => x.exercise !== id), {
exercise: id, solutions: answers, score: calculateScore(), type
}]);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers]); }, [answers]);
useEffect(() => { useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
// 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 }]);
}; };
const calculateScore = () => { const calculateScore = () => {
const total = questions.length; const total = questions.length;
const correct = answers.filter( const correct = answers.filter((x) => {
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false, const matchingQuestion = questions.find((y) => {
).length; return y.id.toString() === x.question.toString();
const missing = total - answers.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length; });
return {total, correct, missing}; let isSolutionCorrect;
if (shuffleMaps.length == 0) {
isSolutionCorrect = matchingQuestion?.solution === x.option;
} else {
const shuffleMap = shuffleMaps.find((map) => map.id == x.question)
isSolutionCorrect = shuffleMap?.map[x.option] == matchingQuestion?.solution;
}
return isSolutionCorrect || false;
}).length;
const missing = total - correct;
return { total, correct, missing };
}; };
const getShuffles = () => {
let shuffle = {};
if (shuffleMaps.length !== 0) {
shuffle = {
shuffleMaps: shuffleMaps.filter((map) =>
answers.some(answer => answer.question === map.id)
)
}
}
return shuffle;
}
const next = () => { const next = () => {
if (questionIndex === questions.length - 1) { if (questionIndex === questions.length - 1) {
onNext({exercise: id, solutions: answers, score: calculateScore(), type}); onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() });
} else { } else {
setQuestionIndex(questionIndex + 1); setQuestionIndex(questionIndex + 1);
} }
scrollToTop(); scrollToTop();
}; };
const back = () => { const back = () => {
if (questionIndex === 0) { if (questionIndex === 0) {
onBack({exercise: id, solutions: answers, score: calculateScore(), type}); onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() });
} else { } else {
setQuestionIndex(questionIndex - 1); setQuestionIndex(questionIndex - 1);
} }
@@ -121,7 +159,7 @@ export default function MultipleChoice({
return ( return (
<> <>
<div className="flex flex-col gap-2 mt-4 h-fit w-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8"> <div className="flex flex-col gap-2 mt-4 h-fit w-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
<span className="text-xl font-semibold">{prompt}</span> {/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
{questionIndex < questions.length && ( {questionIndex < questions.length && (
<Question <Question
{...questions[questionIndex]} {...questions[questionIndex]}
@@ -132,7 +170,10 @@ export default function MultipleChoice({
</div> </div>
<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">
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full"> <Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full"
disabled={
exam && exam.module === "level" && typeof exam.parts[0].intro === "string" && questionIndex === 0}
>
Back Back
</Button> </Button>

View File

@@ -1,31 +1,34 @@
import {SpeakingExercise} from "@/interfaces/exam"; import { SpeakingExercise } from "@/interfaces/exam";
import {CommonProps} from "."; import { CommonProps } from ".";
import {Fragment, useEffect, useState} from "react"; import { Fragment, useEffect, useState } from "react";
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs"; import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Button from "../Low/Button"; import Button from "../Low/Button";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {downloadBlob} from "@/utils/evaluation"; import { downloadBlob } from "@/utils/evaluation";
import axios from "axios"; import axios from "axios";
import Modal from "../Modal";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false, ssr: false,
}); });
export default function Speaking({id, title, text, video_url, type, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack }: SpeakingExercise & 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>();
const [audioURL, setAudioURL] = useState<string>(); const [audioURL, setAudioURL] = useState<string>();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
const [inputText, setInputText] = useState("");
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const saveToStorage = async () => { const saveToStorage = async () => {
if (mediaBlob && mediaBlob.startsWith("blob")) { if (mediaBlob && mediaBlob.startsWith("blob")) {
const blobBuffer = await downloadBlob(mediaBlob); const blobBuffer = await downloadBlob(mediaBlob);
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"}); const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" });
const seed = Math.random().toString().replace("0.", ""); const seed = Math.random().toString().replace("0.", "");
@@ -39,8 +42,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
}, },
}; };
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config); const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config);
if (audioURL) await axios.post("/api/storage/delete", {path: audioURL}); if (audioURL) await axios.post("/api/storage/delete", { path: audioURL });
return response.data.path; return response.data.path;
} }
@@ -49,7 +52,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
useEffect(() => { useEffect(() => {
if (userSolutions.length > 0) { if (userSolutions.length > 0) {
const {solution} = userSolutions[0] as {solution?: string}; const { solution } = userSolutions[0] as { solution?: string };
if (solution && !mediaBlob) setMediaBlob(solution); if (solution && !mediaBlob) setMediaBlob(solution);
if (solution && !solution.startsWith("blob")) setAudioURL(solution); if (solution && !solution.startsWith("blob")) setAudioURL(solution);
} }
@@ -74,36 +77,66 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
}, [isRecording]); }, [isRecording]);
const next = async () => { const next = async () => {
setIsLoading(true);
const storagePath = await saveToStorage();
setIsLoading(false);
onNext({ onNext({
exercise: id, exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [], solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
score: {correct: 0, total: 100, missing: 0}, score: { correct: 0, total: 100, missing: 0 },
type, type,
}); });
}; };
const back = async () => { const back = async () => {
setIsLoading(true);
const storagePath = await saveToStorage();
setIsLoading(false);
onBack({ onBack({
exercise: id, exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [], solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
score: {correct: 0, total: 100, missing: 0}, score: { correct: 0, total: 100, missing: 0 },
type, type,
}); });
}; };
const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value;
const words = newText.match(/\S+/g);
const wordCount = words ? words.length : 0;
if (wordCount <= 100) {
setInputText(newText);
} else {
let count = 0;
let lastIndex = 0;
const matches = newText.matchAll(/\S+/g);
for (const match of matches) {
count++;
if (count > 100) break;
lastIndex = match.index! + match[0].length;
}
setInputText(newText.slice(0, lastIndex));
}
};
return ( return (
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col h-full w-full gap-9">
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
<div className="flex flex-col gap-1 ml-4">
{prompts.map((x, index) => (
<li className="italic" key={index}>
{x}
</li>
))}
</div>
{!!suffix && <span className="font-bold">{suffix}</span>}
</div>
</Modal>
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16"> <div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<span className="font-semibold">{title}</span> <div className="flex flex-col gap-0">
<span className="font-semibold">{title}</span>
{prompts.length > 0 && (
<span className="font-semibold">You should talk for at least 1 minute and 30 seconds for your answer to be valid.</span>
)}
</div>
{!video_url && ( {!video_url && (
<span className="font-regular"> <span className="font-regular">
{text.split("\\n").map((line, index) => ( {text.split("\\n").map((line, index) => (
@@ -115,7 +148,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
</span> </span>
)} )}
</div> </div>
<div className="flex gap-6"> <div className="flex flex-col gap-6 items-center">
{video_url && ( {video_url && (
<div className="flex flex-col gap-4 w-full items-center"> <div className="flex flex-col gap-4 w-full items-center">
<video key={id} autoPlay controls className="max-w-3xl rounded-xl"> <video key={id} autoPlay controls className="max-w-3xl rounded-xl">
@@ -123,25 +156,28 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
</video> </video>
</div> </div>
)} )}
{prompts && prompts.length > 0 && ( {prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
<div className="flex flex-col gap-4">
<span className="font-bold">You should talk about the following things:</span>
<div className="flex flex-col gap-1 ml-4">
{prompts.map((x, index) => (
<li className="italic" key={index}>
{x}
</li>
))}
</div>
</div>
)}
</div> </div>
</div> </div>
{prompts && prompts.length > 0 && (
<div className="w-full h-full flex flex-col gap-4">
<textarea
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full min-h-[200px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
onChange={handleNoteWriting}
value={inputText}
placeholder="Write your notes here..."
spellCheck={false}
/>
<span className="text-base self-end text-mti-gray-cool">Word Count: {(inputText.match(/\S+/g) || []).length}/100</span>
</div>
)}
<ReactMediaRecorder <ReactMediaRecorder
audio audio
onStop={(blob) => setMediaBlob(blob)} onStop={(blob) => setMediaBlob(blob)}
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => ( render={({ status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl }) => (
<div 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="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<p className="text-base font-normal">Record your answer:</p> <p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8"> <div className="flex gap-8 items-center justify-center py-8">

View File

@@ -23,7 +23,6 @@ const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentenc
export interface CommonProps { export interface CommonProps {
examID?: string; examID?: string;
updateIndex?: (internalIndex: number) => void;
onNext: (userSolutions: UserSolution) => void; onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void; onBack: (userSolutions: UserSolution) => void;
} }
@@ -33,7 +32,6 @@ export const renderExercise = (
examID: string, examID: string,
onNext: (userSolutions: UserSolution) => void, onNext: (userSolutions: UserSolution) => void,
onBack: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void,
updateIndex?: (internalIndex: number) => void,
) => { ) => {
switch (exercise.type) { switch (exercise.type) {
case "fillBlanks": case "fillBlanks":
@@ -43,16 +41,7 @@ export const renderExercise = (
case "matchSentences": case "matchSentences":
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />; return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "multipleChoice": case "multipleChoice":
return ( return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
<MultipleChoice
key={exercise.id}
{...(exercise as MultipleChoiceExercise)}
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}
examID={examID}
/>
);
case "writeBlanks": case "writeBlanks":
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />; return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "writing": case "writing":
@@ -65,7 +54,6 @@ export const renderExercise = (
key={exercise.id} key={exercise.id}
{...(exercise as InteractiveSpeakingExercise)} {...(exercise as InteractiveSpeakingExercise)}
examID={examID} examID={examID}
updateIndex={updateIndex}
onNext={onNext} onNext={onNext}
onBack={onBack} onBack={onBack}
/> />

View File

@@ -0,0 +1,82 @@
import {FillBlanksExercise} from "@/interfaces/exam";
import React from "react";
import Input from "@/components/Low/Input";
interface Props {
exercise: FillBlanksExercise;
updateExercise: (data: any) => void;
}
const FillBlanksEdit = (props: Props) => {
const {exercise, updateExercise} = props;
return (
<>
<Input
type="text"
label="Prompt"
name="prompt"
required
value={exercise.prompt}
onChange={(value) =>
updateExercise({
prompt: value,
})
}
/>
<Input
type="text"
label="Text"
name="text"
required
value={exercise.text}
onChange={(value) =>
updateExercise({
text: value,
})
}
/>
<h1>Solutions</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.solutions.map((solution, index) => (
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
<Input
type="text"
label={`Solution ${index + 1}`}
name="solution"
required
value={solution.solution}
onChange={(value) =>
updateExercise({
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? {...sol, solution: value} : sol)),
})
}
/>
</div>
))}
</div>
<h1>Words</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.words.map((word, index) => (
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
<Input
type="text"
label={`Word ${index + 1}`}
name="word"
required
value={typeof word === "string" ? word : ("word" in word ? word.word : "")}
onChange={(value) =>
updateExercise({
words: exercise.words.map((sol, idx) =>
index === idx ? (typeof word === "string" ? value : {...word, word: value}) : sol,
),
})
}
/>
</div>
))}
</div>
</>
);
};
export default FillBlanksEdit;

View File

@@ -0,0 +1,7 @@
import React from "react";
const InteractiveSpeakingEdit = () => {
return null;
};
export default InteractiveSpeakingEdit;

View File

@@ -0,0 +1,130 @@
import React from "react";
import { MatchSentencesExercise } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
interface Props {
exercise: MatchSentencesExercise;
updateExercise: (data: any) => void;
}
const MatchSentencesEdit = (props: Props) => {
const { exercise, updateExercise } = props;
const selectOptions = exercise.options.map((option) => ({
value: option.id,
label: option.id,
}));
return (
<>
<Input
type="text"
label="Prompt"
name="prompt"
required
value={exercise.prompt}
onChange={(value) =>
updateExercise({
prompt: value,
})
}
/>
<h1>Solutions</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.sentences.map((sentence, index) => (
<div key={sentence.id} className="flex flex-col w-full px-2">
<div className="flex w-full">
<Input
type="text"
label={`Sentence ${index + 1}`}
name="sentence"
required
value={sentence.sentence}
onChange={(value) =>
updateExercise({
sentences: exercise.sentences.map((iSol) =>
iSol.id === sentence.id
? {
...iSol,
sentence: value,
}
: iSol
),
})
}
className="px-2"
/>
<div className="w-48 flex items-end px-2">
<Select
value={selectOptions.find(
(o) => o.value === sentence.solution
)}
options={selectOptions}
onChange={(value) => {
updateExercise({
sentences: exercise.sentences.map((iSol) =>
iSol.id === sentence.id
? {
...iSol,
solution: value?.value,
}
: iSol
),
});
}}
/>
</div>
</div>
</div>
))}
<h1>Options</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.options.map((option, index) => (
<div key={option.id} className="flex flex-col w-full px-2">
<div className="flex w-full">
<Input
type="text"
label={`Option ${index + 1}`}
name="option"
required
value={option.sentence}
onChange={(value) =>
updateExercise({
options: exercise.options.map((iSol) =>
iSol.id === option.id
? {
...iSol,
sentence: value,
}
: iSol
),
})
}
className="px-2"
/>
<div className="w-48 flex items-end px-2">
<Select
value={{
value: option.id,
label: option.id,
}}
options={[
{
value: option.id,
label: option.id,
},
]}
disabled
onChange={() => {}}
/>
</div>
</div>
</div>
))}
</div>
</div>
</>
);
};
export default MatchSentencesEdit;

View File

@@ -0,0 +1,137 @@
import React from "react";
import Input from "@/components/Low/Input";
import {
MultipleChoiceExercise,
MultipleChoiceQuestion,
} from "@/interfaces/exam";
import Select from "@/components/Low/Select";
interface Props {
exercise: MultipleChoiceExercise;
updateExercise: (data: any) => void;
}
const variantOptions = [
{ value: "text", label: "Text", key: "text" },
{ value: "image", label: "Image", key: "src" },
];
const MultipleChoiceEdit = (props: Props) => {
const { exercise, updateExercise } = props;
return (
<>
<h1>Questions</h1>
<div className="w-full flex-no-wrap -mx-2">
{exercise.questions.map((question: MultipleChoiceQuestion, index) => {
const variantValue = variantOptions.find(
(o) => o.value === question.variant
);
const solutionsOptions = question.options.map((option) => ({
value: option.id,
label: option.id,
}));
const solutionValue = solutionsOptions.find(
(o) => o.value === question.solution
);
return (
<div key={question.id} className="flex w-full px-2 flex-col">
<span>Question ID: {question.id}</span>
<Input
type="text"
label="Prompt"
name="prompt"
required
value={question.prompt}
onChange={(value) =>
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id ? { ...sol, prompt: value } : sol
),
})
}
/>
<div className="flex w-full">
<div className="w-48 flex items-end px-2">
<Select
value={solutionValue}
options={solutionsOptions}
onChange={(value) => {
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id
? { ...sol, solution: value?.value }
: sol
),
});
}}
/>
</div>
<div className="w-48 flex items-end px-2">
<Select
value={variantValue}
options={variantOptions}
onChange={(value) => {
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id
? { ...sol, variant: value?.value }
: sol
),
});
}}
/>
</div>
</div>
<div className="flex w-full flex-wrap -mx-2">
{question.options.map((option) => (
<div
key={option.id}
className="flex sm:w-1/2 lg:w-1/4 px-2 px-2"
>
<Input
type="text"
label={`Option ${option.id}`}
name="option"
required
value={option.text}
onChange={(value) =>
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id
? {
...sol,
options: sol.options.map((opt) => {
if (
opt.id === option.id &&
variantValue?.key
) {
return {
...opt,
[variantValue.key]: value,
};
}
return opt;
}),
}
: sol
),
})
}
/>
</div>
))}
</div>
</div>
);
})}
</div>
</>
);
};
export default MultipleChoiceEdit;

View File

@@ -0,0 +1,7 @@
import React from 'react';
const SpeakingEdit = () => {
return null;
}
export default SpeakingEdit;

View File

@@ -0,0 +1,71 @@
import React from "react";
import { TrueFalseExercise } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
interface Props {
exercise: TrueFalseExercise;
updateExercise: (data: any) => void;
}
const options = [
{ value: "true", label: "True" },
{ value: "false", label: "False" },
{ value: "not_given", label: "Not Given" },
];
const TrueFalseEdit = (props: Props) => {
const { exercise, updateExercise } = props;
return (
<>
<Input
type="text"
label="Prompt"
name="prompt"
required
value={exercise.prompt}
onChange={(value) =>
updateExercise({
prompt: value,
})
}
/>
<h1>Questions</h1>
<div className="w-full flex-no-wrap -mx-2">
{exercise.questions.map((question, index) => (
<div key={question.id} className="flex w-full px-2">
<Input
type="text"
label={`Question ${index + 1}`}
name="question"
required
value={question.prompt}
onChange={(value) =>
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id ? { ...sol, prompt: value } : sol
),
})
}
/>
<div className="w-48 flex items-end px-2">
<Select
value={options.find((o) => o.value === question.solution)}
options={options}
onChange={(value) => {
updateExercise({
questions: exercise.questions.map((sol) =>
sol.id === question.id ? { ...sol, solution: value?.value } : sol
),
});
}}
className="h-18"
/>
</div>
</div>
))}
</div>
</>
);
};
export default TrueFalseEdit;

View File

@@ -0,0 +1,94 @@
import React from "react";
import Input from "@/components/Low/Input";
import { WriteBlanksExercise } from "@/interfaces/exam";
interface Props {
exercise: WriteBlanksExercise;
updateExercise: (data: any) => void;
}
const WriteBlankEdits = (props: Props) => {
const { exercise, updateExercise } = props;
return (
<>
<Input
type="text"
label="Prompt"
name="prompt"
required
value={exercise.prompt}
onChange={(value) =>
updateExercise({
prompt: value,
})
}
/>
<Input
type="text"
label="Text"
name="text"
required
value={exercise.text}
onChange={(value) =>
updateExercise({
text: value,
})
}
/>
<Input
type="text"
label="Max Words"
name="number"
required
value={exercise.maxWords}
onChange={(value) =>
updateExercise({
maxWords: Number(value),
})
}
/>
<h1>Solutions</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.solutions.map((solution) => (
<div key={solution.id} className="flex flex-col w-full px-2">
<span>Solution ID: {solution.id}</span>
{/* TODO: Consider adding an add and delete button */}
<div className="flex flex-wrap">
{solution.solution.map((sol, solIndex) => (
<Input
key={`${sol}-${solIndex}`}
type="text"
label={`Solution ${solIndex + 1}`}
name="solution"
required
value={sol}
onChange={(value) =>
updateExercise({
solutions: exercise.solutions.map((iSol) =>
iSol.id === solution.id
? {
...iSol,
solution: iSol.solution.map((iiSol, iiIndex) => {
if (iiIndex === solIndex) {
return value;
}
return iiSol;
}),
}
: iSol
),
})
}
className="sm:w-1/2 lg:w-1/4 px-2"
/>
))}
</div>
</div>
))}
</div>
</>
);
};
export default WriteBlankEdits;

View File

@@ -0,0 +1,7 @@
import React from 'react';
const WritingEdit = () => {
return null;
}
export default WritingEdit;

View File

@@ -11,14 +11,15 @@ interface Props {
className?: string; className?: string;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
bgColor?: string;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
} }
export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) { export default function Layout({user, children, className, bgColor="bg-white", navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
const router = useRouter(); const router = useRouter();
return ( return (
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative"> <main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
<Navbar <Navbar
path={router.pathname} path={router.pathname}
user={user} user={user}
@@ -33,12 +34,12 @@ export default function Layout({user, children, className, navDisabled = false,
focusMode={focusMode} focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter} onFocusLayerMouseEnter={onFocusLayerMouseEnter}
className="-md:hidden" className="-md:hidden"
userType={user.type} user={user}
userId={user.id}
/> />
<div <div
className={clsx( className={clsx(
"w-full min-h-full h-fit md:mr-8 bg-white shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2", `w-full min-h-full md:mr-8 ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`,
bgColor !== "bg-white" ? "justify-center" : "h-fit",
className, className,
)}> )}>
{children} {children}

View File

@@ -1,254 +1,179 @@
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { import {Ticket, TicketStatus, TicketStatusLabel, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
Ticket, import {User} from "@/interfaces/user";
TicketStatus, import {USER_TYPE_LABELS} from "@/resources/user";
TicketStatusLabel,
TicketType,
TicketTypeLabel,
} from "@/interfaces/ticket";
import { User } from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user";
import axios from "axios"; import axios from "axios";
import moment from "moment"; import moment from "moment";
import { useState } from "react"; import {useState} from "react";
import { toast } from "react-toastify"; import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import Button from "../Low/Button"; import Button from "../Low/Button";
import Input from "../Low/Input"; import Input from "../Low/Input";
import Select from "../Low/Select"; import Select from "../Low/Select";
import {checkAccess} from "@/utils/permissions";
interface Props { interface Props {
user: User; user: User;
ticket: Ticket; ticket: Ticket;
onClose: () => void; onClose: () => void;
} }
export default function TicketDisplay({ user, ticket, onClose }: Props) { export default function TicketDisplay({user, ticket, onClose}: Props) {
const [subject] = useState(ticket.subject); const [subject] = useState(ticket.subject);
const [type, setType] = useState<TicketType>(ticket.type); const [type, setType] = useState<TicketType>(ticket.type);
const [description] = useState(ticket.description); const [description] = useState(ticket.description);
const [reporter] = useState(ticket.reporter); const [reporter] = useState(ticket.reporter);
const [reportedFrom] = useState(ticket.reportedFrom); const [reportedFrom] = useState(ticket.reportedFrom);
const [status, setStatus] = useState(ticket.status); const [status, setStatus] = useState(ticket.status);
const [assignedTo, setAssignedTo] = useState<string | null>( const [assignedTo, setAssignedTo] = useState<string | null>(ticket.assignedTo || null);
ticket.assignedTo || null, const [isLoading, setIsLoading] = useState(false);
);
const [isLoading, setIsLoading] = useState(false);
const { users } = useUsers(); const {users} = useUsers();
const submit = () => { const submit = () => {
if (!type) if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
return toast.error("Please choose a type!", { toastId: "missing-type" });
setIsLoading(true); setIsLoading(true);
axios axios
.patch(`/api/tickets/${ticket.id}`, { .patch(`/api/tickets/${ticket.id}`, {
subject, subject,
type, type,
description, description,
reporter, reporter,
reportedFrom, reportedFrom,
status, status,
assignedTo, assignedTo,
}) })
.then(() => { .then(() => {
toast.success(`The ticket has been updated!`, { toastId: "submitted" }); toast.success(`The ticket has been updated!`, {toastId: "submitted"});
onClose(); onClose();
}) })
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
toast.error("Something went wrong, please try again later!", { toast.error("Something went wrong, please try again later!", {
toastId: "error", toastId: "error",
}); });
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const del = () => { const del = () => {
if (!confirm("Are you sure you want to delete this ticket?")) return; if (!confirm("Are you sure you want to delete this ticket?")) return;
setIsLoading(true); setIsLoading(true);
axios axios
.delete(`/api/tickets/${ticket.id}`) .delete(`/api/tickets/${ticket.id}`)
.then(() => { .then(() => {
toast.success(`The ticket has been deleted!`, { toastId: "submitted" }); toast.success(`The ticket has been deleted!`, {toastId: "submitted"});
onClose(); onClose();
}) })
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
toast.error("Something went wrong, please try again later!", { toast.error("Something went wrong, please try again later!", {
toastId: "error", toastId: "error",
}); });
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
return ( return (
<form className="flex flex-col gap-4 pt-8"> <form className="flex flex-col gap-4 pt-8">
<Input <Input label="Subject" type="text" name="subject" placeholder="Subject..." value={subject} onChange={(e) => null} disabled />
label="Subject"
type="text"
name="subject"
placeholder="Subject..."
value={subject}
onChange={(e) => null}
disabled
/>
<div className="-md:flex-col flex w-full items-center gap-4"> <div className="-md:flex-col flex w-full items-center gap-4">
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal"> <label className="text-mti-gray-dim text-base font-normal">Status</label>
Status <Select
</label> options={Object.keys(TicketStatusLabel).map((x) => ({
<Select value: x,
options={Object.keys(TicketStatusLabel).map((x) => ({ label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
value: x, }))}
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel], value={{value: status, label: TicketStatusLabel[status]}}
}))} onChange={(value) => setStatus((value?.value as TicketStatus) ?? undefined)}
value={{ value: status, label: TicketStatusLabel[status] }} placeholder="Status..."
onChange={(value) => />
setStatus((value?.value as TicketStatus) ?? undefined) </div>
} <div className="flex w-full flex-col gap-3">
placeholder="Status..." <label className="text-mti-gray-dim text-base font-normal">Type</label>
/> <Select
</div> options={Object.keys(TicketTypeLabel).map((x) => ({
<div className="flex w-full flex-col gap-3"> value: x,
<label className="text-mti-gray-dim text-base font-normal"> label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
Type }))}
</label> value={{value: type, label: TicketTypeLabel[type]}}
<Select onChange={(value) => setType(value!.value as TicketType)}
options={Object.keys(TicketTypeLabel).map((x) => ({ placeholder="Type..."
value: x, />
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel], </div>
}))} </div>
value={{ value: type, label: TicketTypeLabel[type] }}
onChange={(value) => setType(value!.value as TicketType)}
placeholder="Type..."
/>
</div>
</div>
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal"> <label className="text-mti-gray-dim text-base font-normal">Assignee</label>
Assignee <Select
</label> options={[
<Select {value: "me", label: "Assign to me"},
options={[ ...users
{ value: "me", label: "Assign to me" }, .filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
...users .map((u) => ({
.filter((x) => ["admin", "developer", "agent"].includes(x.type)) value: u.id,
.map((u) => ({ label: `${u.name} - ${u.email}`,
value: u.id, })),
label: `${u.name} - ${u.email}`, ]}
})), disabled={checkAccess(user, ["agent"])}
]} value={
disabled={user.type === "agent"} assignedTo
value={ ? {
assignedTo value: assignedTo,
? { label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
value: assignedTo, }
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`, : null
} }
: null onChange={(value) => (value ? setAssignedTo(value.value === "me" ? user.id : value.value) : setAssignedTo(null))}
} placeholder="Assignee..."
onChange={(value) => isClearable
value />
? setAssignedTo(value.value === "me" ? user.id : value.value) </div>
: setAssignedTo(null)
}
placeholder="Assignee..."
isClearable
/>
</div>
<div className="-md:flex-col flex w-full items-center gap-4"> <div className="-md:flex-col flex w-full items-center gap-4">
<Input <Input label="Reported From" type="text" name="reportedFrom" onChange={() => null} value={reportedFrom} disabled />
label="Reported From" <Input label="Date" type="text" name="date" onChange={() => null} value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")} disabled />
type="text" </div>
name="reportedFrom"
onChange={() => null}
value={reportedFrom}
disabled
/>
<Input
label="Date"
type="text"
name="date"
onChange={() => null}
value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")}
disabled
/>
</div>
<div className="-md:flex-col flex w-full items-center gap-4"> <div className="-md:flex-col flex w-full items-center gap-4">
<Input <Input label="Reporter's Name" type="text" name="reporter" onChange={() => null} value={reporter.name} disabled />
label="Reporter's Name" <Input label="Reporter's E-mail" type="text" name="reporter" onChange={() => null} value={reporter.email} disabled />
type="text" <Input
name="reporter" label="Reporter's Type"
onChange={() => null} type="text"
value={reporter.name} name="reporterType"
disabled onChange={() => null}
/> value={USER_TYPE_LABELS[reporter.type]}
<Input disabled
label="Reporter's E-mail" />
type="text" </div>
name="reporter"
onChange={() => null}
value={reporter.email}
disabled
/>
<Input
label="Reporter's Type"
type="text"
name="reporterType"
onChange={() => null}
value={USER_TYPE_LABELS[reporter.type]}
disabled
/>
</div>
<textarea <textarea
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8" className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
placeholder="Write your ticket's description here..." placeholder="Write your ticket's description here..."
contentEditable={false} contentEditable={false}
value={description} value={description}
spellCheck spellCheck
/> />
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4"> <div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
<Button <Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={del} isLoading={isLoading}>
type="button" Delete
color="red" </Button>
className="w-full md:max-w-[200px]"
variant="outline"
onClick={del}
isLoading={isLoading}
>
Delete
</Button>
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4"> <div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
<Button <Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
type="button" Cancel
color="red" </Button>
className="w-full md:max-w-[200px]" <Button type="button" className="w-full md:max-w-[200px]" isLoading={isLoading} onClick={submit}>
variant="outline" Update
onClick={onClose} </Button>
isLoading={isLoading} </div>
> </div>
Cancel </form>
</Button> );
<Button
type="button"
className="w-full md:max-w-[200px]"
isLoading={isLoading}
onClick={submit}
>
Update
</Button>
</div>
</div>
</form>
);
} }

View File

@@ -0,0 +1,39 @@
import { useCallback } from "react";
const HighlightContent: React.FC<{
html: string;
highlightPhrases: string[],
firstOccurence?: boolean
}> = ({
html,
highlightPhrases,
firstOccurence = false
}) => {
const createHighlightedContent = useCallback(() => {
if (highlightPhrases.length === 0) {
return { __html: html };
}
const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'i');
const globalRegex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi');
let highlightedHtml = html;
if (firstOccurence) {
highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
} else {
highlightedHtml = html.replace(globalRegex, (match) => `<span style="background-color: yellow;">${match}</span>`);
}
return { __html: highlightedHtml };
}, [html, highlightPhrases, firstOccurence]);
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
};
export default HighlightContent;

View File

@@ -0,0 +1,168 @@
import React, { useRef, useEffect, useState, useCallback, ReactNode } from 'react';
import { useSpring, animated } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import clsx from 'clsx';
interface InfiniteCarouselProps {
children: React.ReactNode;
height: string;
speed?: number;
gap?: number;
overlay?: ReactNode;
overlayFunc?: (index: number) => void;
overlayClassName?: string;
}
const InfiniteCarousel: React.FC<InfiniteCarouselProps> = ({
children,
height,
speed = 20000,
gap = 16,
overlay = undefined,
overlayFunc = undefined,
overlayClassName = ""
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState<number>(0);
const itemCount = React.Children.count(children);
const [isDragging, setIsDragging] = useState<boolean>(false);
const [itemWidth, setItemWidth] = useState<number>(0);
const [isInfinite, setIsInfinite] = useState<boolean>(true);
const dragStartX = useRef<number>(0);
useEffect(() => {
const handleResize = () => {
if (containerRef.current) {
const containerWidth = containerRef.current.clientWidth;
setContainerWidth(containerWidth);
const firstChild = containerRef.current.firstElementChild?.firstElementChild as HTMLElement;
if (firstChild) {
const childWidth = firstChild.offsetWidth;
setItemWidth(childWidth);
const totalContentWidth = (childWidth + gap) * itemCount - gap;
setIsInfinite(totalContentWidth > containerWidth);
}
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [gap, itemCount]);
const totalWidth = (itemWidth + gap) * itemCount;
const [{ x }, api] = useSpring(() => ({
from: { x: 0 },
to: { x: -totalWidth },
config: { duration: speed },
loop: true,
}));
const startAnimation = useCallback(() => {
if (isInfinite) {
api.start({
from: { x: x.get() },
to: { x: x.get() - totalWidth },
config: { duration: speed },
loop: true,
});
} else {
api.stop();
api.start({ x: 0, immediate: true });
}
}, [api, x, totalWidth, speed, isInfinite]);
useEffect(() => {
if (containerWidth > 0 && !isDragging) {
startAnimation();
}
}, [containerWidth, isDragging, startAnimation]);
const bind = useDrag(({ down, movement: [mx], first }) => {
if (!isInfinite) return;
if (first) {
setIsDragging(true);
api.stop();
dragStartX.current = x.get();
}
if (down) {
let newX = dragStartX.current + mx;
newX = ((newX % totalWidth) + totalWidth) % totalWidth;
if (newX > 0) newX -= totalWidth;
api.start({ x: newX, immediate: true });
} else {
setIsDragging(false);
startAnimation();
}
}, {
filterTaps: true,
from: () => [x.get(), 0],
});
return (
<div
className="overflow-hidden relative select-none"
style={{ height, touchAction: 'pan-y' }}
ref={containerRef}
{...(isInfinite ? bind() : {})}
>
<animated.div
className="flex"
style={{
display: 'flex',
willChange: 'transform',
transform: isInfinite
? x.to((x) => `translate3d(${x}px, 0, 0)`)
: 'none',
gap: `${gap}px`,
width: 'fit-content',
}}
>
{React.Children.map(children, (child, i) => (
<div
key={i}
className="flex-shrink-0 relative"
>
{overlay !== undefined && overlayFunc !== undefined && (
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
{overlay}
</div>
)}
<div
className="select-none"
style={{ pointerEvents: 'none' }}
>
{child}
</div>
</div>
))}
{isInfinite && React.Children.map(children, (child, i) => (
<div
key={`clone-${i}`}
className="flex-shrink-0 relative"
>
{overlay !== undefined && overlayFunc !== undefined && (
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
{overlay}
</div>
)}
<div
className="select-none"
style={{ pointerEvents: 'none' }}
>
{child}
</div>
</div>
))}
</animated.div>
</div>
);
};
export default InfiniteCarousel;

51
src/components/List.tsx Normal file
View File

@@ -0,0 +1,51 @@
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
export default function List<T>({data, columns}: {data: T[]; columns: any[]}) {
const table = useReactTable({
data,
columns: columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder ? null : (
<>
<div
{...{
className: header.column.getCanSort() ? "cursor-pointer select-none py-4 text-left first:pl-4" : "",
onClick: header.column.getToggleSortingHandler(),
}}>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: " 🔼",
desc: " 🔽",
}[header.column.getIsSorted() as string] ?? null}
</div>
</>
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -4,7 +4,7 @@ import ReactSelect, {GroupBase, StylesConfig} from "react-select";
interface Option { interface Option {
[key: string]: any; [key: string]: any;
value: string; value: string | null;
label: string; label: string;
} }

View File

@@ -7,6 +7,7 @@ import {ReactNode, useEffect, useState} from "react";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs"; import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
import ProgressBar from "../Low/ProgressBar"; import ProgressBar from "../Low/ProgressBar";
import TimerEndedModal from "../TimerEndedModal"; import TimerEndedModal from "../TimerEndedModal";
import Timer from "./Timer";
interface Props { interface Props {
minTimer: number; minTimer: number;
@@ -15,35 +16,13 @@ interface Props {
exerciseIndex: number; exerciseIndex: number;
totalExercises: number; totalExercises: number;
disableTimer?: boolean; disableTimer?: boolean;
partLabel?: string;
showTimer?: boolean;
} }
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) { export default function ModuleTitle({
const [timer, setTimer] = useState(minTimer * 60); minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel, showTimer = true
const [showModal, setShowModal] = useState(false); }: Props) {
const [warningMode, setWarningMode] = useState(false);
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
const {timeSpent} = useExamStore((state) => state);
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
useEffect(() => {
if (!disableTimer) {
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
return () => {
clearInterval(timerInterval);
};
}
}, [disableTimer, minTimer]);
useEffect(() => {
if (timer <= 0) setShowModal(true);
}, [timer]);
useEffect(() => {
if (timer < 300 && !warningMode) setWarningMode(true);
}, [timer, warningMode]);
const moduleIcon: {[key in Module]: ReactNode} = { const moduleIcon: {[key in Module]: ReactNode} = {
reading: <BsBook className="text-ielts-reading w-6 h-6" />, reading: <BsBook className="text-ielts-reading w-6 h-6" />,
@@ -55,49 +34,39 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
return ( return (
<> <>
<TimerEndedModal {showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
isOpen={showModal} <div className="w-full">
onClose={() => { {partLabel && (
setHasExamEnded(true); <div className="text-3xl space-y-4">
setShowModal(false); {partLabel.split("\n\n").map((line, index) => {
}} if (index == 0)
/> return (
<motion.div <p key={index} className="font-bold">
className={clsx( {line}
"absolute top-4 right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy", </p>
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt", );
)} else
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}} return (
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}} <p key={index} className="text-2xl font-semibold">
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}> {line}
<BsStopwatch className="w-6 h-6" /> </p>
<span className="text-base font-semibold w-12"> );
{timer > 0 && ( })}
<> </div>
{Math.floor(timer / 60) )}
.toString(10) <div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5")}>
.padStart(2, "0")} <div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
: <div className="flex flex-col gap-3 w-full">
{Math.floor(timer % 60) <div className="w-full flex justify-between">
.toString(10) <span className="text-base font-semibold">
.padStart(2, "0")} {moduleLabels[module]} exam {label && `- ${label}`}
</> </span>
)} <span className="text-sm font-semibold self-end">
{timer <= 0 && <>00:00</>} Question {exerciseIndex}/{totalExercises}
</span> </span>
</motion.div> </div>
<div className="flex gap-6 w-full h-fit items-center mt-5"> <ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
<div className="flex flex-col gap-3 w-full">
<div className="w-full flex justify-between">
<span className="text-base font-semibold">
{moduleLabels[module]} exam {label && `- ${label}`}
</span>
<span className="text-sm font-semibold self-end">
Exercise {exerciseIndex}/{totalExercises}
</span>
</div> </div>
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
</div> </div>
</div> </div>
</> </>

View File

@@ -0,0 +1,80 @@
import useExamStore from "@/stores/examStore";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import TimerEndedModal from "../TimerEndedModal";
import clsx from "clsx";
import { BsStopwatch } from "react-icons/bs";
interface Props {
minTimer: number;
disableTimer?: boolean;
standalone?: boolean;
}
const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) => {
const [timer, setTimer] = useState(minTimer * 60);
const [showModal, setShowModal] = useState(false);
const [warningMode, setWarningMode] = useState(false);
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
const { timeSpent } = useExamStore((state) => state);
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
useEffect(() => {
if (!disableTimer) {
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
return () => {
clearInterval(timerInterval);
};
}
}, [disableTimer, minTimer]);
useEffect(() => {
if (timer <= 0) setShowModal(true);
}, [timer]);
useEffect(() => {
if (timer < 300 && !warningMode) setWarningMode(true);
}, [timer, warningMode]);
return (
<>
<TimerEndedModal
isOpen={showModal}
onClose={() => {
setHasExamEnded(true);
setShowModal(false);
}}
/>
<motion.div
className={clsx(
"absolute right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
standalone ? "top-6" : "top-4",
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
)}
initial={{ scale: warningMode && !disableTimer ? 0.8 : 1 }}
animate={{ scale: warningMode && !disableTimer ? 1.1 : 1 }}
transition={{ repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut" }}>
<BsStopwatch className="w-6 h-6" />
<span className="text-base font-semibold w-12">
{timer > 0 && (
<>
{Math.floor(timer / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(timer % 60)
.toString(10)
.padStart(2, "0")}
</>
)}
{timer <= 0 && <>00:00</>}
</span>
</motion.div>
</>
);
}
export default Timer;

View File

@@ -1,160 +1,215 @@
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import {Dialog, Transition} from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {Fragment} from "react"; import { Fragment } from "react";
import {BsXLg} from "react-icons/bs"; import { BsXLg } from "react-icons/bs";
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
path: string; path: string;
user: User; user: User;
disableNavigation?: boolean; disableNavigation?: boolean;
} }
export default function MobileMenu({isOpen, onClose, path, user, disableNavigation}: Props) { export default function MobileMenu({
const router = useRouter(); isOpen,
onClose,
path,
user,
disableNavigation,
}: Props) {
const router = useRouter();
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500); setTimeout(() => router.reload(), 500);
}); });
}; };
return ( return (
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}> <Dialog as="div" className="relative z-10" onClose={onClose}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0"> leaveTo="opacity-0"
<div className="fixed inset-0 bg-black bg-opacity-25" /> >
</Transition.Child> <div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto"> <div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center text-center"> <div className="flex min-h-full items-center justify-center text-center">
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100" enterTo="opacity-100 scale-100"
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"> leaveTo="opacity-0 scale-95"
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all"> >
<Dialog.Title as="header" className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"> <Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
<Link href={disableNavigation ? "" : "/"}> <Dialog.Title
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} /> as="header"
</Link> className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
<div className="cursor-pointer" onClick={onClose} tabIndex={0}> >
<BsXLg className="text-mti-purple-light text-2xl" onClick={onClose} /> <Link href={disableNavigation ? "" : "/"}>
</div> <Image
</Dialog.Title> src="/logo_title.png"
<div className="flex h-full flex-col gap-6 px-8 text-lg"> alt="EnCoach logo"
<Link width={69}
href={disableNavigation ? "" : "/"} height={69}
className={clsx( />
"w-fit transition duration-300 ease-in-out", </Link>
path === "/" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", <div
)}> className="cursor-pointer"
Dashboard onClick={onClose}
</Link> tabIndex={0}
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && ( >
<> <BsXLg
<Link className="text-mti-purple-light text-2xl"
href={disableNavigation ? "" : "/exam"} onClick={onClose}
className={clsx( />
"w-fit transition duration-300 ease-in-out", </div>
path === "/exam" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", </Dialog.Title>
)}> <div className="flex h-full flex-col gap-6 px-8 text-lg">
Exams <Link
</Link> href={disableNavigation ? "" : "/"}
<Link className={clsx(
href={disableNavigation ? "" : "/exercises"} "w-fit transition duration-300 ease-in-out",
className={clsx( path === "/" &&
"w-fit transition duration-300 ease-in-out", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
path === "/exercises" && )}
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", >
)}> Dashboard
Exercises </Link>
</Link> {checkAccess(user, ["student", "teacher", "developer"]) && (
</> <>
)} <Link
<Link href={disableNavigation ? "" : "/exam"}
href={disableNavigation ? "" : "/stats"} className={clsx(
className={clsx( "w-fit transition duration-300 ease-in-out",
"w-fit transition duration-300 ease-in-out", path === "/exam" &&
path === "/stats" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)}> )}
Stats >
</Link> Exams
<Link </Link>
href={disableNavigation ? "" : "/record"} <Link
className={clsx( href={disableNavigation ? "" : "/exercises"}
"w-fit transition duration-300 ease-in-out", className={clsx(
path === "/record" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "w-fit transition duration-300 ease-in-out",
)}> path === "/exercises" &&
Record "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
</Link> )}
{["admin", "developer", "agent", "corporate"].includes(user.type) && ( >
<Link Exercises
href={disableNavigation ? "" : "/payment-record"} </Link>
className={clsx( </>
"w-fit transition duration-300 ease-in-out", )}
path === "/payment-record" && <Link
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", href={disableNavigation ? "" : "/stats"}
)}> className={clsx(
Payment Record "w-fit transition duration-300 ease-in-out",
</Link> path === "/stats" &&
)} "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
{["admin", "developer", "corporate", "teacher"].includes(user.type) && ( )}
<Link >
href={disableNavigation ? "" : "/settings"} Stats
className={clsx( </Link>
"w-fit transition duration-300 ease-in-out", <Link
path === "/settings" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", href={disableNavigation ? "" : "/record"}
)}> className={clsx(
Settings "w-fit transition duration-300 ease-in-out",
</Link> path === "/record" &&
)} "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
{["admin", "developer", "agent"].includes(user.type) && ( )}
<Link >
href={disableNavigation ? "" : "/tickets"} Record
className={clsx( </Link>
"w-fit transition duration-300 ease-in-out", {checkAccess(user, [
path === "/tickets" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "admin",
)}> "developer",
Tickets "agent",
</Link> "corporate",
)} "mastercorporate",
<Link ]) && (
href={disableNavigation ? "" : "/profile"} <Link
className={clsx( href={disableNavigation ? "" : "/payment-record"}
"w-fit transition duration-300 ease-in-out", className={clsx(
path === "/profile" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ", "w-fit transition duration-300 ease-in-out",
)}> path === "/payment-record" &&
Profile "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
</Link> )}
>
Payment Record
</Link>
)}
{checkAccess(user, [
"admin",
"developer",
"corporate",
"teacher",
"mastercorporate",
]) && (
<Link
href={disableNavigation ? "" : "/settings"}
className={clsx(
"w-fit transition duration-300 ease-in-out",
path === "/settings" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)}
>
Settings
</Link>
)}
{checkAccess(user, ["admin", "developer", "agent"]) && (
<Link
href={disableNavigation ? "" : "/tickets"}
className={clsx(
"w-fit transition duration-300 ease-in-out",
path === "/tickets" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)}
>
Tickets
</Link>
)}
<Link
href={disableNavigation ? "" : "/profile"}
className={clsx(
"w-fit transition duration-300 ease-in-out",
path === "/profile" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)}
>
Profile
</Link>
<span <span
className={clsx("w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out")} className={clsx(
onClick={logout}> "w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out"
Logout )}
</span> onClick={logout}
</div> >
</Dialog.Panel> Logout
</Transition.Child> </span>
</div> </div>
</div> </Dialog.Panel>
</Dialog> </Transition.Child>
</Transition> </div>
); </div>
</Dialog>
</Transition>
);
} }

View File

@@ -1,14 +1,16 @@
import {Dialog, Transition} from "@headlessui/react"; import {Dialog, Transition} from "@headlessui/react";
import clsx from "clsx";
import {Fragment, ReactElement} from "react"; import {Fragment, ReactElement} from "react";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
title?: string; title?: string;
className?: string;
children?: ReactElement; children?: ReactElement;
} }
export default function Modal({isOpen, title, onClose, children}: Props) { export default function Modal({isOpen, title, className, onClose, children}: Props) {
return ( return (
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-[200]" onClose={onClose}> <Dialog as="div" className="relative z-[200]" onClose={onClose}>
@@ -33,7 +35,11 @@ export default function Modal({isOpen, title, onClose, children}: Props) {
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"> leaveTo="opacity-0 scale-95">
<Dialog.Panel className="w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all"> <Dialog.Panel
className={clsx(
"w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all",
className,
)}>
{title && ( {title && (
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900"> <Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
{title} {title}

View File

@@ -0,0 +1,24 @@
import clsx from "clsx";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
const ModuleBadge: React.FC<{ module: string; level?: number }> = ({ module, level }) => (
<div
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" />}
{/* do not switch to level && it will convert the 0.0 to 0*/}
{level !== undefined && (<span className="text-sm">{level.toFixed(1)}</span>)}
</div>
);
export default ModuleBadge;

View File

@@ -1,118 +1,219 @@
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import Link from "next/link"; import Link from "next/link";
import FocusLayer from "@/components/FocusLayer"; import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled"; import { preventNavigation } from "@/utils/navigation.disabled";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {BsList, BsQuestionCircle, BsQuestionCircleFill} from "react-icons/bs"; import { BsList, BsQuestionCircle, BsQuestionCircleFill } 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 {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {Type} from "@/interfaces/user"; import { Type } from "@/interfaces/user";
import {USER_TYPE_LABELS} from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import {isUserFromCorporate} from "@/utils/groups"; import { isUserFromCorporate } from "@/utils/groups";
import Button from "./Low/Button"; import Button from "./Low/Button";
import Modal from "./Modal"; import Modal from "./Modal";
import Input from "./Low/Input"; import Input from "./Low/Input";
import TicketSubmission from "./High/TicketSubmission"; import TicketSubmission from "./High/TicketSubmission";
import { Module } from "@/interfaces";
import Badge from "./Low/Badge";
import {
BsArrowRepeat,
BsBook,
BsCheck,
BsCheckCircle,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
interface Props { interface Props {
user: User; user: User;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
path: string; path: string;
} }
/* 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({
const [isMenuOpen, setIsMenuOpen] = useState(false); user,
const [disablePaymentPage, setDisablePaymentPage] = useState(true); path,
const [isTicketOpen, setIsTicketOpen] = useState(false); navDisabled = false,
focusMode = false,
onFocusLayerMouseEnter,
}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
const [isTicketOpen, setIsTicketOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const disableNavigation = preventNavigation(navDisabled, focusMode); const disableNavigation = preventNavigation(navDisabled, focusMode);
const expirationDateColor = (date: Date) => { const expirationDateColor = (date: Date) => {
const momentDate = moment(date); const momentDate = moment(date);
const today = moment(new Date()); const today = moment(new Date());
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light"; if (today.add(1, "days").isAfter(momentDate))
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light"; return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; if (today.add(3, "days").isAfter(momentDate))
}; return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate))
return "!bg-mti-orange-ultralight border-mti-orange-light";
};
const showExpirationDate = () => { const showExpirationDate = () => {
if (!user.subscriptionExpirationDate) return false; if (!user.subscriptionExpirationDate) return false;
const momentDate = moment(user.subscriptionExpirationDate); const momentDate = moment(user.subscriptionExpirationDate);
const today = moment(new Date()); const today = moment(new Date());
return today.add(7, "days").isAfter(momentDate); return today.add(7, "days").isAfter(momentDate);
}; };
useEffect(() => { useEffect(() => {
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false); if (user.type !== "student" && user.type !== "teacher")
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result)); return setDisablePaymentPage(false);
}, [user]); isUserFromCorporate(user.id).then((result) =>
setDisablePaymentPage(result)
);
}, [user]);
return ( const badges = [
<> {
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket"> module: "reading",
<TicketSubmission user={user} page={router.asPath} onClose={() => setIsTicketOpen(false)} /> icon: () => <BsBook className="h-4 w-4 text-white" />,
</Modal> achieved: user.levels.reading >= user.desiredLevels.reading,
},
{user && ( {
<MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} /> module: "listening",
)} icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4"> achieved: user.levels.listening >= user.desiredLevels.listening,
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8"> },
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" /> {
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1> module: "writing",
</Link> icon: () => <BsPen className="h-4 w-4 text-white" />,
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6"> achieved: user.levels.writing >= user.desiredLevels.writing,
{/* OPEN TICKET SYSTEM */} },
<button {
className={clsx( module: "speaking",
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1", icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20", achieved: user.levels.speaking >= user.desiredLevels.speaking,
)} },
data-tip="Submit a help/feedback ticket" {
onClick={() => setIsTicketOpen(true)}> module: "level",
<BsQuestionCircleFill /> icon: () => <BsClipboard className="h-4 w-4 text-white" />,
</button> achieved: user.levels.level >= user.desiredLevels.level,
},
];
{showExpirationDate() && ( return (
<Link <>
href={!!user.subscriptionExpirationDate && !disablePaymentPage ? "/payment" : ""} <Modal
data-tip="Expiry date" isOpen={isTicketOpen}
className={clsx( onClose={() => setIsTicketOpen(false)}
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none", title="Submit a ticket"
"tooltip tooltip-bottom transition duration-300 ease-in-out", >
!user.subscriptionExpirationDate <TicketSubmission
? "bg-mti-green-ultralight border-mti-green-light" user={user}
: expirationDateColor(user.subscriptionExpirationDate), page={router.asPath}
"border-mti-gray-platinum bg-white", onClose={() => setIsTicketOpen(false)}
)}> />
{!user.subscriptionExpirationDate && "Unlimited"} </Modal>
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
</Link> {user && (
)} <MobileMenu
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6"> disableNavigation={disableNavigation}
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" /> path={path}
<span className="-md:hidden text-right"> isOpen={isMenuOpen}
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "} onClose={() => setIsMenuOpen(false)}
{USER_TYPE_LABELS[user.type]} user={user}
</span> />
</Link> )}
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}> <header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
<BsList className="text-mti-purple-light h-8 w-8" /> <Link
</div> href={disableNavigation ? "" : "/"}
</div> className=" flex items-center gap-8 md:px-8"
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />} >
</header> <img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
</> <h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
); </Link>
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
{user.type === "student" &&
badges.map((badge) => (
<div
key={badge.module}
className={`${badge.achieved ? `bg-ielts-${badge.module}`: 'bg-mti-gray-anti-flash'} flex h-8 w-8 items-center justify-center rounded-full`}
>
{badge.icon()}
</div>
))}
{/* OPEN TICKET SYSTEM */}
<button
className={clsx(
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20"
)}
data-tip="Submit a help/feedback ticket"
onClick={() => setIsTicketOpen(true)}
>
<BsQuestionCircleFill />
</button>
{showExpirationDate() && (
<Link
href={
!!user.subscriptionExpirationDate && !disablePaymentPage
? "/payment"
: ""
}
data-tip="Expiry date"
className={clsx(
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
"tooltip tooltip-bottom transition duration-300 ease-in-out",
!user.subscriptionExpirationDate
? "bg-mti-green-ultralight border-mti-green-light"
: expirationDateColor(user.subscriptionExpirationDate),
"border-mti-gray-platinum bg-white"
)}
>
{!user.subscriptionExpirationDate && "Unlimited"}
{user.subscriptionExpirationDate &&
moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
</Link>
)}
<Link
href={disableNavigation ? "" : "/profile"}
className="-md:hidden flex items-center justify-end gap-6"
>
<img
src={user.profilePicture}
alt={user.name}
className="h-10 w-10 rounded-full object-cover"
/>
<span className="-md:hidden text-right">
{user.type === "corporate"
? `${user.corporateInformation?.companyInformation.name} |`
: ""}{" "}
{user.name} | {USER_TYPE_LABELS[user.type]}
</span>
</Link>
<div
className="cursor-pointer md:hidden"
onClick={() => setIsMenuOpen(true)}
>
<BsList className="text-mti-purple-light h-8 w-8" />
</div>
</div>
{focusMode && (
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
)}
</header>
</>
);
} }

View File

@@ -1,138 +0,0 @@
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 { useState, useEffect } from "react";
import { toast } from "react-toastify";
interface Props {
clientID: string;
currency: string;
price: number;
duration: number;
duration_unit: DurationUnit;
loadScript?: boolean;
setIsLoading: (isLoading: boolean) => void;
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
trackingId?: string;
}
export default function PayPalPayment({
clientID,
price,
currency,
duration,
duration_unit,
loadScript,
setIsLoading,
onSuccess,
trackingId,
}: Props) {
const createOrder = async (
data: CreateOrderData,
actions: CreateOrderActions
): Promise<string> => {
if (!trackingId) {
throw new Error("trackingId is not set");
}
setIsLoading(true);
return axios
.post<OrderResponseBody>("/api/paypal", {
currencyCode: currency,
price,
trackingId,
})
.then((response) => response.data)
.then((data) => {
setIsLoading(false);
return data.id;
})
.catch((err) => {
setIsLoading(false);
return err;
});
};
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
if (!trackingId) {
throw new Error("trackingId is not set");
}
axios
.post<{ ok: boolean; reason?: string }>("/api/paypal/approve", {
id: data.orderID,
duration,
duration_unit,
trackingId,
})
.then((request) => {
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);
})
.catch((err) => {
console.error(err);
toast.error("Something went wrong, please try again later");
});
};
const onError = async (data: Record<string, unknown>) => {
setIsLoading(false);
};
const onCancel = async (
data: Record<string, unknown>,
actions: OnCancelledActions
) => {
setIsLoading(false);
};
if (trackingId) {
return loadScript ? (
<PayPalScriptProvider
options={{
clientId: clientID,
currency,
intent: "capture",
commit: true,
}}
>
<PayPalButtons
className="w-full"
style={{ layout: "vertical" }}
createOrder={createOrder}
onApprove={onApprove}
onCancel={onCancel}
onError={onError}
/>
</PayPalScriptProvider>
) : (
<PayPalButtons
className="w-full"
style={{ layout: "vertical" }}
createOrder={createOrder}
onApprove={onApprove}
onCancel={onCancel}
onError={onError}
/>
);
}
return null;
}

View File

@@ -20,15 +20,6 @@ interface Props {
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) { export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [firstName, setFirstName] = useState(user.name.split(" ")[0]);
const [lastName, setLastName] = useState([...user.name.split(" ")].pop());
const [street, setStreet] = useState("");
const [apartment, setApartment] = useState("");
const [building, setBuilding] = useState("");
const [state, setState] = useState("");
const [floor, setFloor] = useState("");
const router = useRouter(); const router = useRouter();
@@ -50,16 +41,16 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc
}, },
}, },
billing_data: { billing_data: {
apartment: apartment || "N/A", apartment: "N/A",
building: building || "N/A", building: "N/A",
country: user.demographicInformation?.country || "N/A", country: user.demographicInformation?.country || "N/A",
email: user.email, email: user.email,
first_name: user.name.split(" ")[0], first_name: user.name.split(" ")[0],
last_name: [...user.name.split(" ")].pop() || "N/A", last_name: [...user.name.split(" ")].pop() || "N/A",
floor: floor || "N/A", floor: "N/A",
phone_number: user.demographicInformation?.phone || "N/A", phone_number: user.demographicInformation?.phone || "N/A",
state: state || "N/A", state: "N/A",
street: street || "N/A", street: "N/A",
}, },
extras: { extras: {
userID: user.id, userID: user.id,
@@ -71,7 +62,6 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc
const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention); const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention);
router.push(response.data.iframeURL); router.push(response.data.iframeURL);
setIsModalOpen(false);
} catch (error) { } catch (error) {
console.error("Error starting card payment process:", error); console.error("Error starting card payment process:", error);
} }
@@ -79,27 +69,7 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc
return ( return (
<> <>
<Modal isOpen={isModalOpen} title="Billing Data" onClose={() => setIsModalOpen(false)}> <Button isLoading={isLoading} onClick={handleCardPayment}>
<div className="flex flex-col gap-4 mt-4">
<div className="grid grid-cols-2 gap-4">
<Input label="First Name" value={firstName} onChange={setFirstName} type="text" name="firstName" />
<Input label="Last Name" value={lastName} onChange={setLastName} type="text" name="lastName" />
</div>
<div className="grid grid-cols-3 -md:grid-cols-1 gap-4">
<Input label="State" value={state} onChange={setState} type="text" name="state" />
<Input label="Street" value={street} onChange={setStreet} type="text" name="street" />
<Input label="Building" value={building} onChange={setBuilding} type="text" name="building" />
</div>
<div className="grid grid-cols-2 gap-4">
<Input label="Floor" value={floor} onChange={setFloor} type="text" name="floor" />
<Input label="Apartment" value={apartment} onChange={setApartment} type="text" name="apartment" />
</div>
<Button className="w-full max-w-[200px] self-end mt-4" disabled={!firstName || !lastName} onClick={handleCardPayment}>
Complete Payment
</Button>
</div>
</Modal>
<Button isLoading={isLoading} onClick={() => setIsModalOpen(true)}>
Select Select
</Button> </Button>
</> </>

View File

@@ -0,0 +1,82 @@
import React from "react";
import {Permission} from "@/interfaces/permissions";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable, Row} from "@tanstack/react-table";
import Link from "next/link";
import {convertCamelCaseToReadable} from "@/utils/string";
interface Props {
permissions: Permission[];
}
const columnHelper = createColumnHelper<Permission>();
const defaultColumns = [
columnHelper.accessor("type", {
header: () => <span>Type</span>,
cell: ({row, getValue}) => (
<Link
href={`/permissions/${row.original.id}`}
key={row.id}
className="underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer">
{convertCamelCaseToReadable(getValue() as string)}
</Link>
),
}),
];
export default function PermissionList({permissions}: Props) {
const table = useReactTable({
data: permissions,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const groupedData: {[key: string]: Row<Permission>[]} = table.getRowModel().rows.reduce((groups: {[key: string]: Row<Permission>[]}, row) => {
const parent = row.original.topic;
if (!groups[parent]) {
groups[parent] = [];
}
groups[parent].push(row);
return groups;
}, {});
return (
<div className="w-full h-full">
<div className="w-full flex flex-col gap-2">
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{Object.keys(groupedData).map((parent) => (
<React.Fragment key={parent}>
<tr>
<td className="px-2 py-2 items-center w-fit">
<strong>{parent}</strong>
</td>
</tr>
{groupedData[parent].map((row, i) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { Dialog, Transition } from "@headlessui/react";
import { Fragment } from "react";
import Button from "./Low/Button";
interface Props {
isOpen: boolean;
blankQuestions?: boolean;
finishingWhat? : string;
onClose: (next?: boolean) => void;
}
export default function QuestionsModal({ isOpen, onClose, blankQuestions = true, finishingWhat = "module" }: Props) {
return (
<Transition show={isOpen} as={Fragment}>
<Dialog onClose={() => onClose(false)} className="relative z-50">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black/30" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
{blankQuestions ? (
<>
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
<span>
Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be
able to change the answers of the current one, including your unanswered questions. <br />
<br />
Are you sure you want to continue without completing those questions?
</span>
<div className="w-full flex justify-between mt-8">
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
Go Back
</Button>
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
Continue
</Button>
</div>
</>
): (
<>
<Dialog.Title className="font-bold text-xl">Confirm Submission</Dialog.Title>
<span>
Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be
able to review the answers of the current one. <br />
<br />
Are you sure you want to continue?
</span>
<div className="w-full flex justify-between mt-8">
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
Go Back
</Button>
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
Continue
</Button>
</div>
</>
)}
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
);
}

View File

@@ -0,0 +1,64 @@
import clsx from 'clsx';
import React from 'react';
interface RadialProgressBarProps {
percentage: number;
text: string;
color: string;
spanClassName?: string;
size?: number;
strokeWidth?: number;
strokeOpacity?: number;
}
// https://gist.github.com/eYinka/873be69fae3ef27b103681b8a9f5e379 Omarmarei's answer
const RadialProgressBar: React.FC<RadialProgressBarProps> = ({
percentage,
text,
color,
spanClassName = "",
size = 100,
strokeWidth = 10,
strokeOpacity = 0.5
}) => {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percentage / 100) * circumference;
return (
<div className='relative flex items-center justify-center' style={{ width: size, height: size}}>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`
}
className="circular-progress-bar"
>
<circle className="circle-bg" stroke="#e6e6e6" strokeWidth={strokeWidth}
fill="none"
cx={size / 2}
cy={size / 2}
r={radius}
strokeOpacity={strokeOpacity}
/>
<circle
className="circle-progress"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="none"
cx={size / 2}
cy={size / 2}
r={radius}
strokeDasharray={circumference}
strokeDashoffset={offset}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
strokeOpacity={strokeOpacity}
/>
</svg>
<span className={clsx('absolute', spanClassName)}>{text}</span>
</div>
);
};
export default RadialProgressBar;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import clsx from 'clsx';
interface Segment {
percentage: number;
subtitle: string;
color: string;
}
interface SegmentedProgressBarProps {
segments: Segment[];
height?: number;
className?: string;
}
const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({ segments, height=15, className="" }) => {
return (
<div className={className}>
<div className="relative flex rounded-full overflow-hidden bg-gray-200" style={{height: `${height}px`}}>
{segments.map((segment, index) => (
<div
key={index}
className={clsx(
'h-full opacity-50',
'transition-all duration-500 ease-out',
`bg-${segment.color}`,
{
'rounded-l-full': index === 0,
'rounded-r-full': index === segments.length - 1,
'rounded-none': index !== 0 && index !== segments.length - 1
}
)}
style={{width: `${segment.percentage}%`}}
></div>
))}
</div>
<div className="mt-2 flex text-sm justify-between">
{segments.map((segment, index) => (
<div
key={index}
className="flex flex-col text-center w-fit"
>
<span className={clsx('font-semibold',`text-${segment.color}`)}>{segment.subtitle}</span>
<span>{`${segment.percentage}%`}</span>
</div>
))}
</div>
</div>
);
};
export default SegmentedProgressBar;

View File

@@ -12,7 +12,10 @@ import {
BsCloudFill, BsCloudFill,
BsCurrencyDollar, BsCurrencyDollar,
BsClipboardData, BsClipboardData,
BsFileLock,
BsPeople,
} from "react-icons/bs"; } from "react-icons/bs";
import {CiDumbbell} from "react-icons/ci";
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";
@@ -23,16 +26,17 @@ import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled"; import {preventNavigation} from "@/utils/navigation.disabled";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import usePreferencesStore from "@/stores/preferencesStore"; import usePreferencesStore from "@/stores/preferencesStore";
import {Type} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useTicketsListener from "@/hooks/useTicketsListener"; import useTicketsListener from "@/hooks/useTicketsListener";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
interface Props { interface Props {
path: string; path: string;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
className?: string; className?: string;
userType?: Type; user: User;
userId?: string;
} }
interface NavProps { interface NavProps {
@@ -72,12 +76,13 @@ const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false,
); );
}; };
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className, userId}: Props) { export default function Sidebar({path, navDisabled = false, focusMode = false, user, onFocusLayerMouseEnter, className}: Props) {
const router = useRouter(); const router = useRouter();
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]); const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
const {totalAssignedTickets} = useTicketsListener(userId); const {totalAssignedTickets} = useTicketsListener(user.id);
const {permissions} = usePermissions(user.id);
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
@@ -96,33 +101,25 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
)}> )}>
<div className="-xl:hidden flex-col gap-3 xl:flex"> <div className="-xl:hidden flex-col gap-3 xl:flex">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
{(userType === "student" || userType === "teacher" || userType === "developer") && ( {checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && (
<> <Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
<Nav
disabled={disableNavigation}
Icon={BsFileEarmarkText}
label="Exams"
path={path}
keyPath="/exam"
isMinimized={isMinimized}
/>
<Nav
disabled={disableNavigation}
Icon={BsPencil}
label="Exercises"
path={path}
keyPath="/exercises"
isMinimized={isMinimized}
/>
</>
)} )}
{(userType || "") !== 'agent' && ( {checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExercises") && (
<> <Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
</>
)} )}
{["admin", "developer", "agent", "corporate"].includes(userType || "") && ( {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
)}
{checkAccess(user, ["developer", "admin", "teacher", "student"], permissions) && (
<Nav disabled={disableNavigation} Icon={BsPeople} label="Groups" path={path} keyPath="/groups" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
)}
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], permissions, "viewPaymentRecords") && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsCurrencyDollar} Icon={BsCurrencyDollar}
@@ -132,7 +129,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && ( {checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]) && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsShieldFill} Icon={BsShieldFill}
@@ -142,7 +139,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{["admin", "developer", "agent"].includes(userType || "") && ( {checkAccess(user, ["admin", "developer", "agent"], permissions, "viewTickets") && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsClipboardData} Icon={BsClipboardData}
@@ -153,7 +150,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
badge={totalAssignedTickets} badge={totalAssignedTickets}
/> />
)} )}
{userType === "developer" && ( {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsCloudFill} Icon={BsCloudFill}
@@ -163,22 +160,55 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "agent"]) && (
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={isMinimized}
/>
)}
</div> </div>
<div className="-xl:flex flex-col gap-3 xl:hidden"> <div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
{(userType || "") !== 'agent' && ( {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<> <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} />
</>
)} )}
{userType !== "student" && ( {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["student"])) && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
)} )}
{userType === "developer" && ( {checkAccess(user, getTypesOfUser(["student"])) && (
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={BsShieldFill} label="Permissions" path={path} keyPath="/permissions" isMinimized={true} />
)}
{checkAccess(user, ["developer"]) && (
<>
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={true}
/>
</>
)} )}
</div> </div>

View File

@@ -1,53 +1,147 @@
import {FillBlanksExercise} from "@/interfaces/exam"; import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import clsx from "clsx"; import clsx from "clsx";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import {CommonProps} from "."; import { CommonProps } from ".";
import {Fragment} from "react"; import { Fragment } from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
export default function FillBlanksSolutions({
id,
type,
prompt,
solutions,
words,
text,
onNext,
onBack,
}: FillBlanksExercise & CommonProps) {
// next and back was all messed up and still don't know why, anyways
const storeUserSolutions = useExamStore((state) => state.userSolutions);
const correctUserSolutions = storeUserSolutions.find(
(solution) => solution.exercise === id
)?.solutions;
export default function FillBlanksSolutions({id, type, prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
const calculateScore = () => { const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter( const correct = correctUserSolutions!.filter((x) => {
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false, const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
).length; console.log(solution);
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; if (!solution) return false;
return {total, correct, missing}; const option = words.find((w) => {
if (typeof w === "string") {
return w.toLowerCase() === x.solution.toLowerCase();
} else if ('letter' in w) {
return w.word.toLowerCase() === x.solution.toLowerCase();
} else {
return w.id.toString() === x.id.toString();
}
});
if (!option) return false;
if (typeof option === "string") {
return solution.toLowerCase() === option.toLowerCase();
} else if ('letter' in option) {
return solution.toLowerCase() === option.word.toLowerCase();
} else if ('options' in option) {
return option.options[solution as keyof typeof option.options] == x.solution;
}
return false;
}).length;
const missing = total - correctUserSolutions!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing };
}; };
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every(
word => word && typeof word === 'object' && 'id' in word && 'options' in word
);
}
const renderLines = (line: string) => { const renderLines = (line: string) => {
return ( return (
<span> <span>
{reactStringReplace(line, /({{\d+}})/g, (match) => { {reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, ""); const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id); const userSolution = correctUserSolutions!.find((x) => x.id.toString() === id.toString());
const solution = solutions.find((x) => x.id === id)!; const answerSolution = solutions.find(sol => sol.id.toString() === id.toString())!.solution;
if (!userSolution) { if (!userSolution) {
let answerText;
if (typeCheckWordsMC(words)) {
const options = words.find((x) => x.id.toString() === id.toString());
const correctKey = Object.keys(options!.options).find(key =>
key.toLowerCase() === answerSolution.toLowerCase()
);
answerText = options!.options[correctKey as keyof typeof options];
} else {
answerText = answerSolution;
}
return ( return (
<button <button
className={clsx( className={clsx(
"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", "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} {answerText}
</button> </button>
); );
} }
if (userSolution.solution === solution.solution) { const userSolutionWord = words.find((w) =>
typeof w === "string"
? w.toLowerCase() === userSolution.solution.toLowerCase()
: 'letter' in w
? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
: 'options' in w
? w.id === userSolution.id
: false
);
const userSolutionText =
typeof userSolutionWord === "string"
? userSolutionWord
: userSolutionWord && 'letter' in userSolutionWord
? userSolutionWord.word
: userSolutionWord && 'options' in userSolutionWord
? userSolution.solution
: userSolution.solution;
let correct;
let solutionText;
if (typeCheckWordsMC(words)) {
const options = words.find((x) => x.id.toString() === id.toString());
if (options) {
const correctKey = Object.keys(options.options).find(key =>
key.toLowerCase() === answerSolution.toLowerCase()
);
correct = userSolution.solution == options.options[correctKey as keyof typeof options.options];
solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution;
} else {
correct = false;
solutionText = answerSolution;
}
} else {
correct = userSolutionText === answerSolution;
solutionText = answerSolution;
}
if (correct) {
return ( return (
<button <button
className={clsx( className={clsx(
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1", "rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light", userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)}> )}>
{solution.solution} {solutionText}
</button> </button>
); );
} } else {
if (userSolution.solution !== solution.solution) {
return ( return (
<> <>
<button <button
@@ -55,7 +149,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
"rounded-full hover:text-white hover:bg-mti-rose transition duration-300 ease-in-out my-1 mr-1", "rounded-full hover:text-white hover:bg-mti-rose transition duration-300 ease-in-out my-1 mr-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light", userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light",
)}> )}>
{userSolution.solution} {userSolutionText}
</button> </button>
<button <button
@@ -63,7 +157,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1", "rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light", userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)}> )}>
{solution.solution} {solutionText}
</button> </button>
</> </>
); );
@@ -76,16 +170,8 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
return ( return (
<> <>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20"> <div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6"> <span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{userSolutions && {correctUserSolutions &&
text.split("\\n").map((line, index) => ( text.split("\\n").map((line, index) => (
<p key={index}> <p key={index}>
{renderLines(line)} {renderLines(line)}
@@ -113,14 +199,14 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
<Button <Button
color="purple" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})} onClick={() => onBack({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })}
className="max-w-[200px] w-full"> className="max-w-[200px] w-full">
Back Back
</Button> </Button>
<Button <Button
color="purple" color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})} onClick={() => onNext({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>

View File

@@ -1,17 +1,17 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {InteractiveSpeakingExercise} from "@/interfaces/exam"; import { InteractiveSpeakingExercise } from "@/interfaces/exam";
import {CommonProps} from "."; import { CommonProps } from ".";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import Button from "../Low/Button"; 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 { Tab } from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import Modal from "../Modal"; import Modal from "../Modal";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer"; import ReactDiffViewer, { DiffMethod } from "react-diff-viewer";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
export default function InteractiveSpeaking({ export default function InteractiveSpeaking({
id, id,
@@ -24,15 +24,22 @@ export default function InteractiveSpeaking({
onBack, onBack,
}: InteractiveSpeakingExercise & CommonProps) { }: InteractiveSpeakingExercise & CommonProps) {
const [solutionsURL, setSolutionsURL] = useState<string[]>([]); const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0); const [diffNumber, setDiffNumber] = useState(0);
const tooltips: { [key: string]: string } = {
"Grammatical Range and Accuracy": "Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
"Fluency and Coherence": "Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
"Pronunciation": "Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
"Lexical Resource": "Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
};
useEffect(() => { useEffect(() => {
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) { if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then( Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, { path: x.answer }, { responseType: "arraybuffer" }))).then(
(values) => { (values) => {
setSolutionsURL( setSolutionsURL(
values.map(({data}) => { values.map(({ data }) => {
const blob = new Blob([data], {type: "audio/wav"}); const blob = new Blob([data], { type: "audio/wav" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
return url; return url;
@@ -64,13 +71,13 @@ export default function InteractiveSpeaking({
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif', fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px", padding: "32px 28px",
}, },
marker: {display: "none"}, marker: { display: "none" },
diffRemoved: {padding: "32px 28px"}, diffRemoved: { padding: "32px 28px" },
diffAdded: {padding: "32px 28px"}, diffAdded: { padding: "32px 28px" },
wordRemoved: {padding: "0px", display: "initial"}, wordRemoved: { padding: "0px", display: "initial" },
wordAdded: {padding: "0px", display: "initial"}, wordAdded: { padding: "0px", display: "initial" },
wordDiff: {padding: "0px", display: "initial"}, wordDiff: { padding: "0px", display: "initial" },
}} }}
oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")} oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")} newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")}
@@ -115,13 +122,13 @@ export default function InteractiveSpeaking({
{userSolutions && {userSolutions &&
userSolutions.length > 0 && userSolutions.length > 0 &&
userSolutions[0].evaluation && userSolutions[0].evaluation &&
userSolutions[0].evaluation[`transcript_${(index + 1) as 1 | 2 | 3}`] && userSolutions[0].evaluation[`transcript_${(index + 1)}`] &&
userSolutions[0].evaluation[`fixed_text_${(index + 1) as 1 | 2 | 3}`] && ( userSolutions[0].evaluation[`fixed_text_${(index + 1)}`] && (
<Button <Button
className="w-full max-w-[180px] !py-2 self-center" className="w-full max-w-[180px] !py-2 self-center"
color="pink" color="pink"
variant="outline" variant="outline"
onClick={() => setDiffNumber((index + 1) as 1 | 2 | 3)}> onClick={() => setDiffNumber((index + 1))}>
View Correction View Correction
</Button> </Button>
)} )}
@@ -132,18 +139,36 @@ export default function InteractiveSpeaking({
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && ( {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, index) => {
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}> const taskResponse = userSolutions[0].evaluation!.task_response[key];
{key}: Level {userSolutions[0].evaluation!.task_response[key]} const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
</div>
))} return (
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
index === 0 && "tooltip-right"
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade}
</div>
);
})}
</div> </div>
{userSolutions[0].evaluation && {userSolutions[0].evaluation &&
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? ( Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length > 0 ? (
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
<Tab <Tab
className={({selected}) => 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",
)
}>
General Feedback
</Tab>
<Tab
className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80", "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", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
@@ -153,59 +178,49 @@ export default function InteractiveSpeaking({
}> }>
Evaluation Evaluation
</Tab> </Tab>
<Tab {Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => (
className={({selected}) => <Tab
clsx( key={key}
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80", className={({ selected }) =>
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2", clsx(
"transition duration-300 ease-in-out", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking", "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 Recommended Answer<br />(Prompt {index + 1})
className={({selected}) => </Tab>
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.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div key={key} className="flex flex-col gap-2">
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit")} key={key}>
{key}: Level {grade}
</div>
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
</div>
);
})}
</div>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <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> <span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> {Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => (
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap"> <Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
{userSolutions[0].evaluation!.perfect_answer_1!.replaceAll(/\s{2,}/g, "\n\n")} <span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
</span> {userSolutions[0].evaluation![`perfect_answer_${(index + 1)}`].answer.replaceAll(/\s{2,}/g, "\n\n")}
</Tab.Panel> </span>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> </Tab.Panel>
<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.Panels>
</Tab.Group> </Tab.Group>
) : ( ) : (
@@ -226,7 +241,7 @@ export default function InteractiveSpeaking({
onBack({ onBack({
exercise: id, exercise: id,
solutions: userSolutions, solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0}, score: { total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
type, type,
}) })
} }

View File

@@ -1,8 +1,10 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"; import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {CommonProps} from "."; import reactStringReplace from "react-string-replace";
import { CommonProps } from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
function Question({ function Question({
@@ -12,13 +14,51 @@ function Question({
solution, solution,
options, options,
userSolution, userSolution,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) { }: MultipleChoiceQuestion & { userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean }) {
const { userSolutions } = useExamStore((state) => state);
const getShuffledOptions = (options: { id: string, text: string }[], questionShuffleMap: ShuffleMap) => {
const shuffledOptions = ['A', 'B', 'C', 'D'].map(newId => {
const originalId = questionShuffleMap.map[newId];
const originalOption = options.find(option => option.id === originalId);
return {
id: newId,
text: originalOption!.text
};
});
return shuffledOptions;
}
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
if (originalPosition === originalSolution) {
return newPosition;
}
}
return originalSolution;
}
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
if (foundMap) return foundMap;
return userSolution.shuffleMaps?.find(map => map.id === id) || null;
}, null as ShuffleMap | null);
const questionOptions = questionShuffleMap ? getShuffledOptions(options as { id: string, text: string }[], questionShuffleMap) : options;
const newSolution = questionShuffleMap ? getShuffledSolution(solution, questionShuffleMap) : solution;
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u>{word}</u> : null;
});
};
const optionColor = (option: string) => { const optionColor = (option: string) => {
if (option === solution && !userSolution) { if (option === newSolution && !userSolution) {
return "!border-mti-gray-davy !text-mti-gray-davy"; return "!border-mti-gray-davy !text-mti-gray-davy";
} }
if (option === solution) { if (option === newSolution) {
return "!border-mti-purple-light !text-mti-purple-light"; return "!border-mti-purple-light !text-mti-purple-light";
} }
@@ -26,30 +66,36 @@ function Question({
}; };
return ( return (
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col gap-4">
<span> {isNaN(Number(id)) ? (
{id} - {prompt} <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
</span> ) : (
<div className="grid grid-cols-4 gap-4 place-items-center"> <span className="text-lg">
<>
{id} - <span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
</>
</span>
)}
<div className="flex flex-wrap gap-4 justify-between">
{variant === "image" && {variant === "image" &&
options.map((option) => ( questionOptions.map((option) => (
<div <div
key={option.id} key={option?.id}
className={clsx( className={clsx(
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative", "flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
optionColor(option.id), optionColor(option!.id),
)}> )}>
<span className={clsx("text-sm", solution !== option.id && userSolution !== option.id && "opacity-50")}>{option.id}</span> <span className={clsx("text-sm", newSolution !== option?.id && userSolution !== option?.id && "opacity-50")}>{option?.id}</span>
<img src={option.src!} alt={`Option ${option.id}`} /> {"src" in option && <img src={option?.src!} alt={`Option ${option?.id}`} />}
</div> </div>
))} ))}
{variant === "text" && {variant === "text" &&
options.map((option) => ( questionOptions.map((option) => (
<div <div
key={option.id} key={option?.id}
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", optionColor(option.id))}> className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none", optionColor(option!.id))}>
<span className="font-semibold">{option.id}.</span> <span className="font-semibold">{option?.id}.</span>
<span>{option.text}</span> <span>{option?.text}</span>
</div> </div>
))} ))}
</div> </div>
@@ -57,17 +103,9 @@ function Question({
); );
} }
export default function MultipleChoice({ export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) {
id, const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state);
type,
prompt,
questions,
userSolutions,
updateIndex,
onNext,
onBack,
}: MultipleChoiceExercise & CommonProps) {
const [questionIndex, setQuestionIndex] = useState(0);
const calculateScore = () => { const calculateScore = () => {
const total = questions.length; const total = questions.length;
@@ -76,26 +114,22 @@ export default function MultipleChoice({
).length; ).length;
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length; const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
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 });
} else { } else {
setQuestionIndex((prev) => prev + 1); setQuestionIndex(questionIndex + 1);
} }
}; };
const back = () => { const back = () => {
if (questionIndex === 0) { if (questionIndex === 0) {
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type}); onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type });
} else { } else {
setQuestionIndex((prev) => prev - 1); setQuestionIndex(questionIndex - 1);
} }
}; };
@@ -103,7 +137,7 @@ export default function MultipleChoice({
<> <>
<div className="flex flex-col gap-4 w-full h-full mb-20"> <div className="flex flex-col gap-4 w-full h-full mb-20">
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8"> <div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<span className="text-xl font-semibold">{prompt}</span> {/*<span className="text-xl font-semibold">{prompt}</span>*/}
{userSolutions && questionIndex < questions.length && ( {userSolutions && questionIndex < questions.length && (
<Question <Question
{...questions[questionIndex]} {...questions[questionIndex]}
@@ -125,10 +159,14 @@ export default function MultipleChoice({
Wrong Wrong
</div> </div>
</div> </div>
</div> </div>
<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">
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full"> <Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full"
disabled={
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
typeof exam.parts[0].intro === "string" && questionIndex === 0 && partIndex === 0}
>
Back Back
</Button> </Button>

View File

@@ -1,20 +1,20 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {SpeakingExercise} from "@/interfaces/exam"; import { SpeakingExercise } from "@/interfaces/exam";
import {CommonProps} from "."; import { CommonProps } from ".";
import {Fragment, useEffect, useState} from "react"; import { Fragment, useEffect, useState } from "react";
import Button from "../Low/Button"; 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 { Tab } from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import Modal from "../Modal"; import Modal from "../Modal";
import {BsQuestionCircleFill} from "react-icons/bs"; import { BsQuestionCircleFill } from "react-icons/bs";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer"; import ReactDiffViewer, { DiffMethod } from "react-diff-viewer";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { export default function Speaking({ id, type, title, video_url, text, prompts, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) {
const [solutionURL, setSolutionURL] = useState<string>(); const [solutionURL, setSolutionURL] = useState<string>();
const [showDiff, setShowDiff] = useState(false); const [showDiff, setShowDiff] = useState(false);
@@ -23,8 +23,8 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
const solution = userSolutions[0].solution; const solution = userSolutions[0].solution;
if (solution.startsWith("https://")) return setSolutionURL(solution); if (solution.startsWith("https://")) return setSolutionURL(solution);
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => { axios.post(`/api/speaking`, { path: userSolutions[0].solution }, { responseType: "arraybuffer" }).then(({ data }) => {
const blob = new Blob([data], {type: "audio/wav"}); const blob = new Blob([data], { type: "audio/wav" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
setSolutionURL(url); setSolutionURL(url);
@@ -32,6 +32,13 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
} }
}, [userSolutions]); }, [userSolutions]);
const tooltips: { [key: string]: string } = {
"Grammatical Range and Accuracy": "Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
"Fluency and Coherence": "Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
"Pronunciation": "Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
"Lexical Resource": "Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
};
return ( return (
<> <>
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}> <Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
@@ -51,13 +58,13 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif', fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px", padding: "32px 28px",
}, },
marker: {display: "none"}, marker: { display: "none" },
diffRemoved: {padding: "32px 28px"}, diffRemoved: { padding: "32px 28px" },
diffAdded: {padding: "32px 28px"}, diffAdded: { padding: "32px 28px" },
wordRemoved: {padding: "0px", display: "initial"}, wordRemoved: { padding: "0px", display: "initial" },
wordAdded: {padding: "0px", display: "initial"}, wordAdded: { padding: "0px", display: "initial" },
wordDiff: {padding: "0px", display: "initial"}, wordDiff: { padding: "0px", display: "initial" },
}} }}
oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")} oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")} newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")}
@@ -126,18 +133,36 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && ( {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, index) => {
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}> const taskResponse = userSolutions[0].evaluation!.task_response[key];
{key}: Level {userSolutions[0].evaluation!.task_response[key]} const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
</div>
))} return (
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
index === 0 && "tooltip-right"
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade}
</div>
);
})}
</div> </div>
{userSolutions[0].evaluation && {userSolutions[0].evaluation &&
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? ( (userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
<Tab <Tab
className={({selected}) => 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",
)
}>
General Feedback
</Tab>
<Tab
className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80", "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", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
@@ -148,7 +173,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
Evaluation Evaluation
</Tab> </Tab>
<Tab <Tab
className={({selected}) => className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80", "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", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
@@ -160,9 +185,29 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
</Tab> </Tab>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
{/* General Feedback */}
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div key={key} className="flex flex-col gap-2">
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit")} key={key}>
{key}: Level {grade}
</div>
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
</div>
);
})}
</div>
</Tab.Panel>
{/* Evaluation */}
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <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> <span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel> </Tab.Panel>
{/* Recommended Answer */}
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <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"> <span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer && {userSolutions[0].evaluation!.perfect_answer &&
@@ -191,7 +236,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
onBack({ onBack({
exercise: id, exercise: id,
solutions: userSolutions, solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0}, score: { total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
type, type,
}) })
} }

View File

@@ -49,15 +49,11 @@ function Blank({
{userSolution && !isUserSolutionCorrect() && ( {userSolution && !isUserSolutionCorrect() && (
<div <div
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light" className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
placeholder={id}
contentEditable={disabled}> contentEditable={disabled}>
{userSolution} {userSolution}
</div> </div>
)} )}
<div <div className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())} contentEditable={disabled}>
className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())}
placeholder={id}
contentEditable={disabled}>
{!solutions ? userInput : solutions.join(" / ")} {!solutions ? userInput : solutions.join(" / ")}
</div> </div>
</span> </span>

View File

@@ -1,17 +1,30 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {WritingExercise} from "@/interfaces/exam"; import { WritingExercise } from "@/interfaces/exam";
import {CommonProps} from "."; import { CommonProps } from ".";
import {Fragment, useEffect, useState} from "react"; import { Fragment, useEffect, useState } from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
import {Dialog, Tab, Transition} from "@headlessui/react"; import { Dialog, Tab, Transition } from "@headlessui/react";
import {writingReverseMarking} from "@/utils/score"; import { writingReverseMarking } from "@/utils/score";
import clsx from "clsx"; import clsx from "clsx";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer"; import ReactDiffViewer, { DiffMethod } from "react-diff-viewer";
import useUser from "@/hooks/useUser";
import AIDetection from "../AIDetection";
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); const [showDiff, setShowDiff] = useState(false);
const { user } = useUser();
const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined;
const tooltips: { [key: string]: string } = {
"Lexical Resource": "Assesses the diversity and accuracy of vocabulary used. A higher score indicates varied and precise word choice; a lower score points to limited vocabulary and inaccuracies.",
"Task Achievement": "Evaluates how well the task requirements are fulfilled. A higher score means all parts of the task are addressed thoroughly; a lower score shows incomplete or inadequate task response.",
"Coherence and Cohesion": "Measures logical organization and flow of writing. A higher score reflects well-structured and connected ideas; a lower score indicates disorganized writing and poor linkage between ideas.",
"Grammatical Range and Accuracy": "Looks at the range and precision of grammatical structures. A higher score shows varied and accurate grammar use; a lower score suggests frequent errors and limited range.",
};
return ( return (
<> <>
{attachment && ( {attachment && (
@@ -86,13 +99,13 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif', fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px", padding: "32px 28px",
}, },
marker: {display: "none"}, marker: { display: "none" },
diffRemoved: {padding: "32px 28px"}, diffRemoved: { padding: "32px 28px" },
diffAdded: {padding: "32px 28px"}, diffAdded: { padding: "32px 28px" },
wordRemoved: {padding: "0px", display: "initial"}, wordRemoved: { padding: "0px", display: "initial" },
wordAdded: {padding: "0px", display: "initial"}, wordAdded: { padding: "0px", display: "initial" },
wordDiff: {padding: "0px", display: "initial"}, wordDiff: { padding: "0px", display: "initial" },
}} }}
oldValue={userSolutions[0].solution.replaceAll("\\n", "\n")} oldValue={userSolutions[0].solution.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation!.fixed_text!.replaceAll("\\n", "\n")} newValue={userSolutions[0].evaluation!.fixed_text!.replaceAll("\\n", "\n")}
@@ -117,17 +130,36 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && ( {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, index) => {
<div className="bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2" key={key}> const taskResponse = userSolutions[0].evaluation!.task_response[key];
{key}: Level {userSolutions[0].evaluation!.task_response[key]} const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
</div>
))} return (
<div className={clsx(
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
index === 0 && "tooltip-right"
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade}
</div>
);
})}
</div> </div>
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? ( {userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
<Tab <Tab
className={({selected}) => 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",
)
}>
General Feedback
</Tab>
<Tab
className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80", "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", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
@@ -138,7 +170,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
Evaluation Evaluation
</Tab> </Tab>
<Tab <Tab
className={({selected}) => className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80", "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", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
@@ -148,16 +180,54 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
}> }>
Recommended Answer Recommended Answer
</Tab> </Tab>
{aiEval && user?.type !== "student" && (
<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",
)
}>
AI Use
</Tab>
)}
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
{/* Global */}
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div key={key} className="flex flex-col gap-2">
<div className={clsx("bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 w-fit")} key={key}>
{key}: Level {grade}
</div>
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
</div>
);
})}
</div>
</Tab.Panel>
{/* Evaluation */}
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span> <span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel> </Tab.Panel>
{/* Recommended Answer */}
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap"> <span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")} {userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
</span> </span>
</Tab.Panel> </Tab.Panel>
{aiEval && user?.type !== "student" && (
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<AIDetection {...aiEval} />
</Tab.Panel>
)}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
) : ( ) : (
@@ -178,7 +248,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
onBack({ onBack({
exercise: id, exercise: id,
solutions: userSolutions, solutions: userSolutions,
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0}, score: { total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
type, type,
}) })
} }

View File

@@ -22,7 +22,6 @@ 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;
} }
@@ -36,15 +35,7 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: (
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 ( return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
<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":

View File

@@ -0,0 +1,289 @@
import React from 'react';
import { BsClock, BsXCircle } from 'react-icons/bs';
import clsx from 'clsx';
import { Stat, User } from '@/interfaces/user';
import { Module } from "@/interfaces";
import ai_usage from "@/utils/ai.detection";
import { calculateBandScore } from "@/utils/score";
import moment from 'moment';
import { Assignment } from '@/interfaces/results';
import { uuidv4 } from "@firebase/util";
import { useRouter } from "next/router";
import { uniqBy } from "lodash";
import { sortByModule } from "@/utils/moduleUtils";
import { convertToUserSolutions } from "@/utils/stats";
import { getExamById } from "@/utils/exams";
import { Exam, UserSolution } from '@/interfaces/exam';
import ModuleBadge from './ModuleBadge';
const formatTimestamp = (timestamp: string | number) => {
const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp;
const date = moment(time);
const formatter = "YYYY/MM/DD - HH:mm";
return date.format(formatter);
};
const aggregateScoresByModule = (stats: Stat[]): { module: Module; total: number; missing: number; correct: number }[] => {
const scores: {
[key in Module]: { total: number; missing: number; correct: number };
} = {
reading: {
total: 0,
correct: 0,
missing: 0,
},
listening: {
total: 0,
correct: 0,
missing: 0,
},
writing: {
total: 0,
correct: 0,
missing: 0,
},
speaking: {
total: 0,
correct: 0,
missing: 0,
},
level: {
total: 0,
correct: 0,
missing: 0,
},
};
stats.forEach((x) => {
scores[x.module!] = {
total: scores[x.module!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct,
missing: scores[x.module!].missing + x.score.missing,
};
});
return Object.keys(scores)
.filter((x) => scores[x as Module].total > 0)
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
};
interface StatsGridItemProps {
width?: string | undefined;
height?: string | undefined;
examNumber?: number | undefined;
stats: Stat[];
timestamp: string | number;
user: User,
assignments: Assignment[];
users: User[];
training?: boolean,
selectedTrainingExams?: string[];
maxTrainingExams?: number;
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
setExams: (exams: Exam[]) => void;
setShowSolutions: (show: boolean) => void;
setUserSolutions: (solutions: UserSolution[]) => void;
setSelectedModules: (modules: Module[]) => void;
setInactivity: (inactivity: number) => void;
setTimeSpent: (time: number) => void;
renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode;
}
const StatsGridItem: React.FC<StatsGridItemProps> = ({
stats,
timestamp,
user,
assignments,
users,
training,
selectedTrainingExams,
setSelectedTrainingExams,
setExams,
setShowSolutions,
setUserSolutions,
setSelectedModules,
setInactivity,
setTimeSpent,
renderPdfIcon,
width = undefined,
height = undefined,
examNumber = undefined,
maxTrainingExams = undefined
}) => {
const router = useRouter();
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
const assignmentID = stats.reduce((_, current) => current.assignment as any, "");
const assignment = assignments.find((a) => a.id === assignmentID);
const isDisabled = stats.some((x) => x.isDisabled);
const aiUsage = Math.round(ai_usage(stats) * 100);
const aggregatedLevels = aggregatedScores.map((x) => ({
module: x.module,
level: calculateBandScore(x.correct, x.total, x.module, user.focus),
}));
const textColor = clsx(
correct / total >= 0.7 && "text-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
correct / total < 0.3 && "text-mti-rose",
);
const { timeSpent, inactivity, session } = stats[0];
const selectExam = () => {
if (training && !isDisabled && typeof maxTrainingExams !== "undefined" && typeof setSelectedTrainingExams !== "undefined" && typeof timestamp == "string") {
setSelectedTrainingExams(prevExams => {
const uniqueExams = [...new Set(stats.map(stat => `${stat.module}-${stat.date}`))];
const indexes = uniqueExams.map(exam => prevExams.indexOf(exam)).filter(index => index !== -1);
if (indexes.length > 0) {
const newExams = [...prevExams];
indexes.sort((a, b) => b - a).forEach(index => {
newExams.splice(index, 1);
});
return newExams;
} else {
if (prevExams.length + uniqueExams.length <= maxTrainingExams) {
return [...prevExams, ...uniqueExams];
} else {
return prevExams;
}
}
});
} else {
const examPromises = uniqBy(stats, "exam").map((stat) => {
return getExamById(stat.module, stat.exam);
});
if (isDisabled) return;
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
if (!!timeSpent) setTimeSpent(timeSpent);
if (!!inactivity) setInactivity(inactivity);
setUserSolutions(convertToUserSolutions(stats));
setShowSolutions(true);
setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
router.push("/exercises");
}
});
}
};
const content = (
<>
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
<span className="font-medium">{formatTimestamp(timestamp)}</span>
<div className="flex items-center gap-2">
{!!timeSpent && (
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Time Spent">
<BsClock /> {Math.floor(timeSpent / 60)} minutes
</span>
)}
{!!inactivity && (
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Inactivity">
<BsXCircle /> {Math.floor(inactivity / 60)} minutes
</span>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-2">
<span className={textColor}>
Level{" "}
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
</span>
{renderPdfIcon(session, textColor, textColor)}
</div>
{examNumber === undefined ? (
<>
{aiUsage >= 50 && user.type !== "student" && (
<div className={clsx(
"ml-auto border px-1 rounded w-fit mr-1",
{
'bg-orange-100 border-orange-400 text-orange-700': aiUsage < 80,
'bg-red-100 border-red-400 text-red-700': aiUsage >= 80,
}
)}>
<span className="text-xs">AI Usage</span>
</div>
)}
</>
) : (
<div className='flex justify-end'>
<span className="font-semibold bg-gray-200 text-gray-800 px-2.5 py-0.5 rounded-full mt-0.5">{examNumber}</span>
</div>
)}
</div>
</div>
<div className="w-full flex flex-col gap-1">
<div className={clsx(
"grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2",
examNumber !== undefined && "pr-10"
)}>
{aggregatedLevels.map(({ module, level }) => (
<ModuleBadge key={module} module={module} level={level} />
))}
</div>
{assignment && (
<span className="font-light text-sm">
Assignment: {assignment.name}, Teacher: {users.find((u) => u.id === assignment.assigner)?.name}
</span>
)}
</div>
</>
);
return (
<>
<div
key={uuidv4()}
className={clsx(
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
isDisabled && "grayscale tooltip",
correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose",
typeof selectedTrainingExams !== "undefined" && typeof timestamp === "string" && selectedTrainingExams.some(exam => exam.includes(timestamp)) && "border-2 border-slate-600",
)}
onClick={examNumber === undefined ? selectExam : undefined}
style={{
...(width !== undefined && { width }),
...(height !== undefined && { height }),
}}
data-tip="This exam is still being evaluated..."
role="button">
{content}
</div>
<div
key={uuidv4()}
className={clsx(
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:tooltip md:hidden",
correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose",
)}
data-tip="Your screen size is too small to view previous exams."
style={{
...(width !== undefined && { width }),
...(height !== undefined && { height }),
}}
role="button">
{content}
</div>
</>
);
};
export default StatsGridItem;

View File

@@ -0,0 +1,91 @@
import React, { useState, useCallback } from "react";
import ExerciseWalkthrough from "@/training/ExerciseWalkthrough";
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
// This wrapper is just to test new exercises from the handbook, will be removed when all the tips and exercises are in firestore
const TrainingExercise: React.FC<ITrainingTip> = (trainingTip: ITrainingTip) => {
const leftText = "<div class=\"container mx-auto px-4 overflow-x-auto\"><table class=\"min-w-full bg-white border border-gray-300\"><thead><tr class=\"bg-gray-100\"><th class=\"py-2 px-4 border-b font-semibold text-left\">Category</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option A</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option B</th></tr></thead><tbody><tr><td class=\"py-2 px-4 border-b font-medium\">Self</td><td class=\"py-2 px-4 border-b\">You need to take care of yourself and connect with the people around you.</td><td class=\"py-2 px-4 border-b\">Focus on your interests and talents and meet people who are like you.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Home</td><td class=\"py-2 px-4 border-b\">It's a good idea to paint your living room yellow.</td><td class=\"py-2 px-4 border-b\">You should arrange your home so that it makes you feel happy.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Financial Life</td><td class=\"py-2 px-4 border-b\">You can be happy if you have enough money, but don't want money too much.</td><td class=\"py-2 px-4 border-b\">If you waste money on things you don't need, you won't have enough money for things that you do need.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Social Life</td><td class=\"py-2 px-4 border-b\">A good group of friends can increase your happiness.</td><td class=\"py-2 px-4 border-b\">Researchers say that a happy friend can increase our mood by nine percent.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Workplace</td><td class=\"py-2 px-4 border-b\">You spend a lot of time at work, so you should like your workplace.</td><td class=\"py-2 px-4 border-b\">Your boss needs to be someone you enjoy working for.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Community</td><td class=\"py-2 px-4 border-b\">The place where you live is more important for happiness than anything else.</td><td class=\"py-2 px-4 border-b\">Live around people who have the same amount of money as you do.</td></tr></tbody></table></div>";
const tip = {
category: "Strategy",
body: "<p>Look for <b>clues to the main idea</b> in the first (and sometimes second) sentence of a paragraph.</p>"
}
const question = "<div class=\"container mx-auto px-4 py-8\"><h2 class=\"text-2xl font-bold mb-4\">Identifying Main Ideas</h2><p class=\"text-lg leading-relaxed mb-6\">Read the statements below. Circle the main idea in each pair of statements (a or b).</p></div>";
const rightTextData: WalkthroughConfigs[] = [
{
"html": "<div class='bg-blue-100 p-4 rounded-lg mb-4'><h2 class='text-xl font-bold mb-2'>Identifying Main Ideas</h2><p class='text-gray-700 leading-relaxed'>Let's analyze each pair of statements to determine which one represents the main idea. We'll focus on which statement is more general and encompasses the overall concept.</p></div>",
"wordDelay": 200,
"holdDelay": 5000,
"highlight": []
},
{
"html": "<div class='bg-green-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>1. Self</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You need to take care of yourself and connect with the people around you.</b></p><p class='mt-2'>This statement is more comprehensive, covering both self-care and social connections. Option B is more specific and could be considered a subset of A.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["You need to take care of yourself and connect with the people around you."]
},
{
"html": "<div class='bg-yellow-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>2. Home</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>B. You should arrange your home so that it makes you feel happy.</b></p><p class='mt-2'>This statement is more general and applies to the entire home. Option A is a specific example that could fall under this broader concept.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["You should arrange your home so that it makes you feel happy."]
},
{
"html": "<div class='bg-pink-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>3. Financial Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You can be happy if you have enough money, but don't want money too much.</b></p><p class='mt-2'>This statement provides a balanced view of money's role in happiness. Option B is more specific and could be seen as a consequence of wanting money too much.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["You can be happy if you have enough money, but don't want money too much."]
},
{
"html": "<div class='bg-purple-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>4. Social Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. A good group of friends can increase your happiness.</b></p><p class='mt-2'>This statement is more general about the impact of friendships. Option B provides a specific statistic that supports this main idea.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["A good group of friends can increase your happiness."]
},
{
"html": "<div class='bg-indigo-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>5. Workplace</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You spend a lot of time at work, so you should like your workplace.</b></p><p class='mt-2'>This statement covers the overall importance of workplace satisfaction. Option B focuses on one specific aspect (the boss) and is less comprehensive.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["You spend a lot of time at work, so you should like your workplace."]
},
{
"html": "<div class='bg-red-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>6. Community</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. The place where you live is more important for happiness than anything else.</b></p><p class='mt-2'>While this statement might be debatable, it's more general and encompasses the overall importance of community. Option B is a specific suggestion about community demographics.</p></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": ["The place where you live is more important for happiness than anything else."]
},
{
"html": "<div class='bg-orange-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>Key Strategy</h3><p class='text-gray-700 leading-relaxed'>When identifying main ideas:</p><ul class='list-disc pl-5 space-y-2'><li>Look for broader, more encompassing statements</li><li>Consider which statement other ideas could fall under</li><li>Identify which statement provides a general principle rather than a specific example</li></ul></div>",
"wordDelay": 200,
"holdDelay": 8000,
"highlight": []
},
{
"html": "<div class='bg-teal-50 p-4 rounded-lg'><h3 class='text-lg font-semibold mb-2'>Helpful Tip</h3><p class='text-gray-700 leading-relaxed'>Remember to look for clues to the main idea in the first (and sometimes second) sentence of a paragraph. In this exercise, we applied this concept to pairs of statements. This approach can help you quickly identify the central theme or main point in various types of text.</p></div>",
"wordDelay": 200,
"holdDelay": 5000,
"highlight": []
}
]
const mockTip: ITrainingTip = {
id: "some random id",
tipCategory: tip.category,
tipHtml: tip.body,
standalone: false,
exercise: {
question: question,
highlightable: leftText,
segments: rightTextData
}
}
return (
<div className="flex flex-col p-10">
<ExerciseWalkthrough {...trainingTip}
/>
</div>
);
}
export default TrainingExercise;

View File

@@ -0,0 +1,280 @@
import React, {useState, useEffect, useRef, useCallback} from "react";
import {animated} from "@react-spring/web";
import {FaRegCirclePlay, FaRegCircleStop} from "react-icons/fa6";
import HighlightContent from "../HighlightContent";
import {ITrainingTip, SegmentRef, TimelineEvent} from "./TrainingInterfaces";
const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(0);
const [walkthroughHtml, setWalkthroughHtml] = useState<string>("");
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const timelineRef = useRef<TimelineEvent[]>([]);
const animationRef = useRef<number | null>(null);
const segmentsRef = useRef<SegmentRef[]>([]);
const toggleAutoPlay = useCallback(() => {
setIsAutoPlaying((prev) => {
if (!prev && currentTime === getMaxTime()) {
setCurrentTime(0);
}
return !prev;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTime]);
const handleAnimationComplete = useCallback(() => {
setIsAutoPlaying(false);
}, []);
const handleResetAnimation = useCallback((newTime: number) => {
setCurrentTime(newTime);
}, []);
const getMaxTime = (): number => {
return (
tip.exercise?.segments.reduce((sum, segment) => sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0) ?? 0
);
};
useEffect(() => {
const timeline: TimelineEvent[] = [];
let currentTimePosition = 0;
segmentsRef.current = [];
tip.exercise?.segments.forEach((segment, index) => {
const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, "text/html");
const words: string[] = [];
const walkTree = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
words.push(...(node.textContent?.split(/\s+/).filter((word) => word.length > 0) || []));
} else if (node.nodeType === Node.ELEMENT_NODE) {
Array.from(node.childNodes).forEach(walkTree);
}
};
walkTree(doc.body);
const textDuration = words.length * segment.wordDelay;
segmentsRef.current.push({
...segment,
words: words,
startTime: currentTimePosition,
endTime: currentTimePosition + textDuration,
});
timeline.push({
type: "text",
start: currentTimePosition,
end: currentTimePosition + textDuration,
segmentIndex: index,
});
currentTimePosition += textDuration;
timeline.push({
type: "highlight",
start: currentTimePosition,
end: currentTimePosition + segment.holdDelay,
content: segment.highlight,
segmentIndex: index,
});
currentTimePosition += segment.holdDelay;
});
timelineRef.current = timeline;
}, [tip.exercise?.segments]);
const updateText = useCallback(() => {
const currentEvent = timelineRef.current.find((event) => currentTime >= event.start && currentTime < event.end);
if (currentEvent) {
if (currentEvent.type === "text") {
const segment = segmentsRef.current[currentEvent.segmentIndex];
const elapsedTime = currentTime - currentEvent.start;
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
const previousSegmentsHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex)
.map((seg) => seg.html)
.join("");
const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, "text/html");
let wordCount = 0;
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
const words = node.textContent.split(/(\s+)/).filter((word) => word.length > 0);
if (wordCount + words.filter((w) => !/\s+/.test(w)).length <= wordsToShow) {
action(node.cloneNode(true));
wordCount += words.filter((w) => !/\s+/.test(w)).length;
} else {
const remainingWords = wordsToShow - wordCount;
const newTextContent = words.reduce(
(acc, word) => {
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
acc.text += word;
acc.nonSpaceWords++;
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
acc.text += word;
}
return acc;
},
{text: "", nonSpaceWords: 0},
).text;
const newNode = node.cloneNode(false);
newNode.textContent = newTextContent;
action(newNode);
wordCount = wordsToShow;
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const clone = node.cloneNode(false);
action(clone);
Array.from(node.childNodes).some((child) => {
return walkTree(child, (childNode) => (clone as Node).appendChild(childNode));
});
}
return wordCount >= wordsToShow;
};
const fragment = document.createDocumentFragment();
walkTree(doc.body, (node) => fragment.appendChild(node));
const serializer = new XMLSerializer();
const currentSegmentHtml = Array.from(fragment.childNodes)
.map((node) => serializer.serializeToString(node))
.join("");
const newHtml = previousSegmentsHtml + currentSegmentHtml;
setWalkthroughHtml(newHtml);
setHighlightedPhrases([]);
} else if (currentEvent.type === "highlight") {
const newHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex + 1)
.map((seg) => seg.html)
.join("");
setWalkthroughHtml(newHtml);
setHighlightedPhrases(currentEvent.content || []);
}
}
}, [currentTime]);
useEffect(() => {
updateText();
}, [currentTime, updateText]);
useEffect(() => {
if (isAutoPlaying) {
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
if (lastEvent && currentTime >= lastEvent.end) {
setCurrentTime(0);
}
setIsPlaying(true);
} else {
setIsPlaying(false);
}
}, [isAutoPlaying, currentTime]);
useEffect(() => {
const animate = () => {
if (isPlaying) {
setCurrentTime((prevTime) => {
const newTime = prevTime + 50;
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
if (lastEvent && newTime >= lastEvent.end) {
setIsPlaying(false);
handleAnimationComplete();
return lastEvent.end;
}
return newTime;
});
}
animationRef.current = requestAnimationFrame(animate);
};
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [isPlaying, handleAnimationComplete]);
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = parseInt(e.target.value, 10);
setCurrentTime(newTime);
handleResetAnimation(newTime);
};
const handleSliderMouseDown = () => {
setIsPlaying(false);
};
const handleSliderMouseUp = () => {
if (isAutoPlaying) {
setIsPlaying(true);
}
};
if (tip.standalone || !tip.exercise) {
return (
<div className="container mx-auto">
<h1 className="text-xl font-bold text-red-600">The exercise for this tip is not available yet!</h1>
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
</div>
</div>
);
}
return (
<div className="container mx-auto">
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
</div>
<div className="flex flex-col space-y-4">
<div className="flex flex-row items-center space-x-4 py-4">
<button
onClick={toggleAutoPlay}
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
aria-label={isAutoPlaying ? "Pause" : "Play"}>
{isAutoPlaying ? <FaRegCircleStop className="w-6 h-6" /> : <FaRegCirclePlay className="w-6 h-6" />}
</button>
<input
type="range"
min="0"
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
value={currentTime}
onChange={handleSliderChange}
onMouseDown={handleSliderMouseDown}
onMouseUp={handleSliderMouseUp}
onTouchStart={handleSliderMouseDown}
onTouchEnd={handleSliderMouseUp}
className="flex-grow"
/>
</div>
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
<div className="flex-1 bg-white p-6 rounded-lg shadow">
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
<div className="mb-4" dangerouslySetInnerHTML={{__html: tip.exercise.question}} />
<HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
</div>
<div className="flex-1">
<div className="bg-gray-50 rounded-lg shadow">
<div className="p-6 space-y-4">
<animated.div dangerouslySetInnerHTML={{__html: walkthroughHtml}} />
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default ExerciseWalkthrough;

View File

@@ -0,0 +1,56 @@
import { Stat } from "@/interfaces/user";
export interface ITrainingContent {
id: string;
created_at: number;
exams: {
id: string;
date: number;
detailed_summary: string;
performance_comment: string;
score: number;
module: string;
stat_ids: string[];
stats?: Stat[];
}[];
tip_ids: string[];
tips?: ITrainingTip[];
weak_areas: {
area: string;
comment: string;
}[];
}
export interface ITrainingTip {
id: string;
tipCategory: string;
tipHtml: string;
standalone: boolean;
exercise?: {
question: string;
highlightable: string;
segments: WalkthroughConfigs[]
}
}
export interface WalkthroughConfigs {
html: string;
wordDelay: number;
holdDelay: number;
highlight: string[];
}
export interface TimelineEvent {
type: 'text' | 'highlight';
start: number;
end: number;
segmentIndex: number;
content?: string[];
}
export interface SegmentRef extends WalkthroughConfigs {
words: string[];
startTime: number;
endTime: number;
}

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { RiArrowRightUpLine, RiArrowLeftDownLine } from 'react-icons/ri';
import { FaChartLine } from 'react-icons/fa';
import { GiLightBulb } from 'react-icons/gi';
import clsx from 'clsx';
import { ITrainingContent } from './TrainingInterfaces';
interface TrainingScoreProps {
trainingContent: ITrainingContent
gridView: boolean;
}
const TrainingScore: React.FC<TrainingScoreProps> = ({
trainingContent,
gridView
}) => {
const scores = trainingContent.exams.map(exam => exam.score);
const highestScore = Math.max(...scores);
const lowestScore = Math.min(...scores);
let averageScore = scores.length > 0
? scores.reduce((sum, score) => sum + score, 0) / scores.length
: 0;
averageScore = Math.round(averageScore);
const containerClasses = clsx(
"flex flex-row mb-4",
gridView ? "gap-4 justify-between" : "gap-8"
);
const columnClasses = clsx(
"flex flex-col",
gridView ? "gap-4" : "gap-8"
);
return (
<div className={containerClasses}>
<div className={columnClasses}>
<div className="flex flex-row items-center gap-4">
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.7083 3.16669C11.4166 3.16669 11.1701 3.06599 10.9687 2.8646C10.7673 2.66321 10.6666 2.41669 10.6666 2.12502C10.6666 1.83335 10.7673 1.58683 10.9687 1.38544C11.1701 1.18405 11.4166 1.08335 11.7083 1.08335C12 1.08335 12.2465 1.18405 12.4479 1.38544C12.6493 1.58683 12.75 1.83335 12.75 2.12502C12.75 2.41669 12.6493 2.66321 12.4479 2.8646C12.2465 3.06599 12 3.16669 11.7083 3.16669ZM11.7083 16.9167C11.4166 16.9167 11.1701 16.816 10.9687 16.6146C10.7673 16.4132 10.6666 16.1667 10.6666 15.875C10.6666 15.5834 10.7673 15.3368 10.9687 15.1354C11.1701 14.934 11.4166 14.8334 11.7083 14.8334C12 14.8334 12.2465 14.934 12.4479 15.1354C12.6493 15.3368 12.75 15.5834 12.75 15.875C12.75 16.1667 12.6493 16.4132 12.4479 16.6146C12.2465 16.816 12 16.9167 11.7083 16.9167ZM15.0416 6.08335C14.75 6.08335 14.5034 5.98266 14.302 5.78127C14.1007 5.57988 14 5.33335 14 5.04169C14 4.75002 14.1007 4.50349 14.302 4.3021C14.5034 4.10071 14.75 4.00002 15.0416 4.00002C15.3333 4.00002 15.5798 4.10071 15.7812 4.3021C15.9826 4.50349 16.0833 4.75002 16.0833 5.04169C16.0833 5.33335 15.9826 5.57988 15.7812 5.78127C15.5798 5.98266 15.3333 6.08335 15.0416 6.08335ZM15.0416 14C14.75 14 14.5034 13.8993 14.302 13.6979C14.1007 13.4965 14 13.25 14 12.9584C14 12.6667 14.1007 12.4202 14.302 12.2188C14.5034 12.0174 14.75 11.9167 15.0416 11.9167C15.3333 11.9167 15.5798 12.0174 15.7812 12.2188C15.9826 12.4202 16.0833 12.6667 16.0833 12.9584C16.0833 13.25 15.9826 13.4965 15.7812 13.6979C15.5798 13.8993 15.3333 14 15.0416 14ZM16.2916 10.0417C16 10.0417 15.7534 9.94099 15.552 9.7396C15.3507 9.53821 15.25 9.29169 15.25 9.00002C15.25 8.70835 15.3507 8.46183 15.552 8.26044C15.7534 8.05905 16 7.95835 16.2916 7.95835C16.5833 7.95835 16.8298 8.05905 17.0312 8.26044C17.2326 8.46183 17.3333 8.70835 17.3333 9.00002C17.3333 9.29169 17.2326 9.53821 17.0312 9.7396C16.8298 9.94099 16.5833 10.0417 16.2916 10.0417ZM8.99996 17.3334C7.84718 17.3334 6.76385 17.1146 5.74996 16.6771C4.73607 16.2396 3.85413 15.6459 3.10413 14.8959C2.35413 14.1459 1.76038 13.2639 1.32288 12.25C0.885376 11.2361 0.666626 10.1528 0.666626 9.00002C0.666626 7.84724 0.885376 6.76391 1.32288 5.75002C1.76038 4.73613 2.35413 3.85419 3.10413 3.10419C3.85413 2.35419 4.73607 1.76044 5.74996 1.32294C6.76385 0.885437 7.84718 0.666687 8.99996 0.666687V2.33335C7.13885 2.33335 5.56246 2.97919 4.27079 4.27085C2.97913 5.56252 2.33329 7.13891 2.33329 9.00002C2.33329 10.8611 2.97913 12.4375 4.27079 13.7292C5.56246 15.0209 7.13885 15.6667 8.99996 15.6667V17.3334ZM8.99996 10.6667C8.54163 10.6667 8.14927 10.5035 7.82288 10.1771C7.49649 9.85071 7.33329 9.45835 7.33329 9.00002C7.33329 8.93058 7.33676 8.85766 7.34371 8.78127C7.35065 8.70488 7.36801 8.63196 7.39579 8.56252L5.66663 6.83335L6.83329 5.66669L8.56246 7.39585C8.61801 7.38196 8.76385 7.36113 8.99996 7.33335C9.45829 7.33335 9.85065 7.49655 10.177 7.82294C10.5034 8.14933 10.6666 8.54169 10.6666 9.00002C10.6666 9.45835 10.5034 9.85071 10.177 10.1771C9.85065 10.5035 9.45829 10.6667 8.99996 10.6667Z" fill="#40A1EA" />
</svg>
</div>
<div className="flex flex-col">
<p className="font-bold">{trainingContent.exams.length}</p>
<p>Exams Selected</p>
</div>
</div>
<div className="flex flex-row items-center gap-4">
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
<RiArrowRightUpLine color={"#22E1B3"} size={gridView ? 28 : 26} />
</div>
<div className="flex flex-col">
<p className="font-bold">{highestScore}%</p>
<p>Highest Score</p>
</div>
</div>
</div>
<div className={columnClasses}>
<div className="flex flex-row items-center gap-4">
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
<FaChartLine color={"#40A1EA"} size={gridView ? 24 : 26} />
</div>
<div className="flex flex-col">
<p className="font-bold">{averageScore}%</p>
<p>Average Score</p>
</div>
</div>
<div className="flex flex-row items-center gap-4">
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
<RiArrowLeftDownLine color={"#E13922"} size={gridView ? 28 : 26} />
</div>
<div className="flex flex-col">
<p className="font-bold">{lowestScore}%</p>
<p>Lowest Score</p>
</div>
</div>
</div>
{gridView && (
<div className="flex flex-col items-center justify-center gap-2 -lg:hidden">
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
<GiLightBulb color={"#FFCC00"} size={28} />
</div>
<p><span className="font-bold">{trainingContent.tip_ids.length}</span> Tips</p>
</div>
)}
</div>
);
};
export default TrainingScore;

View File

@@ -1,5 +1,5 @@
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User} from "@/interfaces/user"; import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User, Type} from "@/interfaces/user";
import {groupBySession, averageScore} from "@/utils/stats"; import {groupBySession, averageScore} from "@/utils/stats";
import {RadioGroup} from "@headlessui/react"; import {RadioGroup} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
@@ -20,6 +20,10 @@ import useUsers from "@/hooks/useUsers";
import {USER_TYPE_LABELS} from "@/resources/user"; import {USER_TYPE_LABELS} from "@/resources/user";
import {CURRENCIES} from "@/resources/paypal"; import {CURRENCIES} from "@/resources/paypal";
import useCodes from "@/hooks/useCodes"; import useCodes from "@/hooks/useCodes";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import {PERMISSIONS} from "@/constants/userPermissions";
import {PermissionType} from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions";
const expirationDateColor = (date: Date) => { const expirationDateColor = (date: Date) => {
const momentDate = moment(date); const momentDate = moment(date);
@@ -96,6 +100,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
const {stats} = useStats(user.id); const {stats} = useStats(user.id);
const {users} = useUsers(); const {users} = useUsers();
const {codes} = useCodes(user.id); const {codes} = useCodes(user.id);
const {permissions} = usePermissions(loggedInUser.id);
useEffect(() => { useEffect(() => {
if (users && users.length > 0) { if (users && users.length > 0) {
@@ -188,6 +193,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
] ]
: []; : [];
const updateUserPermission = PERMISSIONS.updateUser[user.type] as {
list: Type[];
perm: PermissionType;
};
return ( return (
<> <>
<ProfileSummary user={user} items={user.type === "corporate" ? corporateProfileItems : generalProfileItems} /> <ProfileSummary user={user} items={user.type === "corporate" ? corporateProfileItems : generalProfileItems} />
@@ -308,7 +317,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<Select <Select
className={clsx( 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", "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) || disabledFields.countryManager) && (checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"])) || disabledFields.countryManager) &&
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed", "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
)} )}
options={[ options={[
@@ -344,7 +353,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
}), }),
}} }}
// editing country manager should only be available for dev/admin // editing country manager should only be available for dev/admin
isDisabled={!["developer", "admin"].includes(loggedInUser.type) || disabledFields.countryManager} isDisabled={checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"])) || disabledFields.countryManager}
/> />
)} )}
</div> </div>
@@ -421,7 +430,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
)} )}
<div className="flex flex-col md:flex-row gap-8 w-full"> <div className="flex flex-col md:flex-row gap-8 w-full">
{user.type !== "corporate" && ( {user.type !== "corporate" && user.type !== "mastercorporate" && (
<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">Employment Status</label> <label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
<RadioGroup <RadioGroup
@@ -516,7 +525,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<Checkbox <Checkbox
isChecked={!!expiryDate} isChecked={!!expiryDate}
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)} onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
disabled={disabled}> disabled={
disabled || (!["admin", "developer"].includes(loggedInUser.type) && !!loggedInUser.subscriptionExpirationDate)
}>
Enabled Enabled
</Checkbox> </Checkbox>
</div> </div>
@@ -555,7 +566,12 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
</div> </div>
</div> </div>
</div> </div>
{(loggedInUser.type === "developer" || loggedInUser.type === "admin") && ( {checkAccess(
loggedInUser,
["developer", "admin", "corporate", "mastercorporate"],
permissions,
user.type === "teacher" ? "editTeacher" : user.type === "student" ? "editStudent" : undefined,
) && (
<> <>
<Divider className="w-full !m-0" /> <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">
@@ -563,7 +579,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<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 <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={USER_STATUS_OPTIONS} options={USER_STATUS_OPTIONS.filter((x) => {
if (checkAccess(loggedInUser, ["admin", "developer"])) return true;
return x.value !== "paymentDue";
})}
menuPortalTarget={document?.body} menuPortalTarget={document?.body}
value={USER_STATUS_OPTIONS.find((o) => o.value === status)} value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
onChange={(value) => setStatus(value?.value as typeof user.status)} onChange={(value) => setStatus(value?.value as typeof user.status)}
@@ -591,7 +610,28 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<label className="font-normal text-base text-mti-gray-dim">Type</label> <label className="font-normal text-base text-mti-gray-dim">Type</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={USER_TYPE_OPTIONS} options={USER_TYPE_OPTIONS.filter((x) => {
if (x.value === "student")
return checkAccess(
loggedInUser,
["developer", "admin", "corporate", "mastercorporate"],
permissions,
"editStudent",
);
if (x.value === "teacher")
return checkAccess(
loggedInUser,
["developer", "admin", "corporate", "mastercorporate"],
permissions,
"editTeacher",
);
if (x.value === "corporate")
return checkAccess(loggedInUser, ["developer", "admin", "mastercorporate"], permissions, "editCorporate");
return checkAccess(loggedInUser, ["developer", "admin"]);
})}
menuPortalTarget={document?.body} menuPortalTarget={document?.body}
value={USER_TYPE_OPTIONS.find((o) => o.value === type)} value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
onChange={(value) => setType(value?.value as typeof user.type)} onChange={(value) => setType(value?.value as typeof user.type)}
@@ -642,7 +682,10 @@ 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 disabled={disabled} onClick={updateUser} className="w-full max-w-[200px]"> <Button
disabled={disabled || !checkAccess(loggedInUser, updateUserPermission.list, permissions, updateUserPermission.perm)}
onClick={updateUser}
className="w-full max-w-[200px]">
Update Update
</Button> </Button>
</div> </div>

View File

@@ -0,0 +1,31 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-neutral-200 bg-white p-4 text-neutral-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -10,4 +10,4 @@
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-3ml0u%40storied-phalanx-349916.iam.gserviceaccount.com", "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-3ml0u%40storied-phalanx-349916.iam.gserviceaccount.com",
"universe_domain": "googleapis.com" "universe_domain": "googleapis.com"
} }

View File

@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "encoach-staging",
"private_key_id": "5718a649419776df9637589f8696a258a6a70f6c",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC2C6Es2gY8lLvH\ndVilNtRNm9glSaPXMNw2PzZZbSGuG1uGPFaCzlq1lOb2u17YfMG4GriKIMjIQKXF\nqdvxA8CAmAFRuDjUGmpbO/X1ZW7amOs5Bjed2BYmL01dEqzzwwh7rEfNDjeghRPx\n1uKzH8A6TLT5xq+74I5K1CIgiljBpZimsERu2SDawjkdtZfA7qoylA46Nq66LuwQ\nVyv9CK2SZNpBcT3sunCmRsrCzmSTzKdbcqRPdqUKgZOH/Rjp0sw9VuUgwoxdGZV3\n5SJjObo5ceZ1OSiJm7GwLzp7uq16sqycgSYwppNLI5OtzOfSuWbGD4+a044t2Mlq\n9PHXv7H/AgMBAAECggEAAfhKlFwq8MaL6PggRJq9HbaKgQ4fcOmCmy8AQmPNF1UM\nyVKSKGndjxUfPLCWsaaunUnjZlHoKkvndKXxDyttuVaBE9EiWEqNjRLZ3KpuJ9Jm\nH+CtLbmUCnISQb1n1AlvvZAwhLZbLBL/PhYyWiLapybZAdJAaOWLVKGgBD8gVRQW\nJFCqnszX1O2YlpWHutb979R4qoY/XAf94gyMkTpXZwuETvFqZbau2vxRZ8qARix3\nmic881PwiF6Cod8UPCS9yMK+Q+Se6SomwXU9PCmlummn9xmQBAxYy8gIAVs/J9Fg\n5SvhnImAPDd+zIzzw2cHCiruNWIhroMVZDZJgWdY1QKBgQDjTKKeFOur3ijJJL2/\nWg1SE2jLP0GpXzM5YMx6jdOCNDCzugPngRucRXiTkJ2FnUgyMcQyi6hyrbWXN/6z\nXhx5fwLB4tnTcqOMvNfcay5mDk3RW9ZZJxayB54Sf1Nm/4xiDBnGPT+iHQvK+/pT\nwScWznFkmk60E796o76OLn3PEwKBgQDNCC2uPq+uOcCopIO8HH88gqdxTvpbeHUU\nrdJOmr1VtGNuvay/mfpva9+VEtGbZTFzjhfvfCEIjpj3Llh8Flb9EYa6BmscBiyp\ngszEeFuB3zHndlSCZPnGJ7JiRAdPAEgG3Gl/r9th6PDaEMq0MFS5i7GGhPBIRYCG\nUtmY5eVy5QKBgH5Nuls/YsnJFD7ZNLscziQadvPhvZnhNbSfjmBXaP2EBMAKEFtX\nCcGndN4C0RVLFbAWqWAw7LR0xGA4FEcVd5snsZ+Nb98oZ6sv0H9B67F4J1O7xXsa\n1mitBPBgYjbsr9RXxwa6SB7MJx5vMGXUAeWRZ78wY6V7B76dOKkHOo+TAoGBAJf5\nBOsPueZZFm2qK58GPGVcrsI0+StNuPLP+H+dANQC9mTCIMaQWmm2Oq5jmYwmUKZH\nX4R6rH2MPOOSrbGkWWwRTpyaX1ARX49xzVefoqw8BOB8/Bz+vYjcKcPeitBK9Bhp\nzaUAc4s6PzRTl/xBirtRSQ/df8ECC0cFKBbF6PHlAoGAGqnlpo+k8vAtg6ulCuGu\nx2Y/c5UmvXGHk60pccnW3UtENSDnl99OgMfBz8/qLAMWs6DUQ/kvSlHQPmMBHRWZ\nNTr6ceGXyNs4KdYoj1K7AU3c0Lm0wyQ2giQMoOOUQAm98Xr8z5aiihj10hHPmzzL\n9kwpOmZpjNmC/ERD69imWhY=\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-8rs9e@encoach-staging.iam.gserviceaccount.com",
"client_id": "108221424237414412378",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-8rs9e%40encoach-staging.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

@@ -1,39 +1,91 @@
import {Type} from "@/interfaces/user"; import { Type } from "@/interfaces/user";
export const PERMISSIONS = { export const PERMISSIONS = {
generateCode: { generateCode: {
student: ["corporate", "developer", "admin"], student: ["corporate", "developer", "admin", "mastercorporate"],
teacher: ["corporate", "developer", "admin"], teacher: ["corporate", "developer", "admin", "mastercorporate"],
corporate: ["admin", "developer"], corporate: ["admin", "developer"],
admin: ["developer", "admin"], mastercorporate: ["admin", "developer"],
agent: ["developer", "admin"],
developer: ["developer"], admin: ["developer", "admin"],
}, agent: ["developer", "admin"],
deleteUser: { developer: ["developer"],
student: ["corporate", "developer", "admin"], },
teacher: ["corporate", "developer", "admin"], deleteUser: {
corporate: ["admin", "developer"], student: {
admin: ["developer", "admin"], perm: "deleteStudent",
agent: ["developer", "admin"], list: ["corporate", "developer", "admin", "mastercorporate"],
developer: ["developer"], },
}, teacher: {
updateUser: { perm: "deleteTeacher",
student: ["developer", "admin"], list: ["corporate", "developer", "admin", "mastercorporate"],
teacher: ["developer", "admin"], },
corporate: ["admin", "developer"], corporate: {
admin: ["developer", "admin"], perm: "deleteCorporate",
agent: ["developer", "admin"], list: ["admin", "developer"],
developer: ["developer"], },
}, mastercorporate: {
updateExpiryDate: { perm: undefined,
student: ["developer", "admin"], list: ["admin", "developer"],
teacher: ["developer", "admin"], },
corporate: ["admin", "developer"],
admin: ["developer", "admin"], admin: {
agent: ["developer", "admin"], perm: "deleteAdmin",
developer: ["developer"], list: ["developer", "admin"],
}, },
examManagement: { agent: {
delete: ["developer", "admin"], perm: "deleteCountryManager",
}, list: ["developer", "admin"],
},
developer: {
perm: undefined,
list: ["developer"],
},
},
updateUser: {
student: {
perm: "editStudent",
list: ["developer", "admin"],
},
teacher: {
perm: "editTeacher",
list: ["developer", "admin"],
},
corporate: {
perm: "editCorporate",
list: ["admin", "developer"],
},
mastercorporate: {
perm: undefined,
list: ["admin", "developer"],
},
admin: {
perm: "editAdmin",
list: ["developer", "admin"],
},
agent: {
perm: "editCountryManager",
list: ["developer", "admin"],
},
developer: {
perm: undefined,
list: ["developer"],
},
},
updateExpiryDate: {
student: ["developer", "admin"],
teacher: ["developer", "admin"],
corporate: ["admin", "developer"],
mastercorporate: ["admin", "developer"],
admin: ["developer", "admin"],
agent: ["developer", "admin"],
developer: ["developer"],
},
examManagement: {
delete: ["developer", "admin"],
},
}; };

View File

@@ -25,6 +25,7 @@ import IconCard from "./IconCard";
import useFilterStore from "@/stores/listFilterStore"; import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers"; import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
import CorporateStudentsLevels from "./CorporateStudentsLevels";
interface Props { interface Props {
user: User; user: User;
@@ -37,7 +38,7 @@ export default function AdminDashboard({user}: Props) {
const {stats} = useStats(user.id); const {stats} = useStats(user.id);
const {users, reload} = useUsers(); const {users, reload} = useUsers();
const {groups} = useGroups(); const {groups} = useGroups({});
const {pending, done} = usePaymentStatusUsers(); const {pending, done} = usePaymentStatusUsers();
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
@@ -80,19 +81,21 @@ export default function AdminDashboard({user}: Props) {
: true); : true);
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[filter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> 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">Students ({total})</h2>
</div> </div>
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2> )}
</div> />
<UserList user={user} filters={[filter]} />
</>
); );
}; };
@@ -107,19 +110,21 @@ export default function AdminDashboard({user}: Props) {
: true); : true);
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[filter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> 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">Teachers ({total})</h2>
</div> </div>
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2> )}
</div> />
<UserList user={user} filters={[filter]} />
</>
); );
}; };
@@ -127,7 +132,29 @@ export default function AdminDashboard({user}: Props) {
const filter = (x: User) => x.type === "agent"; const filter = (x: User) => x.type === "agent";
return ( return (
<> <UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<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 ({total})</h2>
</div>
)}
/>
);
};
const CorporateList = () => (
<UserList
user={user}
filters={[(x) => x.type === "corporate"]}
renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => setPage("")}
@@ -135,28 +162,10 @@ export default function AdminDashboard({user}: Props) {
<BsArrowLeft className="text-xl" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
<h2 className="text-2xl font-semibold">Country Managers ({users.filter(filter).length})</h2> <h2 className="text-2xl font-semibold">Corporate ({total})</h2>
</div> </div>
)}
<UserList user={user} filters={[filter]} /> />
</>
);
};
const CorporateList = () => (
<>
<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((x) => x.type === "corporate").length})</h2>
</div>
<UserList user={user} filters={[(x) => x.type === "corporate"]} />
</>
); );
const CorporatePaidStatusList = ({paid}: {paid: Boolean}) => { const CorporatePaidStatusList = ({paid}: {paid: Boolean}) => {
@@ -164,38 +173,43 @@ export default function AdminDashboard({user}: Props) {
const filter = (x: User) => x.type === "corporate" && list.includes(x.id); const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[filter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> 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"} ({total})
</h2>
</div> </div>
<h2 className="text-2xl font-semibold"> )}
{paid ? "Payment Done" : "Pending Payment"} ({list.length}) />
</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
); );
}; };
const InactiveCountryManagerList = () => { const InactiveCountryManagerList = () => {
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[inactiveCountryManagerFilter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> 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 ({total})</h2>
</div> </div>
<h2 className="text-2xl font-semibold">Inactive Country Managers ({users.filter(inactiveCountryManagerFilter).length})</h2> )}
</div> />
<UserList user={user} filters={[inactiveCountryManagerFilter]} />
</>
); );
}; };
@@ -203,25 +217,47 @@ export default function AdminDashboard({user}: Props) {
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));
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[filter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> 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 Students ({total})</h2>
</div> </div>
<h2 className="text-2xl font-semibold">Inactive Students ({users.filter(filter).length})</h2> )}
</div> />
<UserList user={user} filters={[filter]} />
</>
); );
}; };
const InactiveCorporateList = () => { const InactiveCorporateList = () => {
const filter = (x: User) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)); const filter = (x: User) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
return (
<UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Inactive Corporate ({total})</h2>
</div>
)}
/>
);
};
const CorporateStudentsLevelsHelper = () => {
return ( return (
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -231,10 +267,9 @@ export default function AdminDashboard({user}: Props) {
<BsArrowLeft className="text-xl" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
<h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(filter).length})</h2> <h2 className="text-2xl font-semibold">Corporate Students Levels</h2>
</div> </div>
<CorporateStudentsLevels />
<UserList user={user} filters={[filter]} />
</> </>
); );
}; };
@@ -317,6 +352,7 @@ export default function AdminDashboard({user}: Props) {
label="Content Management System (CMS)" label="Content Management System (CMS)"
color="green" color="green"
/> />
<IconCard onClick={() => setPage("corporatestudentslevels")} Icon={BsPersonFill} label="Corporate Students Levels" color="purple" />
</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">
@@ -571,6 +607,7 @@ export default function AdminDashboard({user}: Props) {
{page === "inactiveCountryManagers" && <InactiveCountryManagerList />} {page === "inactiveCountryManagers" && <InactiveCountryManagerList />}
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />} {page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />} {page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
{page === "corporatestudentslevels" && <CorporateStudentsLevelsHelper />}
{page === "" && <DefaultDashboard />} {page === "" && <DefaultDashboard />}
</> </>
); );

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 { User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils"; import {dateSorter} from "@/utils";
import moment from "moment"; import moment from "moment";
@@ -12,7 +12,7 @@ import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import IconCard from "./IconCard"; import IconCard from "./IconCard";
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers'; import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
interface Props { interface Props {
user: User; user: User;
@@ -25,8 +25,7 @@ export default function AgentDashboard({user}: Props) {
const {stats} = useStats(); const {stats} = useStats();
const {users, reload} = useUsers(); const {users, reload} = useUsers();
const {groups} = useGroups(user.id); const {pending, done} = usePaymentStatusUsers();
const { pending, done } = usePaymentStatusUsers();
useEffect(() => { useEffect(() => {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
@@ -38,7 +37,7 @@ export default function AgentDashboard({user}: Props) {
const inactiveReferredCorporateFilter = (x: User) => const inactiveReferredCorporateFilter = (x: User) =>
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)); referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
const UserDisplay = ({ displayUser, allowClick = true }: {displayUser: User, allowClick?: boolean}) => ( const UserDisplay = ({displayUser, allowClick = true}: {displayUser: User; allowClick?: boolean}) => (
<div <div
onClick={() => allowClick && setSelectedUser(displayUser)} onClick={() => allowClick && setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"> className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
@@ -56,37 +55,41 @@ export default function AgentDashboard({user}: Props) {
const ReferredCorporateList = () => { const ReferredCorporateList = () => {
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[referredCorporateFilter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> 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 ({total})</h2>
</div> </div>
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(referredCorporateFilter).length})</h2> )}
</div> />
<UserList user={user} filters={[referredCorporateFilter]} />
</>
); );
}; };
const InactiveReferredCorporateList = () => { const InactiveReferredCorporateList = () => {
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[inactiveReferredCorporateFilter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> 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 ({total})</h2>
</div> </div>
<h2 className="text-2xl font-semibold">Inactive Referred Corporate ({users.filter(inactiveReferredCorporateFilter).length})</h2> )}
</div> />
<UserList user={user} filters={[inactiveReferredCorporateFilter]} />
</>
); );
}; };
@@ -94,38 +97,46 @@ export default function AgentDashboard({user}: Props) {
const filter = (x: User) => x.type === "corporate"; const filter = (x: User) => x.type === "corporate";
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[filter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> 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 ({total})</h2>
</div> </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 CorporatePaidStatusList = ({paid}: {paid: Boolean}) => {
const list = paid ? done : pending; const list = paid ? done : pending;
const filter = (x: User) => x.type === "corporate" && list.includes(x.id); const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[filter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> 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"} ({total})
</h2>
</div> </div>
<h2 className="text-2xl font-semibold">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2> )}
</div> />
<UserList user={user} filters={[filter]} />
</>
); );
}; };
@@ -153,13 +164,7 @@ export default function AgentDashboard({user}: Props) {
value={users.filter(corporateFilter).length} value={users.filter(corporateFilter).length}
color="purple" color="purple"
/> />
<IconCard <IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
onClick={() => setPage("paymentdone")}
Icon={BsCurrencyDollar}
label="Payment Done"
value={done.length}
color="purple"
/>
<IconCard <IconCard
onClick={() => setPage("paymentpending")} onClick={() => setPage("paymentpending")}
Icon={BsCurrencyDollar} Icon={BsCurrencyDollar}

View File

@@ -10,13 +10,17 @@ import {usePDFDownload} from "@/hooks/usePDFDownload";
import {useAssignmentArchive} from "@/hooks/useAssignmentArchive"; import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
import {uniqBy} from "lodash"; import {uniqBy} from "lodash";
import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive"; import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
import {getUserName} from "@/utils/users";
import {User} from "@/interfaces/user";
interface Props { interface Props {
users: User[];
onClick?: () => void; onClick?: () => void;
allowDownload?: boolean; allowDownload?: boolean;
reload?: Function; reload?: Function;
allowArchive?: boolean; allowArchive?: boolean;
allowUnarchive?: boolean; allowUnarchive?: boolean;
allowExcelDownload?: boolean;
} }
export default function AssignmentCard({ export default function AssignmentCard({
@@ -34,8 +38,11 @@ export default function AssignmentCard({
reload, reload,
allowArchive, allowArchive,
allowUnarchive, allowUnarchive,
allowExcelDownload,
users,
}: Assignment & Props) { }: Assignment & Props) {
const renderPdfIcon = usePDFDownload("assignments"); const renderPdfIcon = usePDFDownload("assignments");
const renderExcelIcon = usePDFDownload("assignments", "excel");
const renderArchiveIcon = useAssignmentArchive(id, reload); const renderArchiveIcon = useAssignmentArchive(id, reload);
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload); const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
@@ -60,6 +67,7 @@ export default function AssignmentCard({
<h3 className="text-xl font-semibold">{name}</h3> <h3 className="text-xl font-semibold">{name}</h3>
<div className="flex gap-2"> <div className="flex gap-2">
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")} {allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{allowExcelDownload && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")} {allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")} {allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
</div> </div>
@@ -72,11 +80,14 @@ export default function AssignmentCard({
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"} textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
/> />
</div> </div>
<span className="flex justify-between gap-1"> <div className="flex flex-col gap-1">
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span> <span className="flex justify-between gap-1">
<span>-</span> <span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span> <span>-</span>
</span> <span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
<span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span>
</div>
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2"> <div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{uniqBy(exams, (x) => x.module).map(({module}) => ( {uniqBy(exams, (x) => x.module).map(({module}) => (
<div <div

View File

@@ -2,7 +2,7 @@ import Input from "@/components/Low/Input";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import clsx from "clsx"; import clsx from "clsx";
import {useState} from "react"; import {useEffect, useState} from "react";
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs"; import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {generate} from "random-words"; import {generate} from "random-words";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
@@ -16,11 +16,11 @@ import moment from "moment";
import axios from "axios"; import axios from "axios";
import {getExam} from "@/utils/exams"; import {getExam} from "@/utils/exams";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {uuidv4} from "@firebase/util";
import {Assignment} from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import {InstructorGender, Variant} from "@/interfaces/exam"; import {InstructorGender, Variant} from "@/interfaces/exam";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import useExams from "@/hooks/useExams";
interface Props { interface Props {
isCreating: boolean; isCreating: boolean;
@@ -44,6 +44,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied"); const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
// creates a new exam for each assignee or just one exam for all assignees // creates a new exam for each assignee or just one exam for all assignees
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false); const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
const [useRandomExams, setUseRandomExams] = useState(true);
const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]);
const {exams} = useExams();
useEffect(() => {
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
}, [selectedModules]);
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module); const modules = selectedModules.filter((x) => x !== module);
@@ -61,6 +69,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
assignees, assignees,
name, name,
startDate, startDate,
examIDs: !useRandomExams ? examIDs : undefined,
endDate, endDate,
selectedModules, selectedModules,
generateMultiple, generateMultiple,
@@ -229,19 +238,52 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
</div> </div>
</div> </div>
<div className="flex flex-col gap-3 w-full"> {selectedModules.includes("speaking") && (
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label> <div className="flex flex-col gap-3 w-full">
<Select <label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label>
value={{value: instructorGender, label: capitalize(instructorGender)}} <Select
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)} value={{value: instructorGender, label: capitalize(instructorGender)}}
disabled={!selectedModules.includes("speaking") || !!assignment} onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
options={[ disabled={!selectedModules.includes("speaking") || !!assignment}
{value: "male", label: "Male"}, options={[
{value: "female", label: "Female"}, {value: "male", label: "Male"},
{value: "varied", label: "Varied"}, {value: "female", label: "Female"},
]} {value: "varied", label: "Varied"},
/> ]}
</div> />
</div>
)}
{selectedModules.length > 0 && (
<div className="flex flex-col gap-3 w-full">
<Checkbox isChecked={useRandomExams} onChange={setUseRandomExams}>
Random Exams
</Checkbox>
{!useRandomExams && (
<div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">{capitalize(module)} Exam</label>
<Select
value={{
value: examIDs.find((e) => e.module === module)?.id || null,
label: examIDs.find((e) => e.module === module)?.id || "",
}}
onChange={(value) =>
value
? setExamIDs((prev) => [...prev.filter((x) => x.module !== module), {id: value.value!, module}])
: setExamIDs((prev) => prev.filter((x) => x.module !== module))
}
options={exams
.filter((x) => !x.isDiagnostic && x.module === module)
.map((x) => ({value: x.id, label: x.id}))}
/>
</div>
))}
</div>
)}
</div>
)}
<section className="w-full flex flex-col gap-3"> <section className="w-full flex flex-col gap-3">
<span className="font-semibold">Assignees ({assignees.length} selected)</span> <span className="font-semibold">Assignees ({assignees.length} selected)</span>
@@ -323,7 +365,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
</Button> </Button>
)} )}
<Button <Button
disabled={selectedModules.length === 0 || !name || !startDate || !endDate || assignees.length === 0} disabled={
selectedModules.length === 0 ||
!name ||
!startDate ||
!endDate ||
assignees.length === 0 ||
(!useRandomExams && examIDs.length < selectedModules.length)
}
className="w-full max-w-[200px]" className="w-full max-w-[200px]"
onClick={createAssignment} onClick={createAssignment}
isLoading={isLoading}> isLoading={isLoading}>

View File

@@ -10,6 +10,7 @@ import {getExamById} from "@/utils/exams";
import {sortByModule} from "@/utils/moduleUtils"; import {sortByModule} from "@/utils/moduleUtils";
import {calculateBandScore} from "@/utils/score"; import {calculateBandScore} from "@/utils/score";
import {convertToUserSolutions} from "@/utils/stats"; import {convertToUserSolutions} from "@/utils/stats";
import {getUserName} from "@/utils/users";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, uniqBy} from "lodash"; import {capitalize, uniqBy} from "lodash";
@@ -241,13 +242,16 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span> <span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span> <span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
</div> </div>
<span> <div className="flex flex-col gap-2">
Assignees:{" "} <span>
{users Assignees:{" "}
.filter((u) => assignment?.assignees.includes(u.id)) {users
.map((u) => `${u.name} (${u.email})`) .filter((u) => assignment?.assignees.includes(u.id))
.join(", ")} .map((u) => `${u.name} (${u.email})`)
</span> .join(", ")}
</span>
<span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span>
</div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-xl font-bold">Average Scores</span> <span className="text-xl font-bold">Average Scores</span>

View File

@@ -23,10 +23,13 @@ import {
BsPersonBadge, BsPersonBadge,
BsPersonCheck, BsPersonCheck,
BsPeople, BsPeople,
BsArrowRepeat,
BsPlus,
BsEnvelopePaper,
} 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";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats"; import {groupByExam} from "@/utils/stats";
@@ -35,20 +38,134 @@ import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore"; import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import useCodes from "@/hooks/useCodes"; import useCodes from "@/hooks/useCodes";
import {getUserCorporate} from "@/utils/groups";
import useAssignments from "@/hooks/useAssignments";
import {Assignment} from "@/interfaces/results";
import AssignmentView from "./AssignmentView";
import AssignmentCreator from "./AssignmentCreator";
import clsx from "clsx";
import AssignmentCard from "./AssignmentCard";
import {createColumnHelper} from "@tanstack/react-table";
import Checkbox from "@/components/Low/Checkbox";
import List from "@/components/List";
import {getUserCompanyName} from "@/resources/user";
interface Props { interface Props {
user: CorporateUser; user: CorporateUser;
} }
type StudentPerformanceItem = User & {corporateName: string; group: string};
const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]}) => {
const [isShowingAmount, setIsShowingAmount] = useState(false);
const columnHelper = createColumnHelper<StudentPerformanceItem>();
const columns = [
columnHelper.accessor("name", {
header: "Student Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("email", {
header: "E-mail",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("demographicInformation.passport_id", {
header: "ID",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("group", {
header: "Group",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("corporateName", {
header: "Corporate",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("levels.reading", {
header: "Reading",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.listening", {
header: "Listening",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.writing", {
header: "Writing",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.speaking", {
header: "Speaking",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.level", {
header: "Level",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels", {
id: "overall_level",
header: "Overall",
cell: (info) =>
!isShowingAmount
? averageLevelCalculator(
users,
stats.filter((x) => x.user === info.row.original.id),
).toFixed(1)
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
}),
];
return (
<div className="flex flex-col gap-4 w-full h-full">
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
Show Utilization
</Checkbox>
<List<StudentPerformanceItem>
data={items.sort(
(a, b) =>
averageLevelCalculator(
users,
stats.filter((x) => x.user === b.id),
) -
averageLevelCalculator(
users,
stats.filter((x) => x.user === a.id),
),
)}
columns={columns}
/>
</div>
);
};
export default function CorporateDashboard({user}: Props) { export default function CorporateDashboard({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);
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const [userBalance, setUserBalance] = useState(0);
const {stats} = useStats(); const {stats} = useStats();
const {users, reload} = useUsers(); const {users, reload, isLoading} = useUsers();
const {codes} = useCodes(user.id); const {codes} = useCodes(user.id);
const {groups} = useGroups(user.id); const {groups} = useGroups({admin: user.id});
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter(); const router = useRouter();
@@ -57,6 +174,19 @@ export default function CorporateDashboard({user}: Props) {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]); }, [selectedUser, page]);
useEffect(() => {
const relatedGroups = groups.filter((x) => x.name === "Students" || x.name === "Teachers" || x.name === "Corporate");
const usersInGroups = relatedGroups.map((x) => x.participants).flat();
const filteredCodes = codes.filter((x) => !x.userId || !usersInGroups.includes(x.userId));
setUserBalance(usersInGroups.length + filteredCodes.length);
}, [codes, groups]);
useEffect(() => {
// in this case it fetches the master corporate account
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 teacherFilter = (user: User) => user.type === "teacher" && groups.flatMap((g) => g.participants).includes(user.id); const teacherFilter = (user: User) => user.type === "teacher" && groups.flatMap((g) => g.participants).includes(user.id);
@@ -85,19 +215,21 @@ export default function CorporateDashboard({user}: Props) {
: groups.flatMap((g) => g.participants).includes(x.id)); : groups.flatMap((g) => g.participants).includes(x.id));
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[filter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> 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">Students ({total})</h2>
</div> </div>
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2> )}
</div> />
<UserList user={user} filters={[filter]} />
</>
); );
}; };
@@ -112,19 +244,21 @@ export default function CorporateDashboard({user}: Props) {
: groups.flatMap((g) => g.participants).includes(x.id)); : groups.flatMap((g) => g.participants).includes(x.id));
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[filter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> 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">Teachers ({total})</h2>
</div> </div>
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2> )}
</div> />
<UserList user={user} filters={[filter]} />
</>
); );
}; };
@@ -148,16 +282,177 @@ export default function CorporateDashboard({user}: Props) {
); );
}; };
const AssignmentsPage = () => {
const activeFilter = (a: Assignment) =>
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
const archivedFilter = (a: Assignment) => a.archived;
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
return (
<>
<AssignmentView
isOpen={!!selectedAssignment && !isCreatingAssignment}
onClose={() => {
setSelectedAssignment(undefined);
setIsCreatingAssignment(false);
reloadAssignments();
}}
assignment={selectedAssignment}
/>
<AssignmentCreator
assignment={selectedAssignment}
groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
users={users.filter(
(x) =>
x.type === "student" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id)),
)}
assigner={user.id}
isCreating={isCreatingAssignment}
cancelCreation={() => {
setIsCreatingAssignment(false);
setSelectedAssignment(undefined);
reloadAssignments();
}}
/>
<div className="w-full flex justify-between items-center">
<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>
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
</div>
</div>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => (
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
<div className="flex flex-wrap gap-2">
<div
onClick={() => setIsCreatingAssignment(true)}
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
<BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span>
</div>
{assignments.filter(futureFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => {
setSelectedAssignment(a);
setIsCreatingAssignment(true);
}}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowArchive
allowExcelDownload
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowUnarchive
allowExcelDownload
/>
))}
</div>
</section>
</>
);
};
const StudentPerformancePage = () => {
const students = users
.filter((x) => x.type === "student" && groups.flatMap((g) => g.participants).includes(x.id))
.map((u) => ({
...u,
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
corporateName: getUserCompanyName(u, users, groups),
}));
return (
<>
<div className="w-full flex justify-between items-center">
<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>
<div
onClick={reload}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
</div>
</div>
<StudentPerformanceList items={students} stats={stats} users={users} />
</>
);
};
const averageLevelCalculator = (studentStats: Stat[]) => { const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats const formattedStats = studentStats
.map((s) => ({focus: users.find((u) => u.id === s.user)?.focus, score: s.score, module: s.module})) .map((s) => ({
focus: users.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus); .filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({ const bandScores = formattedStats.map((s) => ({
module: s.module, module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
})); }));
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0}; const levels: {[key in Module]: number} = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level)); bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels); return calculateAverageLevel(levels);
@@ -165,7 +460,12 @@ export default function CorporateDashboard({user}: Props) {
const DefaultDashboard = () => ( const DefaultDashboard = () => (
<> <>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between 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="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center">
<IconCard <IconCard
onClick={() => setPage("students")} onClick={() => setPage("students")}
Icon={BsPersonFill} Icon={BsPersonFill}
@@ -196,7 +496,7 @@ export default function CorporateDashboard({user}: Props) {
<IconCard <IconCard
Icon={BsPersonCheck} Icon={BsPersonCheck}
label="User Balance" label="User Balance"
value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`} value={`${userBalance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
color="purple" color="purple"
/> />
<IconCard <IconCard
@@ -205,6 +505,25 @@ export default function CorporateDashboard({user}: Props) {
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"} value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose" color="rose"
/> />
<IconCard
Icon={BsPersonFillGear}
label="Student Performance"
value={users.filter(studentFilter).length}
color="purple"
onClick={() => setPage("studentsPerformance")}
/>
<button
disabled={isAssignmentsLoading}
onClick={() => setPage("assignments")}
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light">
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
</span>
</span>
</button>
</section> </section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
@@ -320,6 +639,8 @@ export default function CorporateDashboard({user}: Props) {
{page === "students" && <StudentsList />} {page === "students" && <StudentsList />}
{page === "teachers" && <TeachersList />} {page === "teachers" && <TeachersList />}
{page === "groups" && <GroupsList />} {page === "groups" && <GroupsList />}
{page === "assignments" && <AssignmentsPage />}
{page === "studentsPerformance" && <StudentPerformancePage />}
{page === "" && <DefaultDashboard />} {page === "" && <DefaultDashboard />}
</> </>
); );

View File

@@ -0,0 +1,108 @@
import React from "react";
import useUsers from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups";
import {User} from "@/interfaces/user";
import Select from "@/components/Low/Select";
import ProgressBar from "@/components/Low/ProgressBar";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {capitalize} from "lodash";
import {getLevelLabel} from "@/utils/score";
const Card = ({user}: {user: User}) => {
return (
<div className="border-mti-gray-platinum flex flex-col h-fit w-full cursor-pointer gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
<div className="flex flex-col gap-3">
<h3 className="text-xl font-semibold">{user.name}</h3>
</div>
<div className="flex w-full gap-3 flex-wrap">
{MODULE_ARRAY.map((module) => {
const desiredLevel = user.desiredLevels[module] || 9;
const level = user.levels[module] || 0;
return (
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 min-w-[250px]" key={module}>
<div className="flex items-center gap-2 md:gap-3">
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
</div>
<div className="flex w-full flex-col">
<span className="text-sm font-bold md:font-extrabold w-full">{capitalize(module)}</span>
<div className="text-mti-gray-dim text-sm font-normal">
{module === "level" && <span>English Level: {getLevelLabel(level).join(" / ")}</span>}
{module !== "level" && (
<div className="flex flex-col">
<span>Level {level} / Level 9</span>
<span>Desired Level: {desiredLevel}</span>
</div>
)}
</div>
</div>
</div>
<div className="md:pl-14">
<ProgressBar
color={module}
label=""
mark={Math.round((desiredLevel * 100) / 9)}
markLabel={`Desired Level: ${desiredLevel}`}
percentage={Math.round((level * 100) / 9)}
className="h-2 w-full"
/>
</div>
</div>
);
})}
</div>
</div>
);
};
const CorporateStudentsLevels = () => {
const {users} = useUsers();
const {groups} = useGroups({});
const corporateUsers = users.filter((u) => u.type === "corporate") as User[];
const [corporateId, setCorporateId] = React.useState<string>("");
const corporate = corporateUsers.find((u) => u.id === corporateId) || corporateUsers[0];
const groupsFromCorporate = corporate ? groups.filter((g) => g.admin === corporate.id) : [];
const groupsParticipants = groupsFromCorporate
.flatMap((g) => g.participants)
.reduce((accm: User[], p) => {
const user = users.find((u) => u.id === p) as User;
if (user) {
return [...accm, user];
}
return accm;
}, []);
return (
<>
<Select
options={corporateUsers.map((x: User) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
value={corporate ? {value: corporate.id, label: corporate.name} : null}
onChange={(value) => setCorporateId(value?.value!)}
styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
{groupsParticipants.map((u) => (
<Card user={u} key={u.id} />
))}
</>
);
};
export default CorporateStudentsLevels;

View File

@@ -0,0 +1,838 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
import {useEffect, useState} from "react";
import {
BsArrowLeft,
BsClipboard2Data,
BsClock,
BsPaperclip,
BsPersonFill,
BsPencilSquare,
BsPersonCheck,
BsPeople,
BsBank,
BsEnvelopePaper,
BsArrowRepeat,
BsPlus,
BsPersonFillGear,
BsFilter,
BsDatabase,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats";
import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
import useCodes from "@/hooks/useCodes";
import useAssignments from "@/hooks/useAssignments";
import {Assignment} from "@/interfaces/results";
import AssignmentView from "./AssignmentView";
import AssignmentCreator from "./AssignmentCreator";
import clsx from "clsx";
import AssignmentCard from "./AssignmentCard";
import {createColumn, createColumnHelper} from "@tanstack/react-table";
import List from "@/components/List";
import {getUserCorporate} from "@/utils/groups";
import {getCorporateUser, getUserCompanyName} from "@/resources/user";
import Checkbox from "@/components/Low/Checkbox";
import {groupBy, uniq, uniqBy} from "lodash";
import Select from "@/components/Low/Select";
import {Menu, MenuButton, MenuItem, MenuItems} from "@headlessui/react";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
import MasterStatistical from "./MasterStatistical";
interface Props {
user: MasterCorporateUser;
}
const activeFilter = (a: Assignment) =>
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
const archivedFilter = (a: Assignment) => a.archived;
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
type StudentPerformanceItem = User & {corporate?: CorporateUser; group?: Group};
const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]; groups: Group[]}) => {
const [isShowingAmount, setIsShowingAmount] = useState(false);
const [availableCorporates] = useState(
uniqBy(
items.map((x) => x.corporate),
"id",
),
);
const [availableGroups] = useState(
uniqBy(
items.map((x) => x.group),
"id",
),
);
const [selectedCorporate, setSelectedCorporate] = useState<CorporateUser | null | undefined>(null);
const [selectedGroup, setSelectedGroup] = useState<Group | null | undefined>(null);
const columnHelper = createColumnHelper<StudentPerformanceItem>();
const columns = [
columnHelper.accessor("name", {
header: "Student Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("email", {
header: "E-mail",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("demographicInformation.passport_id", {
header: "ID",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("group", {
header: "Group",
cell: (info) => info.getValue()?.name || "N/A",
}),
columnHelper.accessor("corporate", {
header: "Corporate",
cell: (info) => (!!info.getValue() ? getUserCompanyName(info.getValue() as User, users, groups) : "N/A"),
}),
columnHelper.accessor("levels.reading", {
header: "Reading",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.listening", {
header: "Listening",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.writing", {
header: "Writing",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.speaking", {
header: "Speaking",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.level", {
header: "Level",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "level" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "level" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels", {
id: "overall_level",
header: "Overall",
cell: (info) =>
!isShowingAmount
? averageLevelCalculator(
users,
stats.filter((x) => x.user === info.row.original.id),
).toFixed(1)
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
}),
];
const filterUsers = (data: StudentPerformanceItem[]) => {
console.log(data, selectedCorporate);
const filterByCorporate = (item: StudentPerformanceItem) => item.corporate?.id === selectedCorporate?.id;
const filterByGroup = (item: StudentPerformanceItem) => item.group?.id === selectedGroup?.id;
const filters: ((item: StudentPerformanceItem) => boolean)[] = [];
if (selectedCorporate !== null) filters.push(filterByCorporate);
if (selectedGroup !== null) filters.push(filterByGroup);
return filters.reduce((d, f) => d.filter(f), data);
};
return (
<div className="flex flex-col gap-4 w-full h-full">
<div className="w-full flex gap-4 justify-between items-center">
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
Show Utilization
</Checkbox>
<Popover>
<PopoverTrigger>
<div className="flex items-center justify-center p-2 hover:bg-neutral-300/50 rounded-full transition ease-in-out duration-300">
<BsFilter size={20} />
</div>
</PopoverTrigger>
<PopoverContent className="w-96">
<div className="flex flex-col gap-4">
<span className="font-bold text-lg">Filters</span>
<Select
options={availableCorporates.map((x) => ({
value: x?.id || "N/A",
label: x?.corporateInformation?.companyInformation?.name || x?.name || "N/A",
}))}
isClearable
value={
selectedCorporate === null
? null
: {
value: selectedCorporate?.id || "N/A",
label:
selectedCorporate?.corporateInformation?.companyInformation?.name ||
selectedCorporate?.name ||
"N/A",
}
}
placeholder="Select a Corporate..."
onChange={(value) =>
!value
? setSelectedCorporate(null)
: setSelectedCorporate(
value.value === "N/A" ? undefined : availableCorporates.find((x) => x?.id === value.value),
)
}
/>
<Select
options={availableGroups.map((x) => ({
value: x?.id || "N/A",
label: x?.name || "N/A",
}))}
isClearable
value={
selectedGroup === null
? null
: {
value: selectedGroup?.id || "N/A",
label: selectedGroup?.name || "N/A",
}
}
placeholder="Select a Group..."
onChange={(value) =>
!value
? setSelectedGroup(null)
: setSelectedGroup(value.value === "N/A" ? undefined : availableGroups.find((x) => x?.id === value.value))
}
/>
</div>
</PopoverContent>
</Popover>
</div>
<List<StudentPerformanceItem>
data={filterUsers(
items.sort(
(a, b) =>
averageLevelCalculator(
users,
stats.filter((x) => x.user === b.id),
) -
averageLevelCalculator(
users,
stats.filter((x) => x.user === a.id),
),
),
)}
columns={columns}
/>
</div>
);
};
export default function MasterCorporateDashboard({user}: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]);
const {stats} = useStats();
const {users, reload} = useUsers();
const {codes} = useCodes(user.id);
const {groups} = useGroups({admin: user.id, userType: user.type});
const masterCorporateUserGroups = [...new Set(groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants))];
const corporateUserGroups = [...new Set(groups.flatMap((g) => g.participants))];
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
useEffect(() => {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
useEffect(() => {
setCorporateAssignments(
assignments.filter(activeFilter).map((a) => ({
...a,
corporate: !!users.find((x) => x.id === a.assigner)
? getCorporateUser(users.find((x) => x.id === a.assigner)!, users, groups)
: undefined,
})),
);
}, [assignments, groups, users]);
const studentFilter = (user: User) => user.type === "student" && corporateUserGroups.includes(user.id);
const teacherFilter = (user: User) => user.type === "teacher" && corporateUserGroups.includes(user.id);
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const StudentsList = () => {
const filter = (x: User) =>
x.type === "student" && (!!selectedUser ? corporateUserGroups.includes(x.id) || false : corporateUserGroups.includes(x.id));
return (
<UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<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">Students ({total})</h2>
</div>
)}
/>
);
};
const TeachersList = () => {
const filter = (x: User) =>
x.type === "teacher" && (!!selectedUser ? corporateUserGroups.includes(x.id) || false : corporateUserGroups.includes(x.id));
return (
<UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<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">Teachers ({total})</h2>
</div>
)}
/>
);
};
const corporateUserFilter = (x: User) =>
x.type === "corporate" && (!!selectedUser ? masterCorporateUserGroups.includes(x.id) || false : masterCorporateUserGroups.includes(x.id));
const CorporateList = () => {
return (
<UserList
user={user}
filters={[corporateUserFilter]}
renderHeader={(total) => (
<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">Corporates ({total})</h2>
</div>
)}
/>
);
};
const GroupsList = () => {
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">Groups ({groups.length})</h2>
</div>
<GroupList user={user} />
</>
);
};
// const AssignmentsPage = () => {
// const activeFilter = (a: Assignment) =>
// moment(a.endDate).isAfter(moment()) &&
// moment(a.startDate).isBefore(moment()) &&
// a.assignees.length > a.results.length;
// const pastFilter = (a: Assignment) =>
// (moment(a.endDate).isBefore(moment()) ||
// a.assignees.length === a.results.length) &&
// !a.archived;
// const archivedFilter = (a: Assignment) => a.archived;
// const futureFilter = (a: Assignment) =>
// moment(a.startDate).isAfter(moment());
const StudentPerformancePage = () => {
const students = users
.filter((x) => x.type === "student" && groups.flatMap((g) => g.participants).includes(x.id))
.map((u) => ({
...u,
group: groups.find((x) => x.participants.includes(u.id)),
corporate: getCorporateUser(u, users, groups),
}));
return (
<>
<div className="w-full flex justify-between items-center">
<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>
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
</div>
</div>
<StudentPerformanceList items={students} stats={stats} users={users} groups={groups} />
</>
);
};
const AssignmentsPage = () => {
return (
<>
<AssignmentView
isOpen={!!selectedAssignment && !isCreatingAssignment}
onClose={() => {
setSelectedAssignment(undefined);
setIsCreatingAssignment(false);
reloadAssignments();
}}
assignment={selectedAssignment}
/>
<AssignmentCreator
assignment={selectedAssignment}
groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
users={users.filter(
(x) =>
x.type === "student" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id)),
)}
assigner={user.id}
isCreating={isCreatingAssignment}
cancelCreation={() => {
setIsCreatingAssignment(false);
setSelectedAssignment(undefined);
reloadAssignments();
}}
/>
<div className="w-full flex justify-between items-center">
<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>
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-lg font-bold">Active Assignments Status</span>
<div className="flex items-center gap-4">
<span>
<b>Total:</b> {assignments.filter(activeFilter).reduce((acc, curr) => acc + curr.results.length, 0)}/
{assignments.filter(activeFilter).reduce((acc, curr) => curr.exams.length + acc, 0)}
</span>
{Object.keys(groupBy(corporateAssignments, (x) => x.corporate?.id)).map((x) => (
<div key={x}>
<span className="font-semibold">{getUserCompanyName(users.find((u) => u.id === x)!, users, groups)}: </span>
<span>
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.results.length + acc, 0)}/
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.exams.length + acc, 0)}
</span>
</div>
))}
</div>
</div>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => (
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
<div className="flex flex-wrap gap-2">
<div
onClick={() => setIsCreatingAssignment(true)}
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
<BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span>
</div>
{assignments.filter(futureFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => {
setSelectedAssignment(a);
setIsCreatingAssignment(true);
}}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowArchive
allowExcelDownload
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowUnarchive
allowExcelDownload
/>
))}
</div>
</section>
</>
);
};
const MasterStatisticalPage = () => {
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">Master Statistical</h2>
</div>
<MasterStatistical
users={masterCorporateUserGroups.reduce((accm: CorporateUser[], id) => {
const user = users.find((u) => u.id === id) as CorporateUser;
if (user) return [...accm, user];
return accm;
}, [])}
/>
</>
);
};
const DefaultDashboard = () => (
<>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
<IconCard
onClick={() => setPage("students")}
Icon={BsPersonFill}
label="Students"
value={users.filter(studentFilter).length}
color="purple"
/>
<IconCard
onClick={() => setPage("teachers")}
Icon={BsPencilSquare}
label="Teachers"
value={users.filter(teacherFilter).length}
color="purple"
/>
<IconCard
Icon={BsClipboard2Data}
label="Exams Performed"
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
color="purple"
/>
<IconCard
Icon={BsPaperclip}
label="Average Level"
value={averageLevelCalculator(
users,
stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)),
).toFixed(1)}
color="purple"
/>
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
<IconCard
Icon={BsPersonCheck}
label="User Balance"
value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose"
/>
<IconCard
Icon={BsBank}
label="Corporate"
value={masterCorporateUserGroups.length}
color="purple"
onClick={() => setPage("corporate")}
/>
<IconCard
Icon={BsPersonFillGear}
label="Student Performance"
value={users.filter(studentFilter).length}
color="purple"
onClick={() => setPage("studentsPerformance")}
/>
{/* <IconCard
Icon={BsDatabase}
label="Master Statistical"
// value={masterCorporateUserGroups.length}
color="purple"
onClick={() => setPage("statistical")}
/> */}
<button
disabled={isAssignmentsLoading}
onClick={() => setPage("assignments")}
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light">
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
</span>
</span>
</button>
</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 students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(teacherFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...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"
? () => {
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
}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{page === "students" && <StudentsList />}
{page === "teachers" && <TeachersList />}
{page === "groups" && <GroupsList />}
{page === "corporate" && <CorporateList />}
{page === "assignments" && <AssignmentsPage />}
{page === "studentsPerformance" && <StudentPerformancePage />}
{page === "statistical" && <MasterStatisticalPage />}
{page === "" && <DefaultDashboard />}
</>
);
}

View File

@@ -0,0 +1,34 @@
import React from "react";
import {CorporateUser} from "@/interfaces/user";
import {BsBank, BsPersonFill} from "react-icons/bs";
import IconCard from "./IconCard";
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
interface Props {
users: CorporateUser[];
}
const MasterStatistical = (props: Props) => {
const {users} = props;
const usersList = React.useMemo(() => users.map((x) => x.id), [users]);
const {assignments} = useAssignmentsCorporates({corporates: usersList});
return (
<div className="flex flex-wrap gap-2 items-center text-center">
<IconCard Icon={BsBank} label="Consolidate" value={0} color="purple" onClick={() => console.log("clicked")} />
{users.map((group) => (
<IconCard
key={group.id}
Icon={BsBank}
label={group.corporateInformation?.companyInformation?.name}
value={0}
color="purple"
onClick={() => console.log("clicked", group)}
/>
))}
<IconCard onClick={() => console.log("clicked")} Icon={BsPersonFill} label="Consolidate Highest Student" color="purple" />
</div>
);
};
export default MasterStatistical;

View File

@@ -1,7 +1,6 @@
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 InviteCard from "@/components/Medium/InviteCard"; import InviteCard from "@/components/Medium/InviteCard";
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 useInvites from "@/hooks/useInvites"; import useInvites from "@/hooks/useInvites";

View File

@@ -46,6 +46,8 @@ 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"; import {getUserCorporate} from "@/utils/groups";
import {checkAccess} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
interface Props { interface Props {
user: User; user: User;
@@ -61,7 +63,8 @@ export default function TeacherDashboard({user}: Props) {
const {stats} = useStats(); const {stats} = useStats();
const {users, reload} = useUsers(); const {users, reload} = useUsers();
const {groups} = useGroups(user.id); const {groups} = useGroups({adminAdmins: user.id});
const {permissions} = usePermissions(user.id);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id}); const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
useEffect(() => { useEffect(() => {
@@ -99,24 +102,26 @@ export default function TeacherDashboard({user}: Props) {
: groups.flatMap((g) => g.participants).includes(x.id)); : groups.flatMap((g) => g.participants).includes(x.id));
return ( return (
<> <UserList
<div className="flex flex-col gap-4"> user={user}
<div filters={[filter]}
onClick={() => setPage("")} renderHeader={(total) => (
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="flex flex-col gap-4">
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> 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">Students ({total})</h2>
</div> </div>
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2> )}
</div> />
<UserList user={user} filters={[filter]} />
</>
); );
}; };
const GroupsList = () => { const GroupsList = () => {
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id); const filter = (x: Group) => x.admin === user.id;
return ( return (
<> <>
@@ -137,14 +142,24 @@ export default function TeacherDashboard({user}: Props) {
const averageLevelCalculator = (studentStats: Stat[]) => { const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats const formattedStats = studentStats
.map((s) => ({focus: users.find((u) => u.id === s.user)?.focus, score: s.score, module: s.module})) .map((s) => ({
focus: users.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus); .filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({ const bandScores = formattedStats.map((s) => ({
module: s.module, module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
})); }));
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0}; const levels: {[key in Module]: number} = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level)); bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels); return calculateAverageLevel(levels);
@@ -178,7 +193,7 @@ export default function TeacherDashboard({user}: Props) {
? groups ? groups
.filter((g) => g.admin === selectedUser.id) .filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id) || false .includes(x.id)
: groups.flatMap((g) => g.participants).includes(x.id)), : groups.flatMap((g) => g.participants).includes(x.id)),
)} )}
assigner={user.id} assigner={user.id}
@@ -207,7 +222,7 @@ export default function TeacherDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2> <h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => ( {assignments.filter(activeFilter).map((a) => (
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} /> <AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
))} ))}
</div> </div>
</section> </section>
@@ -223,6 +238,7 @@ export default function TeacherDashboard({user}: Props) {
{assignments.filter(futureFilter).map((a) => ( {assignments.filter(futureFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users}
onClick={() => { onClick={() => {
setSelectedAssignment(a); setSelectedAssignment(a);
setIsCreatingAssignment(true); setIsCreatingAssignment(true);
@@ -238,11 +254,13 @@ export default function TeacherDashboard({user}: Props) {
{assignments.filter(pastFilter).map((a) => ( {assignments.filter(pastFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users}
onClick={() => setSelectedAssignment(a)} onClick={() => setSelectedAssignment(a)}
key={a.id} key={a.id}
allowDownload allowDownload
reload={reloadAssignments} reload={reloadAssignments}
allowArchive allowArchive
allowExcelDownload
/> />
))} ))}
</div> </div>
@@ -253,11 +271,13 @@ export default function TeacherDashboard({user}: Props) {
{assignments.filter(archivedFilter).map((a) => ( {assignments.filter(archivedFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users}
onClick={() => setSelectedAssignment(a)} onClick={() => setSelectedAssignment(a)}
key={a.id} key={a.id}
allowDownload allowDownload
reload={reloadAssignments} reload={reloadAssignments}
allowUnarchive allowUnarchive
allowExcelDownload
/> />
))} ))}
</div> </div>
@@ -297,7 +317,15 @@ export default function TeacherDashboard({user}: Props) {
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)} value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
color="purple" color="purple"
/> />
<IconCard Icon={BsPeople} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} /> {checkAccess(user, ["teacher", "developer"], permissions, "viewGroup") && (
<IconCard
Icon={BsPeople}
label="Groups"
value={groups.filter((x) => x.admin === user.id).length}
color="purple"
onClick={() => setPage("groups")}
/>
)}
<div <div
onClick={() => setPage("assignments")} onClick={() => setPage("assignments")}
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 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 w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">

View File

@@ -9,10 +9,23 @@ import clsx from "clsx";
import Link from "next/link"; 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,
BsClipboardFill,
BsEyeFill,
BsHeadphones,
BsMegaphone,
BsPen,
BsShareFill,
} from "react-icons/bs";
import {LevelScore} from "@/constants/ielts"; import {LevelScore} from "@/constants/ielts";
import {getLevelScore} from "@/utils/score"; import {getLevelScore} from "@/utils/score";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
import Modal from "@/components/Modal";
import { UserSolution } from "@/interfaces/exam";
import ai_usage from "@/utils/ai.detection";
interface Score { interface Score {
module: Module; module: Module;
@@ -25,13 +38,21 @@ interface Props {
user: User; user: User;
modules: Module[]; modules: Module[];
scores: Score[]; scores: Score[];
information: {
timeSpent?: number;
inactivity?: number;
};
solutions: UserSolution[];
isLoading: boolean; isLoading: boolean;
onViewResults: (moduleIndex?: number) => void; onViewResults: (moduleIndex?: number) => void;
} }
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) { export default function Finish({user, scores, modules, information, solutions, isLoading, onViewResults}: Props) {
const [selectedModule, setSelectedModule] = useState(modules[0]); const [selectedModule, setSelectedModule] = useState(modules[0]);
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!); const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
const aiUsage = Math.round(ai_usage(solutions) * 100);
const exams = useExamStore((state) => state.exams); const exams = useExamStore((state) => state.exams);
@@ -62,7 +83,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
const getTotalExercises = () => { const getTotalExercises = () => {
const exam = exams.find((x) => x.module === selectedModule)!; const exam = exams.find((x) => x.module === selectedModule)!;
if (exam.module === "reading" || exam.module === "listening") { if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
return exam.parts.flatMap((x) => x.exercises).length; return exam.parts.flatMap((x) => x.exercises).length;
} }
@@ -86,6 +107,21 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
return ( return (
<> <>
<Modal title="Extra Information" isOpen={isExtraInformationOpen} onClose={() => setIsExtraInformationOpen(false)}>
<div className="flex flex-col gap-2 mt-4">
{!!information.timeSpent && (
<span>
<b>Time Spent:</b> {Math.floor(information.timeSpent / 60)} minute(s)
</span>
)}
{!!information.inactivity && (
<span>
<b>Inactivity:</b> {Math.floor(information.inactivity / 60)} minute(s)
</span>
)}
</div>
</Modal>
<div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8"> <div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8">
<ModuleTitle <ModuleTitle
module={selectedModule} module={selectedModule}
@@ -94,7 +130,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer} minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
disableTimer disableTimer
/> />
<div className="flex gap-4 self-start"> <div className="flex gap-4 self-start w-full">
{modules.includes("reading") && ( {modules.includes("reading") && (
<div <div
onClick={() => setSelectedModule("reading")} onClick={() => setSelectedModule("reading")}
@@ -118,6 +154,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div> </div>
)} )}
{modules.includes("writing") && ( {modules.includes("writing") && (
<div className="flex w-full justify-between items-center">
<div <div
onClick={() => setSelectedModule("writing")} onClick={() => setSelectedModule("writing")}
className={clsx( className={clsx(
@@ -127,6 +164,18 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<BsPen className="h-6 w-6" /> <BsPen className="h-6 w-6" />
<span className="font-semibold">Writing</span> <span className="font-semibold">Writing</span>
</div> </div>
{aiUsage >= 50 && user.type !== "student" && (
<div className={clsx(
"flex items-center justify-center border px-3 h-full rounded",
{
'bg-orange-100 border-orange-400 text-orange-700': aiUsage < 80,
'bg-red-100 border-red-400 text-red-700': aiUsage >= 80,
}
)}>
<span className="text-xs">AI Usage</span>
</div>
)}
</div>
)} )}
{modules.includes("speaking") && ( {modules.includes("speaking") && (
<div <div
@@ -247,6 +296,16 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</button> </button>
<span>Review {capitalize(selectedModule)}</span> <span>Review {capitalize(selectedModule)}</span>
</div> </div>
{(!!information.inactivity || !!information.timeSpent) && (
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button
onClick={() => setIsExtraInformationOpen(true)}
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
<BsClipboardFill className="h-7 w-7 text-white" />
</button>
<span>Extra Information</span>
</div>
)}
</div> </div>
<Link href="/" className="w-full max-w-[200px] self-end"> <Link href="/" className="w-full max-w-[200px] self-end">

View File

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

View File

@@ -0,0 +1,37 @@
import Button from "@/components/Low/Button";
import { Module } from "@/interfaces";
import { LevelPart, UserSolution } from "@/interfaces/exam";
import { ReactNode } from "react";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
interface Props {
partIndex: number;
part: LevelPart // for now
onNext: () => void;
}
const PartDivider: React.FC<Props> = ({ partIndex, part, onNext }) => {
const moduleIcon: { [key in Module]: ReactNode } = {
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
level: <BsClipboard className="text-white w-6 h-6" />,
};
return (
<div className="flex flex-col w-3/6 h-fit border bg-white rounded-3xl p-12 gap-8">
{/** only level for now */}
<div className="flex flex-row gap-4 items-center"><div className="w-12 h-12 bg-ielts-level flex items-center justify-center rounded-lg">{moduleIcon["level"]}</div><p className="text-3xl">{`Part ${partIndex + 1}`}</p></div>
{part.intro!.split('\\n\\n').map((x, index) => <p key={`line-${index}`} className="text-2xl text-clip">{x}</p>)}
<div className="flex items-center justify-center mt-4">
<Button color="purple" onClick={() => onNext()} className="max-w-[200px] self-end w-full text-2xl">
{partIndex === 0 ? `Start now`: `Start Part ${partIndex + 1}`}
</Button>
</div>
</div>
)
}
export default PartDivider;

View File

@@ -0,0 +1,146 @@
import { LevelPart } from "@/interfaces/exam";
import { useEffect, useRef } from "react";
interface Props {
part: LevelPart,
contextWord: string | undefined,
setContextWordLine: React.Dispatch<React.SetStateAction<number | undefined>>
}
const TextComponent: React.FC<Props> = ({part, contextWord, setContextWordLine}) => {
const textRef = useRef<HTMLDivElement>(null);
const calculateLineNumbers = () => {
if (textRef.current) {
const computedStyle = window.getComputedStyle(textRef.current);
const containerWidth = textRef.current.clientWidth;
const offscreenElement = document.createElement('div');
offscreenElement.style.position = 'absolute';
offscreenElement.style.top = '-9999px';
offscreenElement.style.left = '-9999px';
offscreenElement.style.width = `${containerWidth}px`;
offscreenElement.style.font = computedStyle.font;
offscreenElement.style.lineHeight = computedStyle.lineHeight;
offscreenElement.style.whiteSpace = 'pre-wrap';
offscreenElement.style.wordWrap = 'break-word';
offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign;
const paragraphs = part.context!.split('\n\n');
let currentLine = 1;
let contextWordLine: number | null = null;
const paragraphLineStarts: number[] = [];
paragraphs.forEach((paragraph, pIndex) => {
const p = document.createElement('p');
p.style.margin = '0';
p.style.padding = '0';
paragraph.split(/(\s+)/).forEach((word: string) => {
const span = document.createElement('span');
span.textContent = word;
p.appendChild(span);
});
offscreenElement.appendChild(p);
if (pIndex < paragraphs.length - 1) {
const gap = document.createElement('div');
gap.style.height = '16px'; // gap-4
offscreenElement.appendChild(gap);
}
});
document.body.appendChild(offscreenElement);
let currentLineTop: number | undefined;
const elements = offscreenElement.querySelectorAll('p, div');
elements.forEach((element) => {
if (element.tagName === 'P') {
const spans = element.querySelectorAll<HTMLSpanElement>('span');
paragraphLineStarts.push(currentLine);
spans.forEach(span => {
const rect = span.getBoundingClientRect();
const top = rect.top;
if (currentLineTop === undefined || top > currentLineTop) {
if (currentLineTop !== undefined) {
currentLine++;
}
currentLineTop = top;
}
if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) {
contextWordLine = currentLine;
}
});
} else if (element.tagName === 'DIV') { // Gap
currentLine++;
currentLineTop = undefined;
}
});
if (contextWordLine) {
setContextWordLine(contextWordLine);
}
document.body.removeChild(offscreenElement);
}
};
useEffect(() => {
calculateLineNumbers();
const resizeObserver = new ResizeObserver(() => {
calculateLineNumbers();
});
if (textRef.current) {
// eslint-disable-next-line react-hooks/exhaustive-deps
resizeObserver.observe(textRef.current);
}
return () => {
if (textRef.current) {
// eslint-disable-next-line react-hooks/exhaustive-deps
resizeObserver.unobserve(textRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [part.context, contextWord]);
/*if (typeof part.showContextLines === "undefined") {
return (
<div className="flex flex-col gap-2 w-full">
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{!!part.context &&
part.context
.split(/\n|(\\n)/g)
.filter((x) => x && x.length > 0 && x !== "\\n")
.map((line, index) => (
<Fragment key={index}>
<p key={index}>{line}</p>
</Fragment>
))}
</div>
);
}*/
return (
<div className="flex flex-col gap-2 w-full">
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
<div className="flex mt-2">
<div ref={textRef} className="h-fit ml-2 flex flex-col gap-4">
{part.context!.split('\n\n').map((line, index) => {
return <p key={`line-${index}`}><span className="mr-6">{index + 1}</span>{line}</p>
})}
</div>
</div>
</div>
);
}
export default TextComponent;

442
src/exams/Level/index.tsx Normal file
View File

@@ -0,0 +1,442 @@
import QuestionsModal from "@/components/QuestionsModal";
import { renderExercise } from "@/components/Exercises";
import Button from "@/components/Low/Button";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import { renderSolution } from "@/components/Solutions";
import { Module } from "@/interfaces";
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap, UserSolution } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import { countExercises } from "@/utils/moduleUtils";
import clsx from "clsx";
import { use, useEffect, useState } from "react";
import TextComponent from "./TextComponent";
import PartDivider from "./PartDivider";
import Timer from "@/components/Medium/Timer";
import { Stat } from "@/interfaces/user";
interface Props {
exam: LevelExam;
showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
editing?: boolean;
partDividers?: boolean;
}
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every(
word => word && typeof word === 'object' && 'id' in word && 'options' in word
);
}
export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) {
const levelBgColor = "bg-ielts-level-light";
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
const [showQuestionsModal, setShowQuestionsModal] = useState(false);
const { setBgColor } = useExamStore((state) => state);
const { userSolutions, setUserSolutions } = useExamStore((state) => state);
const { hasExamEnded, setHasExamEnded } = useExamStore((state) => state);
const { partIndex, setPartIndex } = useExamStore((state) => state);
const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state);
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps])
const [currentExercise, setCurrentExercise] = useState<Exercise>();
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
const [contextWord, setContextWord] = useState<string | undefined>(undefined);
const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined);
useEffect(() => {
if (showSolutions && exerciseIndex && exam.shuffle && userSolutions[exerciseIndex].shuffleMaps) {
setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[])
}
}, [showSolutions, exerciseIndex, setShuffleMaps, userSolutions, exam.shuffle])
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex(exerciseIndex + 1);
}
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const getExercise = () => {
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
if (!exercise) return undefined;
exercise = {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
if (exam.shuffle && exercise.type === "multipleChoice" && !showSolutions) {
console.log("Shuffling MC ");
const exerciseShuffles = userSolutions[exerciseIndex].shuffleMaps;
if (exerciseShuffles && exerciseShuffles.length == 0) {
const newShuffleMaps: ShuffleMap[] = [];
exercise.questions = exercise.questions.map(question => {
const options = [...question.options];
let shuffledOptions = [...options].sort(() => Math.random() - 0.5);
const newOptions = options.map((option, index) => ({
id: option.id,
text: shuffledOptions[index].text
}));
const optionMapping = options.reduce<{ [key: string]: string }>((acc, originalOption) => {
const shuffledPosition = newOptions.find(newOpt => newOpt.text === originalOption.text)?.id;
if (shuffledPosition) {
acc[shuffledPosition] = originalOption.id;
}
return acc;
}, {});
newShuffleMaps.push({ id: question.id, map: optionMapping });
return { ...question, options: newOptions };
});
setShuffleMaps(newShuffleMaps);
} else {
console.log("retrieving MC shuffles");
exercise.questions = exercise.questions.map(question => {
const questionShuffleMap = shuffleMaps.find(map => map.id === question.id);
if (questionShuffleMap) {
const newOptions = question.options.map(option => ({
id: option.id,
text: question.options.find(o => questionShuffleMap.map[o.id] === option.id)?.text || option.text
}));
return { ...question, options: newOptions };
}
return question;
});
}
} else if (exam.shuffle && exercise.type === "fillBlanks" && typeCheckWordsMC(exercise.words) && !showSolutions) {
if (shuffleMaps.length === 0 && !showSolutions) {
const newShuffleMaps: ShuffleMap[] = [];
console.log("Shuffling Words");
exercise.words = exercise.words.map(word => {
if ('options' in word) {
const options = { ...word.options };
const originalKeys = Object.keys(options);
const shuffledKeys = [...originalKeys].sort(() => Math.random() - 0.5);
const newOptions = shuffledKeys.reduce((acc, key, index) => {
acc[key as keyof typeof options] = options[originalKeys[index] as keyof typeof options];
return acc;
}, {} as { [key in keyof typeof options]: string });
const optionMapping = originalKeys.reduce((acc, key, index) => {
acc[key as keyof typeof options] = shuffledKeys[index];
return acc;
}, {} as { [key in keyof typeof options]: string });
newShuffleMaps.push({ id: word.id, map: optionMapping });
return { ...word, options: newOptions };
}
return word;
});
setShuffleMaps(newShuffleMaps);
} else {
console.log("Retrieving Words shuffle");
exercise.words = exercise.words.map(word => {
if ('options' in word) {
const shuffleMap = shuffleMaps.find(map => map.id === word.id);
if (shuffleMap) {
const options = { ...word.options };
const shuffledOptions = Object.keys(options).reduce((acc, key) => {
const shuffledKey = shuffleMap.map[key as keyof typeof options];
acc[shuffledKey as keyof typeof options] = options[key as keyof typeof options];
return acc;
}, {} as { [key in keyof typeof options]: string });
return { ...word, options: shuffledOptions };
}
}
return word;
});
}
}
console.log(exercise);
return exercise;
};
useEffect(() => {
if (exerciseIndex !== -1) {
setCurrentExercise(getExercise());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex, exerciseIndex, shuffleMaps, exam.parts[partIndex].context]);
useEffect(() => {
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
if (exerciseIndex !== -1 && currentExercise && currentExercise.type === "multipleChoice" && currentExercise.questions[storeQuestionIndex].prompt) {
const match = currentExercise.questions[storeQuestionIndex].prompt.match(regex);
if (match) {
const word = match[1];
const originalLineNumber = match[2];
if (word !== contextWord) {
setContextWord(word);
}
const updatedPrompt = currentExercise.questions[storeQuestionIndex].prompt.replace(
`in line ${originalLineNumber}`,
`in line ${contextWordLine || originalLineNumber}`
);
currentExercise.questions[storeQuestionIndex].prompt = updatedPrompt;
} else {
setContextWord(undefined);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentExercise, storeQuestionIndex]);
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]);
}
/*if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") {
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]);
}*/
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex(exerciseIndex + 1);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded && (showQuestionsModal || showSolutions)) {
if (!showSolutions && exam.parts[0].intro) {
setShowPartDivider(true);
setBgColor(levelBgColor);
}
setPartIndex(partIndex + 1);
setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0);
setStoreQuestionIndex(0);
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions) {
setShowQuestionsModal(true);
return;
}
if (
solution &&
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
(x) => x === 0,
) &&
!showSolutions &&
!editing &&
!hasExamEnded
) {
setShowQuestionsModal(true);
return;
}
setHasExamEnded(false);
if (solution) {
let stat = { ...solution, module: "level" as Module, exam: exam.id }
if (exam.shuffle) {
stat.shuffleMaps = shuffleMaps
}
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...stat }]);
} else {
onFinish(userSolutions);
}
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]);
}
setExerciseIndex(exerciseIndex - 1);
if (exerciseIndex - 1 === -1) {
setPartIndex(partIndex - 1);
const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex];
if (previousExercise.type === "multipleChoice") {
setStoreQuestionIndex(previousExercise.questions.length - 1)
}
const multipleChoiceQuestionsDone = [];
for (let i = 0; i < exam.parts.length; i++) {
if (i == (partIndex - 1)) break;
for (let j = 0; j < exam.parts[i].exercises.length; j++) {
const exercise = exam.parts[i].exercises[j];
if (exercise.type === "multipleChoice") {
multipleChoiceQuestionsDone.push({ id: exercise.id, amount: exercise.questions.length - 1 })
}
if (exercise.type === "fillBlanks") {
multipleChoiceQuestionsDone.push({ id: exercise.id, amount: exercise.words.length - 1 })
}
}
}
setMultipleChoicesDone(multipleChoiceQuestionsDone);
}
};
useEffect(() => {
if (exerciseIndex === -1) {
nextExercise()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex])
const calculateExerciseIndex = () => {
if (partIndex === 0) {
return (
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex //+ multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
}
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
return (
exercisesDone +
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
storeQuestionIndex
+ multipleChoicesDone.reduce((acc, curr) => { return acc + curr.amount }, 0)
);
};
const renderText = () => (
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}>
<>
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">
Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read.
</h4>
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div>
<TextComponent
part={exam.parts[partIndex]}
contextWord={contextWord}
setContextWordLine={setContextWordLine}
/>
</>
</div>
);
const partLabel = () => {
if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words))
return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${currentExercise.words[currentExercise.words.length - 1].id})\n\n${currentExercise.prompt}`
if (currentExercise?.type === "multipleChoice") {
return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})\n\n${currentExercise.prompt}`
}
if (typeof exam.parts[partIndex].context === "string") {
const nextExercise = exam.parts[partIndex].exercises[0] as MultipleChoiceExercise;
return `Part ${partIndex + 1} (Questions ${nextExercise.questions[0].id} - ${nextExercise.questions[nextExercise.questions.length - 1].id})\n\n${nextExercise.prompt}`
}
}
const modalKwargs = () => {
const allSolutionsCorrectLength = exam.parts[partIndex].exercises.every((exercise) => {
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
if (exercise.type === "multipleChoice") {
return userSolution?.solutions.length === exercise.questions.length;
}
if (exercise.type === "fillBlanks") {
return userSolution?.solutions.length === exercise.words.length;
}
return false;
});
return {
blankQuestions: !allSolutionsCorrectLength,
finishingWhat: "part",
onClose: partIndex !== exam.parts.length - 1 ? (
function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
) : function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); onFinish(userSolutions); } else { setShowQuestionsModal(false) } }
}
}
return (
<>
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
<QuestionsModal isOpen={showQuestionsModal} {...modalKwargs()} />
{
!(partIndex === 0 && storeQuestionIndex === 0 && showPartDivider) &&
<Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} />
}
{exam.parts[0].intro && showPartDivider ? <PartDivider part={exam.parts[partIndex]} partIndex={partIndex} onNext={() => { setShowPartDivider(false); setBgColor("bg-white") }} /> : (
<>
<ModuleTitle
partLabel={partLabel()}
minTimer={exam.minTimer}
exerciseIndex={calculateExerciseIndex()}
module="level"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions || editing}
showTimer={typeof exam.parts[0].intro === "undefined"}
/>
<div
className={clsx(
"mb-20 w-full",
partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4",
)}>
{partIndex > -1 && !!exam.parts[partIndex].context && renderText()}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
!editing &&
currentExercise &&
renderExercise(currentExercise, exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
(showSolutions || editing) &&
currentExercise &&
renderSolution(currentExercise, nextExercise, previousExercise)}
</div>
{/*exerciseIndex === -1 && partIndex > 0 && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => {
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex(partIndex - 1);
}}
className="max-w-[200px] w-full"
disabled={
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
typeof exam.parts[0].intro === "string" && storeQuestionIndex === 0}
>
Back
</Button>
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)*/}
{exerciseIndex === -1 && partIndex === 0 && (
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Start now
</Button>
)}
</>
)}
</div>
</>
);
}

View File

@@ -5,7 +5,7 @@ import {renderSolution} from "@/components/Solutions";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import AudioPlayer from "@/components/Low/AudioPlayer"; import AudioPlayer from "@/components/Low/AudioPlayer";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/BlankQuestionsModal"; import BlankQuestionsModal from "@/components/QuestionsModal";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {countExercises} from "@/utils/moduleUtils"; import {countExercises} from "@/utils/moduleUtils";
@@ -19,8 +19,6 @@ const INSTRUCTIONS_AUDIO_SRC =
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82"; "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
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 [timesListened, setTimesListened] = useState(0); const [timesListened, setTimesListened] = useState(0);
const [showBlankModal, setShowBlankModal] = useState(false); const [showBlankModal, setShowBlankModal] = useState(false);
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]); const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
@@ -64,10 +62,6 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
} }
}, [hasExamEnded, exerciseIndex, setExerciseIndex]); }, [hasExamEnded, exerciseIndex, setExerciseIndex]);
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
const confirmFinishModule = (keepGoing?: boolean) => { const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) { if (!keepGoing) {
setShowBlankModal(false); setShowBlankModal(false);
@@ -220,14 +214,14 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)} renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{/* Solution renderer */} {/* Solution renderer */}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)} renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
</div> </div>
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && ( {exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (

View File

@@ -15,7 +15,7 @@ import ProgressBar from "@/components/Low/ProgressBar";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import {Divider} from "primereact/divider"; import {Divider} from "primereact/divider";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/BlankQuestionsModal"; import BlankQuestionsModal from "@/components/QuestionsModal";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams"; import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils"; import {countExercises} from "@/utils/moduleUtils";
@@ -89,7 +89,7 @@ function TextComponent({part, exerciseType}: {part: ReadingPart; exerciseType: s
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" /> <div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{part.text.content {part.text.content
.split(/\n|(\\n)/g) .split(/\n|(\\n)/g)
.filter((x) => x && x.length > 0) .filter((x) => x && x.length > 0 && x !== "\\n")
.map((line, index) => ( .map((line, index) => (
<Fragment key={index}> <Fragment key={index}>
{exerciseType === "matchSentences" && ( {exerciseType === "matchSentences" && (
@@ -106,8 +106,6 @@ function TextComponent({part, exerciseType}: {part: ReadingPart; exerciseType: 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 [showTextModal, setShowTextModal] = useState(false); const [showTextModal, setShowTextModal] = useState(false);
const [showBlankModal, setShowBlankModal] = useState(false); const [showBlankModal, setShowBlankModal] = useState(false);
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]); const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
@@ -155,10 +153,6 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
}; };
}, []); }, []);
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex(exerciseIndex + 1); setExerciseIndex(exerciseIndex + 1);
@@ -314,13 +308,13 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)} renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)} renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
</div> </div>
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && ( {exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
<Button <Button

View File

@@ -1,442 +1,310 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { useState } from "react"; import {useState} from "react";
import { Module } from "@/interfaces"; import {Module} from "@/interfaces";
import clsx from "clsx"; import clsx from "clsx";
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import { import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
BsArrowRepeat, import {totalExamsByModule} from "@/utils/stats";
BsBook,
BsCheck,
BsCheckCircle,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
import { totalExamsByModule } from "@/utils/stats";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import { calculateAverageLevel } from "@/utils/score"; import {calculateAverageLevel} from "@/utils/score";
import { sortByModuleName } from "@/utils/moduleUtils"; import {sortByModuleName} from "@/utils/moduleUtils";
import { capitalize } from "lodash"; import {capitalize} from "lodash";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import { Variant } from "@/interfaces/exam"; import {Variant} from "@/interfaces/exam";
import useSessions, { Session } from "@/hooks/useSessions"; import useSessions, {Session} from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard"; import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import moment from "moment"; import moment from "moment";
interface Props { interface Props {
user: User; user: User;
page: "exercises" | "exams"; page: "exercises" | "exams";
onStart: ( onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
modules: Module[], disableSelection?: boolean;
avoidRepeated: boolean,
variant: Variant,
) => void;
disableSelection?: boolean;
} }
export default function Selection({ export default function Selection({user, page, onStart, disableSelection = false}: Props) {
user, const [selectedModules, setSelectedModules] = useState<Module[]>([]);
page, const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
onStart, const [variant, setVariant] = useState<Variant>("full");
disableSelection = false,
}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const [variant, setVariant] = useState<Variant>("full");
const { stats } = useStats(user?.id); const {stats} = useStats(user?.id);
const { sessions, isLoading, reload } = useSessions(user.id); const {sessions, isLoading, reload} = useSessions(user.id);
const state = useExamStore((state) => state); const state = useExamStore((state) => state);
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module); const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
prev.includes(module) ? modules : [...modules, module], };
);
};
const loadSession = async (session: Session) => { const loadSession = async (session: Session) => {
state.setSelectedModules(session.selectedModules); state.setSelectedModules(session.selectedModules);
state.setExam(session.exam); state.setExam(session.exam);
state.setExams(session.exams); state.setExams(session.exams);
state.setSessionId(session.sessionId); state.setSessionId(session.sessionId);
state.setAssignment(session.assignment); state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex); state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex); state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex); state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent); state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions); state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false); state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex); state.setQuestionIndex(session.questionIndex);
}; };
return ( return (
<> <>
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16"> <div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
{user && ( {user && (
<ProfileSummary <ProfileSummary
user={user} user={user}
items={[ items={[
{ {
icon: ( icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" /> label: "Reading",
), value: totalExamsByModule(stats, "reading"),
label: "Reading", tooltip: "The amount of reading exams performed.",
value: totalExamsByModule(stats, "reading"), },
tooltip: "The amount of reading exams performed.", {
}, icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
{ label: "Listening",
icon: ( value: totalExamsByModule(stats, "listening"),
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" /> tooltip: "The amount of listening exams performed.",
), },
label: "Listening", {
value: totalExamsByModule(stats, "listening"), icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
tooltip: "The amount of listening exams performed.", label: "Writing",
}, value: totalExamsByModule(stats, "writing"),
{ tooltip: "The amount of writing exams performed.",
icon: ( },
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" /> {
), icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
label: "Writing", label: "Speaking",
value: totalExamsByModule(stats, "writing"), value: totalExamsByModule(stats, "speaking"),
tooltip: "The amount of writing exams performed.", tooltip: "The amount of speaking exams performed.",
}, },
{ {
icon: ( icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" /> label: "Level",
), value: totalExamsByModule(stats, "level"),
label: "Speaking", tooltip: "The amount of level exams performed.",
value: totalExamsByModule(stats, "speaking"), },
tooltip: "The amount of speaking exams performed.", ]}
}, />
{ )}
icon: (
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
),
label: "Level",
value: totalExamsByModule(stats, "level"),
tooltip: "The amount of level exams performed.",
},
]}
/>
)}
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<span className="text-lg font-bold">About {capitalize(page)}</span> <span className="text-lg font-bold">About {capitalize(page)}</span>
<span className="text-mti-gray-taupe"> <span className="text-mti-gray-taupe">
{page === "exercises" && ( {page === "exercises" && (
<> <>
In the realm of language acquisition, practice makes perfect, In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full
and our exercises are the key to unlocking your full potential. potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar
Dive into a world of interactive and engaging exercises that drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully
cater to diverse learning styles. From grammar drills that build designed to make learning English both enjoyable and effective. Whether you&apos;re looking to reinforce specific
a strong foundation to vocabulary challenges that broaden your skills or embark on a holistic language journey, our exercises are your companions in the pursuit of excellence.
lexicon, our exercises are carefully designed to make learning Embrace the joy of learning as you navigate through a variety of activities that cater to every facet of language
English both enjoyable and effective. Whether you&apos;re acquisition. Your linguistic adventure starts here!
looking to reinforce specific skills or embark on a holistic </>
language journey, our exercises are your companions in the )}
pursuit of excellence. Embrace the joy of learning as you {page === "exams" && (
navigate through a variety of activities that cater to every <>
facet of language acquisition. Your linguistic adventure starts Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and
here! enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate
</> your abilities. Whether you&apos;re a beginner or a seasoned learner, our exams cater to all levels, providing a
)} comprehensive evaluation of your reading, writing, speaking, and listening skills. Prepare to embark on a journey of
{page === "exams" && ( self-discovery and language mastery as you navigate through our thoughtfully curated exams. Your success is not just a
<> destination; it&apos;s a testament to your dedication and our commitment to empowering you with the English language.
Welcome to the heart of success on your English language </>
journey! Our exams are crafted with precision to assess and )}
enhance your language skills. Each test is a passport to your </span>
linguistic prowess, designed to challenge and elevate your </section>
abilities. Whether you&apos;re a beginner or a seasoned learner,
our exams cater to all levels, providing a comprehensive
evaluation of your reading, writing, speaking, and listening
skills. Prepare to embark on a journey of self-discovery and
language mastery as you navigate through our thoughtfully
curated exams. Your success is not just a destination; it&apos;s
a testament to your dedication and our commitment to empowering
you with the English language.
</>
)}
</span>
</section>
{sessions.length > 0 && ( {sessions.length > 0 && (
<section className="flex flex-col gap-3 md:gap-3"> <section className="flex flex-col gap-3 md:gap-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div <div
onClick={reload} onClick={reload}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out" className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
> <span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
<span className="text-mti-black text-lg font-bold"> <BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
Unfinished Sessions </div>
</span> </div>
<BsArrowRepeat <span className="text-mti-gray-taupe flex gap-8 overflow-x-auto pb-2">
className={clsx("text-xl", isLoading && "animate-spin")} {sessions
/> .sort((a, b) => moment(b.date).diff(moment(a.date)))
</div> .map((session) => (
</div> <SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} />
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> ))}
{sessions </span>
.sort((a, b) => moment(b.date).diff(moment(a.date))) </section>
.map((session) => ( )}
<SessionCard
session={session}
key={session.sessionId}
reload={reload}
loadSession={loadSession}
/>
))}
</span>
</section>
)}
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8"> <section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
<div <div
onClick={ onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
!disableSelection && !selectedModules.includes("level") className={clsx(
? () => toggleModule("reading") "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
: undefined selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
} )}>
className={clsx( <div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", <BsBook className="h-7 w-7 text-white" />
selectedModules.includes("reading") || disableSelection </div>
? "border-mti-purple-light" <span className="font-semibold">Reading:</span>
: "border-mti-gray-platinum", <p className="text-left text-xs">
)} Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
> </p>
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> {!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
<BsBook className="h-7 w-7 text-white" /> <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
</div> )}
<span className="font-semibold">Reading:</span> {(selectedModules.includes("reading") || disableSelection) && (
<p className="text-left text-xs"> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
Expand your vocabulary, improve your reading comprehension and )}
improve your ability to interpret texts in English. {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
</p> </div>
{!selectedModules.includes("reading") && <div
!selectedModules.includes("level") && onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
!disableSelection && ( className={clsx(
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
)} selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
{(selectedModules.includes("reading") || disableSelection) && ( )}>
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
)} <BsHeadphones className="h-7 w-7 text-white" />
{selectedModules.includes("level") && ( </div>
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" /> <span className="font-semibold">Listening:</span>
)} <p className="text-left text-xs">
</div> Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
<div </p>
onClick={ {!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
!disableSelection && !selectedModules.includes("level") <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
? () => toggleModule("listening") )}
: undefined {(selectedModules.includes("listening") || disableSelection) && (
} <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
className={clsx( )}
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
selectedModules.includes("listening") || disableSelection </div>
? "border-mti-purple-light" <div
: "border-mti-gray-platinum", onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
)} className={clsx(
> "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
<BsHeadphones className="h-7 w-7 text-white" /> )}>
</div> <div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<span className="font-semibold">Listening:</span> <BsPen className="h-7 w-7 text-white" />
<p className="text-left text-xs"> </div>
Improve your ability to follow conversations in English and your <span className="font-semibold">Writing:</span>
ability to understand different accents and intonations. <p className="text-left text-xs">
</p> Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
{!selectedModules.includes("listening") && </p>
!selectedModules.includes("level") && {!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
!disableSelection && ( <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> )}
)} {(selectedModules.includes("writing") || disableSelection) && (
{(selectedModules.includes("listening") || disableSelection) && ( <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> )}
)} {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
{selectedModules.includes("level") && ( </div>
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" /> <div
)} onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
</div> className={clsx(
<div "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
onClick={ selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
!disableSelection && !selectedModules.includes("level") )}>
? () => toggleModule("writing") <div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
: undefined <BsMegaphone className="h-7 w-7 text-white" />
} </div>
className={clsx( <span className="font-semibold">Speaking:</span>
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", <p className="text-left text-xs">
selectedModules.includes("writing") || disableSelection You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings.
? "border-mti-purple-light" </p>
: "border-mti-gray-platinum", {!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
)} <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
> )}
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> {(selectedModules.includes("speaking") || disableSelection) && (
<BsPen className="h-7 w-7 text-white" /> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
</div> )}
<span className="font-semibold">Writing:</span> {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
<p className="text-left text-xs"> </div>
Allow you to practice writing in a variety of formats, from simple {!disableSelection && (
paragraphs to complex essays. <div
</p> onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
{!selectedModules.includes("writing") && className={clsx(
!selectedModules.includes("level") && "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
!disableSelection && ( selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> )}>
)} <div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
{(selectedModules.includes("writing") || disableSelection) && ( <BsClipboard className="h-7 w-7 text-white" />
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> </div>
)} <span className="font-semibold">Level:</span>
{selectedModules.includes("level") && ( <p className="text-left text-xs">You&apos;ll be able to test your english level with multiple choice questions.</p>
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" /> {!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && (
)} <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
</div> )}
<div {(selectedModules.includes("level") || disableSelection) && (
onClick={ <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
!disableSelection && !selectedModules.includes("level") )}
? () => toggleModule("speaking") {!selectedModules.includes("level") && selectedModules.length > 0 && (
: undefined <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
} )}
className={clsx( </div>
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", )}
selectedModules.includes("speaking") || disableSelection </section>
? "border-mti-purple-light" <div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
: "border-mti-gray-platinum", <div className="flex w-full flex-col items-center gap-3">
)} <div
> className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
<BsMegaphone className="h-7 w-7 text-white" /> <input type="checkbox" className="hidden" />
</div> <div
<span className="font-semibold">Speaking:</span> className={clsx(
<p className="text-left text-xs"> "border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
You&apos;ll have access to interactive dialogs, pronunciation "transition duration-300 ease-in-out",
exercises and speech recordings. avoidRepeatedExams && "!bg-mti-purple-light ",
</p> )}>
{!selectedModules.includes("speaking") && <BsCheck color="white" className="h-full w-full" />
!selectedModules.includes("level") && </div>
!disableSelection && ( <span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> Avoid Repeated Questions
)} </span>
{(selectedModules.includes("speaking") || disableSelection) && ( </div>
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <div
)} className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
{selectedModules.includes("level") && ( onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" /> <input type="checkbox" className="hidden" />
)} <div
</div> className={clsx(
{!disableSelection && ( "border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
<div "transition duration-300 ease-in-out",
onClick={ variant === "full" && "!bg-mti-purple-light ",
selectedModules.length === 0 || )}>
selectedModules.includes("level") <BsCheck color="white" className="h-full w-full" />
? () => toggleModule("level") </div>
: undefined <span>Full length exams</span>
} </div>
className={clsx( </div>
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", <div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
selectedModules.includes("level") || disableSelection <Button color="purple" className="w-full max-w-xs px-12 md:hidden" disabled>
? "border-mti-purple-light" Start Exam
: "border-mti-gray-platinum", </Button>
)} </div>
> <Button
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> onClick={() =>
<BsClipboard className="h-7 w-7 text-white" /> onStart(
</div> !disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
<span className="font-semibold">Level:</span> avoidRepeatedExams,
<p className="text-left text-xs"> variant,
You&apos;ll be able to test your english level with multiple )
choice questions. }
</p> color="purple"
{!selectedModules.includes("level") && className="-md:hidden w-full max-w-xs px-12 md:self-end"
selectedModules.length === 0 && disabled={selectedModules.length === 0 && !disableSelection}>
!disableSelection && ( Start Exam
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> </Button>
)} </div>
{(selectedModules.includes("level") || disableSelection) && ( </div>
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> </>
)} );
{!selectedModules.includes("level") &&
selectedModules.length > 0 && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
)}
</section>
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
<div className="flex w-full flex-col items-center gap-3">
<div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
onClick={() => setAvoidRepeatedExams((prev) => !prev)}
>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
avoidRepeatedExams && "!bg-mti-purple-light ",
)}
>
<BsCheck color="white" className="h-full w-full" />
</div>
<span
className="tooltip"
data-tip="If possible, the platform will choose exams not yet done."
>
Avoid Repeated Questions
</span>
</div>
<div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
// onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
>
<input type="checkbox" className="hidden" disabled />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
variant === "full" && "!bg-mti-purple-light ",
)}
>
<BsCheck color="white" className="h-full w-full" />
</div>
<span>Full length exams</span>
</div>
</div>
<div
className="tooltip w-full"
data-tip={`Your screen size is too small to do ${page}`}
>
<Button
color="purple"
className="w-full max-w-xs px-12 md:hidden"
disabled
>
Start Exam
</Button>
</div>
<Button
onClick={() =>
onStart(
!disableSelection
? selectedModules.sort(sortByModuleName)
: ["reading", "listening", "writing", "speaking"],
avoidRepeatedExams,
variant,
)
}
color="purple"
className="-md:hidden w-full max-w-xs px-12 md:self-end"
disabled={selectedModules.length === 0 && !disableSelection}
>
Start Exam
</Button>
</div>
</div>
</>
);
} }

View File

@@ -2,7 +2,7 @@ import {renderExercise} from "@/components/Exercises";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions"; import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles"; import {infoButtonStyle} from "@/constants/buttonStyles";
import {UserSolution, SpeakingExam} from "@/interfaces/exam"; import {UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams"; import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils"; import {countExercises} from "@/utils/moduleUtils";
@@ -20,25 +20,26 @@ 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 [speakingPromptsDone, setSpeakingPromptsDone] = useState<{id: string; amount: number}[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const {userSolutions, setUserSolutions} = useExamStore((state) => state); const {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state); const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
setCurrentQuestionIndex(0);
}, [questionIndex]);
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
} }
setQuestionIndex((prev) => prev + currentQuestionIndex);
if (questionIndex > 0) {
const exercise = getExercise();
setSpeakingPromptsDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: questionIndex}]);
}
setQuestionIndex(0);
if (exerciseIndex + 1 < exam.exercises.length) { if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex(exerciseIndex + 1); setExerciseIndex(exerciseIndex + 1);
@@ -71,8 +72,9 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
const exercise = exam.exercises[exerciseIndex]; const exercise = exam.exercises[exerciseIndex];
return { return {
...exercise, ...exercise,
variant: exerciseIndex < 2 && exercise.type === "interactiveSpeaking" ? "initial" : undefined,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
}; } as SpeakingExercise | InteractiveSpeakingExercise;
}; };
return ( return (
@@ -81,7 +83,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 + questionIndex + currentQuestionIndex} exerciseIndex={exerciseIndex + 1 + questionIndex + speakingPromptsDone.reduce((acc, curr) => acc + curr.amount, 0)}
module="speaking" module="speaking"
totalExercises={countExercises(exam.exercises)} totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions} disableTimer={showSolutions}
@@ -89,11 +91,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(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)} renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length && exerciseIndex < exam.exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)} renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
</div> </div>
</> </>
); );

View File

@@ -0,0 +1,235 @@
/* 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 TestReportFooter from "./test.report.footer";
import { StyleSheet } from "@react-pdf/renderer";
const customStyles = StyleSheet.create({
testDetails: {
display: "flex",
gap: 4,
},
testDetailsContainer: {
display: "flex",
gap: 16,
},
table: {
width: "100%",
},
tableRow: {
flexDirection: "row",
},
tableCol70: {
width: "70%", // First column width (50%)
borderStyle: "solid",
borderWidth: 1,
borderColor: "#000",
// padding: 5,
},
tableCol25: {
width: "16.67%", // Remaining four columns each get 1/6 of the total width (50% / 3 = 16.67%)
borderStyle: "solid",
borderWidth: 1,
borderColor: "#000",
padding: 5,
},
tableCol20: {
width: "20%", // Width for each of the 5 sub-columns (50% / 5 = 20%)
borderStyle: "solid",
borderWidth: 1,
borderColor: "#000",
padding: 5,
},
tableCol10: {
width: "10%", // Width for each of the 5 sub-columns (50% / 5 = 20%)
borderStyle: "solid",
borderWidth: 1,
borderColor: "#000",
padding: 5,
},
tableCellHeader: {
fontSize: 12,
textAlign: "center",
},
tableCellHeaderColor: {
backgroundColor: "#d3d3d3",
},
tableCell: {
fontSize: 10,
textAlign: "center",
},
});
interface Props {
date: string;
name: string;
email: string;
id: string;
gender?: string;
passportId: string;
corporateName: string;
downloadDate: string;
userId: string;
uniqueExercises: { name: string; result: string }[];
timeSpent: string;
score: string;
}
const LevelTestReport = ({
date,
name,
email,
id,
gender,
passportId,
corporateName,
downloadDate,
userId,
uniqueExercises,
timeSpent,
score,
}: Props) => {
const defaultTextStyle = [styles.textFont, { fontSize: 8 }];
return (
<Document>
<Page style={styles.body} orientation="landscape">
<Text style={[styles.textFont, styles.textBold, { fontSize: 11 }]}>
Corporate Name: {corporateName}
</Text>
<View style={styles.textMargin}>
<Text style={defaultTextStyle}>
Report Download date: {downloadDate}
</Text>
</View>
<Text style={[styles.textFont, styles.textBold, { fontSize: 11 }]}>
Test Information: {id}
</Text>
<View style={styles.textMargin}>
<Text style={defaultTextStyle}>Date of Test: {date}</Text>
<Text style={defaultTextStyle}>Candidates name: {name}</Text>
<Text style={defaultTextStyle}>Email: {email}</Text>
<Text style={defaultTextStyle}>National ID: {passportId}</Text>
<Text style={defaultTextStyle}>Gender: {gender}</Text>
<Text style={defaultTextStyle}>Candidate ID: {userId}</Text>
</View>
<View style={customStyles.table}>
{/* Header Row */}
<View style={customStyles.tableRow}>
<View
style={[
customStyles.tableCol70,
customStyles.tableCellHeaderColor,
]}
>
<Text style={[customStyles.tableCellHeader, { padding: 5 }]}>
Test sections
</Text>
</View>
<View
style={[
customStyles.tableCol20,
customStyles.tableCellHeaderColor,
]}
>
<Text style={customStyles.tableCellHeader}>Time spent</Text>
</View>
<View
style={[
customStyles.tableCol10,
customStyles.tableCellHeaderColor,
]}
>
<Text style={customStyles.tableCellHeader}>Score</Text>
</View>
</View>
<View style={customStyles.tableRow}>
<View style={customStyles.tableCol70}>
<View style={customStyles.tableRow}>
{uniqueExercises.map((exercise, index) => (
<View
style={[
customStyles.tableCol20,
index !== uniqueExercises.length - 1
? { borderWidth: 0, borderRightWidth: 1 }
: { borderWidth: 0 },
]}
key={index}
>
<Text style={customStyles.tableCell}>Part {index + 1}</Text>
</View>
))}
</View>
</View>
<View style={customStyles.tableCol20}>
<Text style={customStyles.tableCell}></Text>
</View>
<View style={customStyles.tableCol10}>
<Text style={customStyles.tableCell}></Text>
</View>
</View>
<View style={customStyles.tableRow}>
<View style={customStyles.tableCol70}>
<View style={customStyles.tableRow}>
{uniqueExercises.map((exercise, index) => (
<View
style={[
customStyles.tableCol20,
index !== uniqueExercises.length - 1
? { borderWidth: 0, borderRightWidth: 1 }
: { borderWidth: 0 },
]}
key={index}
>
<Text style={customStyles.tableCell}>{exercise.name}</Text>
</View>
))}
</View>
</View>
<View style={customStyles.tableCol20}>
<Text style={customStyles.tableCell}></Text>
</View>
<View style={customStyles.tableCol10}>
<Text style={customStyles.tableCell}></Text>
</View>
</View>
<View style={customStyles.tableRow}>
<View style={customStyles.tableCol70}>
<View style={customStyles.tableRow}>
{uniqueExercises.map((exercise, index) => (
<View
style={[
customStyles.tableCol20,
index !== uniqueExercises.length - 1
? { borderWidth: 0, borderRightWidth: 1 }
: { borderWidth: 0 },
]}
key={index}
>
<Text style={customStyles.tableCell}>
{exercise.result}
</Text>
</View>
))}
</View>
</View>
<View style={customStyles.tableCol20}>
<Text style={customStyles.tableCell}>{timeSpent}</Text>
</View>
<View style={customStyles.tableCol10}>
<Text style={customStyles.tableCell}>{score}</Text>
</View>
</View>
</View>
<TestReportFooter userId={userId} />
</Page>
</Document>
);
};
export default LevelTestReport;

View File

@@ -1,8 +1,9 @@
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"; import {getStorage} from "firebase/storage";
const serviceAccount = require("@/constants/serviceAccountKey.json"); const stagingServiceAccount = require("@/constants/staging.json");
const platformServiceAccount = require("@/constants/platform.json");
const firebaseConfig = { const firebaseConfig = {
apiKey: process.env.FIREBASE_PUBLIC_API_KEY || "", apiKey: process.env.FIREBASE_PUBLIC_API_KEY || "",
@@ -16,8 +17,8 @@ const firebaseConfig = {
export const app = initializeApp(firebaseConfig, Math.random().toString()); export const app = initializeApp(firebaseConfig, Math.random().toString());
export const adminApp = admin.initializeApp( export const adminApp = admin.initializeApp(
{ {
credential: admin.cert(serviceAccount), credential: admin.cert(process.env.ENVIRONMENT === "platform" ? platformServiceAccount : stagingServiceAccount),
}, },
Math.random().toString(), Math.random().toString(),
); );
export const storage = getStorage(app); export const storage = getStorage(app);

View File

@@ -0,0 +1,34 @@
import { Assignment } from "@/interfaces/results";
import axios from "axios";
import { useEffect, useState } from "react";
export default function useAssignmentsCorporates({
corporates,
}: {
corporates: string[];
}) {
const [assignments, setAssignments] = useState<Assignment[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
if (corporates.length === 0) {
setAssignments([]);
return;
}
setIsLoading(true);
axios
.get<Assignment[]>(
`/api/assignments/corporate?ids=${corporates.join(",")}`
)
.then(async (response) => {
setAssignments(response.data);
})
.finally(() => setIsLoading(false));
};
useEffect(getData, [corporates]);
return { assignments, isLoading, isError, reload: getData };
}

View File

@@ -2,7 +2,7 @@ import {Assignment} from "@/interfaces/results";
import axios from "axios"; import axios from "axios";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
export default function useAssignments({assigner, assignees}: {assigner?: string; assignees?: string}) { export default function useAssignments({assigner, assignees, corporate}: {assigner?: string; assignees?: string; corporate?: string}) {
const [assignments, setAssignments] = useState<Assignment[]>([]); const [assignments, setAssignments] = useState<Assignment[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
@@ -10,12 +10,13 @@ export default function useAssignments({assigner, assignees}: {assigner?: string
const getData = () => { const getData = () => {
setIsLoading(true); setIsLoading(true);
axios axios
.get<Assignment[]>("/api/assignments") .get<Assignment[]>(!corporate ? "/api/assignments" : `/api/assignments/corporate/${corporate}`)
.then((response) => { .then(async (response) => {
if (assigner) { if (assigner) {
setAssignments(response.data.filter((a) => a.assigner === assigner)); setAssignments(response.data.filter((a) => a.assigner === assigner));
return; return;
} }
if (assignees) { if (assignees) {
setAssignments(response.data.filter((a) => a.assignees.filter((x) => assignees.includes(x)).length > 0)); setAssignments(response.data.filter((a) => a.assignees.filter((x) => assignees.includes(x)).length > 0));
return; return;
@@ -26,7 +27,7 @@ export default function useAssignments({assigner, assignees}: {assigner?: string
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
useEffect(getData, [assignees, assigner]); useEffect(getData, [assignees, assigner, corporate]);
return {assignments, isLoading, isError, reload: getData}; return {assignments, isLoading, isError, reload: getData};
} }

View File

@@ -2,25 +2,40 @@ import {Group, User} from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
export default function useGroups(admin?: string) { interface Props {
admin?: string;
userType?: string;
adminAdmins?: string;
}
export default function useGroups({admin, userType, adminAdmins}: Props) {
const [groups, setGroups] = useState<Group[]>([]); const [groups, setGroups] = useState<Group[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const isMasterType = userType?.startsWith("master");
const getData = () => { const getData = () => {
setIsLoading(true); setIsLoading(true);
axios
.get<Group[]>("/api/groups")
.then((response) => {
const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || "");
const filteredGroups = admin ? response.data.filter(filter) : response.data; const url = admin && !adminAdmins ? `/api/groups?admin=${admin}` : "/api/groups";
axios
.get<Group[]>(url)
.then((response) => {
if (isMasterType) return setGroups(response.data);
const filterByAdmins = !!adminAdmins
? [adminAdmins, ...response.data.filter((g) => g.participants.includes(adminAdmins)).flatMap((g) => g.admin)]
: [admin];
const adminFilter = (g: Group) => filterByAdmins.includes(g.admin) || g.participants.includes(admin || "");
const filteredGroups = !!admin || !!adminAdmins ? response.data.filter(adminFilter) : response.data;
return setGroups(admin ? filteredGroups.map((g) => ({...g, disableEditing: g.disableEditing || g.admin !== admin})) : filteredGroups); return setGroups(admin ? filteredGroups.map((g) => ({...g, disableEditing: g.disableEditing || g.admin !== admin})) : filteredGroups);
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
useEffect(getData, [admin]); useEffect(getData, [admin, adminAdmins, isMasterType]);
return {groups, isLoading, isError, reload: getData}; return {groups, isLoading, isError, reload: getData};
} }

View File

@@ -27,6 +27,10 @@ export function useListSearch<T>(fields: string[][], rows: T[]) {
if (typeof value === "string") { if (typeof value === "string") {
return value.toLowerCase().includes(searchText); return value.toLowerCase().includes(searchText);
} }
if (typeof value === "number") {
return (value as Number).toString().includes(searchText);
}
}); });
}); });
}, [fields, rows, text]); }, [fields, rows, text]);

View File

@@ -1,15 +1,16 @@
import React from "react"; import React from "react";
import axios from "axios"; import axios from "axios";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { BsFilePdf } from "react-icons/bs"; import { BsFilePdf, BsFileExcel} from "react-icons/bs";
type DownloadingPdf = { type DownloadingPdf = {
[key: string]: boolean; [key: string]: boolean;
}; };
type PdfEndpoint = "stats" | "assignments"; type PdfEndpoint = "stats" | "assignments";
type FileType = "pdf" | "excel";
export const usePDFDownload = (endpoint: PdfEndpoint) => { export const usePDFDownload = (endpoint: PdfEndpoint, file: FileType = 'pdf') => {
const [downloadingPdf, setDownloadingPdf] = React.useState<DownloadingPdf>( const [downloadingPdf, setDownloadingPdf] = React.useState<DownloadingPdf>(
{} {}
); );
@@ -17,7 +18,7 @@ export const usePDFDownload = (endpoint: PdfEndpoint) => {
const triggerDownload = async (id: string) => { const triggerDownload = async (id: string) => {
try { try {
setDownloadingPdf((prev) => ({ ...prev, [id]: true })); setDownloadingPdf((prev) => ({ ...prev, [id]: true }));
const res = await axios.post(`/api/${endpoint}/${id}/export`); const res = await axios.post(`/api/${endpoint}/${id}/export/${file}`);
toast.success("Report ready!"); toast.success("Report ready!");
const link = document.createElement("a"); const link = document.createElement("a");
link.href = res.data; link.href = res.data;
@@ -45,8 +46,11 @@ export const usePDFDownload = (endpoint: PdfEndpoint) => {
<span className={`${loadingClasses} loading loading-infinity w-6`} /> <span className={`${loadingClasses} loading loading-infinity w-6`} />
); );
} }
const Icon = file === "excel" ? BsFileExcel : BsFilePdf;
return ( return (
<BsFilePdf <Icon
className={`${downloadClasses} text-2xl cursor-pointer`} className={`${downloadClasses} text-2xl cursor-pointer`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();

View File

@@ -0,0 +1,28 @@
import {Exam} from "@/interfaces/exam";
import {Permission, PermissionType} from "@/interfaces/permissions";
import {ExamState} from "@/stores/examStore";
import axios from "axios";
import {useEffect, useState} from "react";
export default function usePermissions(user: string) {
const [permissions, setPermissions] = useState<PermissionType[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Permission[]>(`/api/permissions`)
.then((response) => {
const permissionTypes = response.data
.filter((x) => !x.users.includes(user))
.reduce((acc, curr) => [...acc, curr.type], [] as PermissionType[]);
setPermissions(permissionTypes);
})
.finally(() => setIsLoading(false));
};
useEffect(getData, [user]);
return {permissions, isLoading, isError, reload: getData};
}

View File

@@ -1,19 +1,25 @@
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 = "full" | "partial"; export type Variant = "full" | "partial";
export type InstructorGender = "male" | "female" | "varied"; export type InstructorGender = "male" | "female" | "varied";
export type Difficulty = "easy" | "medium" | "hard"; export type Difficulty = "easy" | "medium" | "hard";
export interface ReadingExam { interface ExamBase {
parts: ReadingPart[];
id: string; id: string;
module: "reading"; module: Module;
minTimer: number; minTimer: number;
type: "academic" | "general";
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty; difficulty?: Difficulty;
shuffle?: boolean;
createdBy?: string; // option as it has been added later
createdAt?: string; // option as it has been added later
}
export interface ReadingExam extends ExamBase {
module: "reading";
parts: ReadingPart[];
type: "academic" | "general";
} }
export interface ReadingPart { export interface ReadingPart {
@@ -24,24 +30,20 @@ export interface ReadingPart {
exercises: Exercise[]; exercises: Exercise[];
} }
export interface LevelExam { export interface LevelExam extends ExamBase {
module: "level"; module: "level";
id: string; parts: LevelPart[];
exercises: Exercise[];
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
difficulty?: Difficulty;
} }
export interface ListeningExam { export interface LevelPart {
context?: string;
intro?: string;
exercises: Exercise[];
}
export interface ListeningExam extends ExamBase {
parts: ListeningPart[]; parts: ListeningPart[];
id: string;
module: "listening"; module: "listening";
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
difficulty?: Difficulty;
} }
export interface ListeningPart { export interface ListeningPart {
@@ -65,16 +67,12 @@ export interface UserSolution {
}; };
exercise: string; exercise: string;
isDisabled?: boolean; isDisabled?: boolean;
shuffleMaps?: ShuffleMap[]
} }
export interface WritingExam { export interface WritingExam extends ExamBase {
module: "writing"; module: "writing";
id: string;
exercises: WritingExercise[]; exercises: WritingExercise[];
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
difficulty?: Difficulty;
} }
interface WordCounter { interface WordCounter {
@@ -82,15 +80,10 @@ interface WordCounter {
limit: number; limit: number;
} }
export interface SpeakingExam { export interface SpeakingExam extends ExamBase {
id: string;
module: "speaking"; module: "speaking";
exercises: (SpeakingExercise | InteractiveSpeakingExercise)[]; exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
instructorGender: InstructorGender; instructorGender: InstructorGender;
difficulty?: Difficulty;
} }
export type Exercise = export type Exercise =
@@ -106,21 +99,24 @@ export type Exercise =
export interface Evaluation { export interface Evaluation {
comment: string; comment: string;
overall: number; overall: number;
task_response: {[key: string]: number}; task_response: { [key: string]: number | { grade: number; comment: string } };
misspelled_pairs?: {correction: string | null; misspelled: string}[]; misspelled_pairs?: { correction: string | null; misspelled: string }[];
} }
interface InteractiveSpeakingEvaluation extends Evaluation {
perfect_answer_1?: string; type InteractivePerfectAnswerKey = `perfect_answer_${number}`;
transcript_1?: string; type InteractiveTranscriptKey = `transcript_${number}`;
fixed_text_1?: string; type InteractiveFixedTextKey = `fixed_text_${number}`;
perfect_answer_2?: string;
transcript_2?: string; type InteractivePerfectAnswerType = { [key in InteractivePerfectAnswerKey]: { answer: string } };
fixed_text_2?: string; type InteractiveTranscriptType = { [key in InteractiveTranscriptKey]?: string };
perfect_answer_3?: string; type InteractiveFixedTextType = { [key in InteractiveFixedTextKey]?: string };
transcript_3?: string;
fixed_text_3?: string; interface InteractiveSpeakingEvaluation extends Evaluation,
} InteractivePerfectAnswerType,
InteractiveTranscriptType,
InteractiveFixedTextType { }
interface SpeakingEvaluation extends CommonEvaluation { interface SpeakingEvaluation extends CommonEvaluation {
perfect_answer_1?: string; perfect_answer_1?: string;
@@ -147,17 +143,36 @@ export interface WritingExercise {
userSolutions: { userSolutions: {
id: string; id: string;
solution: string; solution: string;
evaluation?: CommonEvaluation; evaluation?: WritingEvaluation;
}[]; }[];
topic?: string; topic?: string;
} }
export interface AIDetectionAttributes {
predicted_class: "ai" | "mixed" | "human";
confidence_category: "high" | "medium" | "low";
class_probabilities: {
ai: number;
human: number;
mixed: number;
};
sentences: {
sentence: string;
highlight_sentence_for_ai: boolean;
}[];
}
export interface WritingEvaluation extends CommonEvaluation {
ai_detection?: AIDetectionAttributes;
}
export interface SpeakingExercise { export interface SpeakingExercise {
id: string; id: string;
type: "speaking"; type: "speaking";
title: string; title: string;
text: string; text: string;
prompts: string[]; prompts: string[];
suffix?: string;
video_url: string; video_url: string;
userSolutions: { userSolutions: {
id: string; id: string;
@@ -171,23 +186,38 @@ export interface InteractiveSpeakingExercise {
id: string; id: string;
type: "interactiveSpeaking"; type: "interactiveSpeaking";
title: string; title: string;
first_title?: string;
second_title?: string;
text: string; text: string;
prompts: {text: string; video_url: string}[]; prompts: { text: string; video_url: string }[];
userSolutions: { userSolutions: {
id: string; id: string;
solution: {questionIndex: number; question: string; answer: string}[]; solution: { questionIndex: number; question: string; answer: string }[];
evaluation?: InteractiveSpeakingEvaluation; evaluation?: InteractiveSpeakingEvaluation;
}[]; }[];
topic?: string; topic?: string;
first_topic?: string;
second_topic?: string;
variant?: "initial" | "final";
}
export interface FillBlanksMCOption {
id: string;
options: {
A: string;
B: string;
C: string;
D: string;
}
} }
export interface FillBlanksExercise { export interface FillBlanksExercise {
prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it." prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it."
type: "fillBlanks"; type: "fillBlanks";
id: string; id: string;
words: string[]; // *EXAMPLE: ["preserve", "unaware"] words: (string | { letter: string; word: string } | FillBlanksMCOption)[]; // *EXAMPLE: ["preserve", "unaware"]
text: string; // *EXAMPLE: "They tried to {{1}} burning" text: string; // *EXAMPLE: "They tried to {{1}} burning"
allowRepetition: boolean; allowRepetition?: boolean;
solutions: { solutions: {
id: string; // *EXAMPLE: "1" id: string; // *EXAMPLE: "1"
solution: string; // *EXAMPLE: "preserve" solution: string; // *EXAMPLE: "preserve"
@@ -196,6 +226,7 @@ export interface FillBlanksExercise {
id: string; // *EXAMPLE: "1" id: string; // *EXAMPLE: "1"
solution: string; // *EXAMPLE: "preserve" solution: string; // *EXAMPLE: "preserve"
}[]; }[];
variant?: string;
} }
export interface TrueFalseExercise { export interface TrueFalseExercise {
@@ -203,7 +234,7 @@ export interface TrueFalseExercise {
id: string; id: string;
prompt: string; // *EXAMPLE: "Select the appropriate option." prompt: string; // *EXAMPLE: "Select the appropriate option."
questions: TrueFalseQuestion[]; questions: TrueFalseQuestion[];
userSolutions: {id: string; solution: "true" | "false" | "not_given"}[]; userSolutions: { id: string; solution: "true" | "false" | "not_given" }[];
} }
export interface TrueFalseQuestion { export interface TrueFalseQuestion {
@@ -232,7 +263,7 @@ export interface MatchSentencesExercise {
type: "matchSentences"; type: "matchSentences";
id: string; id: string;
prompt: string; prompt: string;
userSolutions: {question: string; option: string}[]; userSolutions: { question: string; option: string }[];
sentences: MatchSentenceExerciseSentence[]; sentences: MatchSentenceExerciseSentence[];
allowRepetition: boolean; allowRepetition: boolean;
options: MatchSentenceExerciseOption[]; options: MatchSentenceExerciseOption[];
@@ -255,7 +286,7 @@ export interface MultipleChoiceExercise {
id: string; id: string;
prompt: string; // *EXAMPLE: "Select the appropriate option." prompt: string; // *EXAMPLE: "Select the appropriate option."
questions: MultipleChoiceQuestion[]; questions: MultipleChoiceQuestion[];
userSolutions: {question: string; option: string}[]; userSolutions: { question: string; option: string }[];
} }
export interface MultipleChoiceQuestion { export interface MultipleChoiceQuestion {
@@ -268,4 +299,12 @@ export interface MultipleChoiceQuestion {
src?: string; // *EXAMPLE: "https://i.imgur.com/rEbrSqA.png" (only used if the variant is "image") src?: string; // *EXAMPLE: "https://i.imgur.com/rEbrSqA.png" (only used if the variant is "image")
text?: string; // *EXAMPLE: "wallet, pens and novel" (only used if the variant is "text") text?: string; // *EXAMPLE: "wallet, pens and novel" (only used if the variant is "text")
}[]; }[];
shuffleMap?: Record<string, string>;
}
export interface ShuffleMap {
id: string;
map: {
[key: string]: string;
}
} }

View File

@@ -1,55 +1,56 @@
export interface TokenSuccess { export interface TokenSuccess {
scope: string; scope: string;
access_token: string; access_token: string;
token_type: string; token_type: string;
app_id: string; app_id: string;
expires_in: number; expires_in: number;
nonce: string; nonce: string;
} }
export interface TokenError { export interface TokenError {
error: string; error: string;
error_description: string; error_description: string;
} }
export interface Package { export interface Package {
id: string; id: string;
currency: string; currency: string;
duration: number; duration: number;
duration_unit: DurationUnit; duration_unit: DurationUnit;
price: number; price: number;
} }
export interface Discount { export interface Discount {
id: string; id: string;
percentage: number; percentage: number;
domain: string; domain: string;
validUntil?: Date;
} }
export type DurationUnit = "weeks" | "days" | "months" | "years"; export type DurationUnit = "weeks" | "days" | "months" | "years";
export interface Payment { export interface Payment {
id: string; id: string;
corporate: string; corporate: string;
agent?: string; agent?: string;
agentCommission: number; agentCommission: number;
agentValue: number; agentValue: number;
currency: string; currency: string;
value: number; value: number;
isPaid: boolean; isPaid: boolean;
date: Date | string; date: Date | string;
corporateTransfer?: string; corporateTransfer?: string;
commissionTransfer?: string; commissionTransfer?: string;
} }
export interface PaypalPayment { export interface PaypalPayment {
orderId: string; orderId: string;
userId: string; userId: string;
status: string; status: string;
createdAt: Date; createdAt: Date;
value: number; value: number;
currency: string; currency: string;
subscriptionDuration: number; subscriptionDuration: number;
subscriptionDurationUnit: DurationUnit; subscriptionDurationUnit: DurationUnit;
subscriptionExpirationDate: Date; subscriptionExpirationDate: Date;
} }

View File

@@ -0,0 +1,89 @@
export interface PermissionTopic {
topic: string;
list: string[];
}
export const permissions = [
{
topic: "Manage Corporate",
list: [
"viewCorporate",
"editCorporate",
"deleteCorporate",
"createCodeCorporate",
],
},
{
topic: "Manage Admin",
list: ["viewAdmin", "editAdmin", "deleteAdmin", "createCodeAdmin"],
},
{
topic: "Manage Student",
list: ["viewStudent", "editStudent", "deleteStudent", "createCodeStudent"],
},
{
topic: "Manage Teacher",
list: ["viewTeacher", "editTeacher", "deleteTeacher", "createCodeTeacher"],
},
{
topic: "Manage Country Manager",
list: [
"viewCountryManager",
"editCountryManager",
"deleteCountryManager",
"createCodeCountryManager",
],
},
{
topic: "Manage Exams",
list: [
"createReadingExam",
"createListeningExam",
"createWritingExam",
"createSpeakingExam",
"createLevelExam",
],
},
{
topic: "View Pages",
list: [
"viewExams",
"viewExercises",
"viewRecords",
"viewStats",
"viewTickets",
"viewPaymentRecords",
],
},
{
topic: "Manage Group",
list: ["viewGroup", "editGroup", "deleteGroup", "createGroup"],
},
{
topic: "Manage Codes",
list: ["viewCodes", "deleteCodes", "createCodes"],
},
{
topic: "Others",
list: ["all"],
},
] as const;
const permissionsList = [
...new Set(
permissions.reduce(
(accm: string[], permission) => [...accm, ...permission.list],
[]
)
),
];
export type PermissionType =
(typeof permissionsList)[keyof typeof permissionsList];
export interface Permission {
id: string;
type: PermissionType;
topic: string;
users: string[];
}

View File

@@ -1,5 +1,6 @@
import { Module } from "."; import { Module } from ".";
import { InstructorGender } from "./exam"; import { InstructorGender, ShuffleMap } from "./exam";
import { PermissionType } from "./permissions";
export type User = export type User =
| StudentUser | StudentUser
@@ -7,7 +8,8 @@ export type User =
| CorporateUser | CorporateUser
| AgentUser | AgentUser
| AdminUser | AdminUser
| DeveloperUser; | DeveloperUser
| MasterCorporateUser;
export type UserStatus = "active" | "disabled" | "paymentDue"; export type UserStatus = "active" | "disabled" | "paymentDue";
export interface BasicUser { export interface BasicUser {
@@ -25,6 +27,8 @@ export interface BasicUser {
subscriptionExpirationDate?: null | Date; subscriptionExpirationDate?: null | Date;
registrationDate?: Date; registrationDate?: Date;
status: UserStatus; status: UserStatus;
permissions: PermissionType[];
lastLogin?: Date;
} }
export interface StudentUser extends BasicUser { export interface StudentUser extends BasicUser {
@@ -45,6 +49,12 @@ export interface CorporateUser extends BasicUser {
demographicInformation?: DemographicCorporateInformation; demographicInformation?: DemographicCorporateInformation;
} }
export interface MasterCorporateUser extends BasicUser {
type: "mastercorporate";
corporateInformation: CorporateInformation;
demographicInformation?: DemographicCorporateInformation;
}
export interface AgentUser extends BasicUser { export interface AgentUser extends BasicUser {
type: "agent"; type: "agent";
agentInformation: AgentInformation; agentInformation: AgentInformation;
@@ -131,6 +141,7 @@ export interface Stat {
solutions: any[]; solutions: any[];
type: string; type: string;
timeSpent?: number; timeSpent?: number;
inactivity?: number;
assignment?: string; assignment?: string;
score: { score: {
correct: number; correct: number;
@@ -138,6 +149,11 @@ export interface Stat {
missing: number; missing: number;
}; };
isDisabled?: boolean; isDisabled?: boolean;
shuffleMaps?: ShuffleMap[];
pdf?: {
path: string;
version: string;
};
} }
export interface Group { export interface Group {
@@ -166,7 +182,8 @@ export type Type =
| "corporate" | "corporate"
| "admin" | "admin"
| "developer" | "developer"
| "agent"; | "agent"
| "mastercorporate";
export const userTypes: Type[] = [ export const userTypes: Type[] = [
"student", "student",
"teacher", "teacher",
@@ -174,4 +191,5 @@ export const userTypes: Type[] = [
"admin", "admin",
"developer", "developer",
"agent", "agent",
"mastercorporate",
]; ];

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -16,16 +16,42 @@ import {useFilePicker} from "use-file-picker";
import readXlsxFile from "read-excel-file"; import readXlsxFile from "read-excel-file";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import {BsFileEarmarkEaselFill, BsQuestionCircleFill} from "react-icons/bs"; import {BsFileEarmarkEaselFill, BsQuestionCircleFill} from "react-icons/bs";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import {PermissionType} from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = { const USER_TYPE_PERMISSIONS: {
student: [], [key in Type]: {perm: PermissionType | undefined; list: Type[]};
teacher: [], } = {
agent: [], student: {
corporate: ["student", "teacher"], perm: "createCodeStudent",
admin: ["student", "teacher", "agent", "corporate", "admin"], list: [],
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"], },
teacher: {
perm: "createCodeTeacher",
list: [],
},
agent: {
perm: "createCodeCountryManager",
list: [],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
mastercorporate: {
perm: undefined,
list: ["student", "teacher", "corporate"],
},
admin: {
perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
},
developer: {
perm: undefined,
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
},
}; };
export default function BatchCodeGenerator({user}: {user: User}) { export default function BatchCodeGenerator({user}: {user: User}) {
@@ -39,6 +65,7 @@ export default function BatchCodeGenerator({user}: {user: User}) {
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const {users} = useUsers(); const {users} = useUsers();
const {permissions} = usePermissions(user?.id || "");
const {openFilePicker, filesContent, clear} = useFilePicker({ const {openFilePicker, filesContent, clear} = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
@@ -46,8 +73,6 @@ export default function BatchCodeGenerator({user}: {user: User}) {
readAs: "ArrayBuffer", readAs: "ArrayBuffer",
}); });
useEffect(() => console.log(expiryDate), [expiryDate]);
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]); }, [isExpiryDateEnabled]);
@@ -200,7 +225,7 @@ export default function BatchCodeGenerator({user}: {user: User}) {
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}> <Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"} {filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button> </Button>
{user && (user.type === "developer" || user.type === "admin" || user.type === "corporate") && ( {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<> <>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label> <label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
@@ -233,7 +258,10 @@ export default function BatchCodeGenerator({user}: {user: User}) {
onChange={(e) => setType(e.target.value as typeof user.type)} onChange={(e) => setType(e.target.value as typeof user.type)}
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"> className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
{Object.keys(USER_TYPE_LABELS) {Object.keys(USER_TYPE_LABELS)
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type)) .filter((x) => {
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, getTypesOfUser(list), permissions, perm);
})
.map((type) => ( .map((type) => (
<option key={type} value={type}> <option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} {USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
@@ -241,9 +269,11 @@ export default function BatchCodeGenerator({user}: {user: User}) {
))} ))}
</select> </select>
)} )}
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}> {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
Generate & Send <Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
</Button> Generate & Send
</Button>
)}
</div> </div>
</> </>
); );

View File

@@ -0,0 +1,230 @@
import Button from "@/components/Low/Button";
import useUsers from "@/hooks/useUsers";
import {Type as UserType, User} from "@/interfaces/user";
import axios from "axios";
import {uniqBy} from "lodash";
import {useEffect, useState} from "react";
import {toast} from "react-toastify";
import {useFilePicker} from "use-file-picker";
import readXlsxFile from "read-excel-file";
import Modal from "@/components/Modal";
import {BsQuestionCircleFill} from "react-icons/bs";
import {PermissionType} from "@/interfaces/permissions";
import moment from "moment";
import {checkAccess} from "@/utils/permissions";
import Checkbox from "@/components/Low/Checkbox";
import ReactDatePicker from "react-datepicker";
import clsx from "clsx";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
type Type = Exclude<UserType, "admin" | "developer" | "agent" | "mastercorporate">;
const USER_TYPE_LABELS: {[key in Type]: string} = {
student: "Student",
teacher: "Teacher",
corporate: "Corporate",
};
const USER_TYPE_PERMISSIONS: {
[key in Type]: {perm: PermissionType | undefined; list: Type[]};
} = {
student: {
perm: "createCodeStudent",
list: [],
},
teacher: {
perm: "createCodeTeacher",
list: [],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
};
export default function BatchCreateUser({user}: {user: User}) {
const [infos, setInfos] = useState<
{
email: string;
name: string;
passport_id: string;
type: Type;
demographicInformation: {
country: string;
passport_id: string;
phone: string;
};
}[]
>([]);
const [isLoading, setIsLoading] = useState(false);
const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false);
const {users} = useUsers();
const {openFilePicker, filesContent, clear} = useFilePicker({
accept: ".xlsx",
multiple: false,
readAs: "ArrayBuffer",
});
useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
useEffect(() => {
if (filesContent.length > 0) {
const file = filesContent[0];
readXlsxFile(file.content).then((rows) => {
try {
const information = uniqBy(
rows
.map((row) => {
const [firstName, lastName, country, passport_id, email, phone, group] = row as string[];
return EMAIL_REGEX.test(email.toString().trim())
? {
email: email.toString().trim().toLowerCase(),
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
type: type,
passport_id: passport_id?.toString().trim() || undefined,
groupName: group,
demographicInformation: {
country: country,
passport_id: passport_id?.toString().trim() || undefined,
phone,
},
}
: undefined;
})
.filter((x) => !!x) as typeof infos,
(x) => x.email,
);
if (information.length === 0) {
toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
);
return clear();
}
setInfos(information);
} catch {
toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
);
return clear();
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]);
const makeUsers = async () => {
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
if (!confirm(`You are about to add ${newUsers.length}, are you sure you want to continue?`)) return;
if (newUsers.length > 0) {
setIsLoading(true);
try {
for (const newUser of newUsers) await axios.post("/api/make_user", {...newUser, type, expiryDate});
toast.success(`Successfully added ${newUsers.length} user(s)!`);
} catch {
toast.error("Something went wrong, please try again later!");
} finally {
setIsLoading(false);
setInfos([]);
clear();
}
}
};
return (
<>
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
<div className="mt-4 flex flex-col gap-2">
<span>Please upload an Excel file with the following format:</span>
<table className="w-full">
<thead>
<tr>
<th className="border border-neutral-200 px-2 py-1">First Name</th>
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
<th className="border border-neutral-200 px-2 py-1">Country</th>
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
<th className="border border-neutral-200 px-2 py-1">Phone Number</th>
<th className="border border-neutral-200 px-2 py-1">Group Name</th>
</tr>
</thead>
</table>
<span className="mt-4">
<b>Notes:</b>
<ul>
<li>- All incorrect e-mails will be ignored;</li>
<li>- All already registered e-mails will be ignored;</li>
<li>- You may have a header row with the format above, however, it is not necessary;</li>
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
</ul>
</span>
</div>
</Modal>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<div className="flex items-end justify-between">
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
<div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
<BsQuestionCircleFill />
</div>
</div>
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button>
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as Type)}
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
{Object.keys(USER_TYPE_LABELS).map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
<Button className="my-auto" onClick={makeUsers} disabled={infos.length === 0}>
Create
</Button>
</div>
</>
);
}

View File

@@ -11,14 +11,41 @@ import {useEffect, useState} from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import {checkAccess} from "@/utils/permissions";
import {PermissionType} from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions";
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = { const USER_TYPE_PERMISSIONS: {
student: [], [key in Type]: {perm: PermissionType | undefined; list: Type[]};
teacher: [], } = {
agent: [], student: {
corporate: ["student", "teacher"], perm: "createCodeStudent",
admin: ["student", "teacher", "agent", "corporate", "admin"], list: [],
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"], },
teacher: {
perm: "createCodeTeacher",
list: [],
},
agent: {
perm: "createCodeCountryManager",
list: [],
},
corporate: {
perm: "createCodeCorporate",
list: ["student", "teacher"],
},
mastercorporate: {
perm: undefined,
list: ["student", "teacher", "corporate"],
},
admin: {
perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
},
developer: {
perm: undefined,
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
},
}; };
export default function CodeGenerator({user}: {user: User}) { export default function CodeGenerator({user}: {user: User}) {
@@ -28,6 +55,7 @@ export default function CodeGenerator({user}: {user: User}) {
); );
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student"); const [type, setType] = useState<Type>("student");
const {permissions} = usePermissions(user?.id || "");
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
@@ -41,7 +69,9 @@ export default function CodeGenerator({user}: {user: User}) {
.post("/api/code", {type, codes: [code], expiryDate}) .post("/api/code", {type, codes: [code], expiryDate})
.then(({data, status}) => { .then(({data, status}) => {
if (data.ok) { if (data.ok) {
toast.success(`Successfully generated a ${capitalize(type)} code!`, {toastId: "success"}); toast.success(`Successfully generated a ${capitalize(type)} code!`, {
toastId: "success",
});
setGeneratedCode(code); setGeneratedCode(code);
return; return;
} }
@@ -56,7 +86,9 @@ export default function CodeGenerator({user}: {user: User}) {
return; return;
} }
toast.error(`Something went wrong, please try again later!`, {toastId: "error"}); toast.error(`Something went wrong, please try again later!`, {
toastId: "error",
});
}); });
}; };
@@ -69,7 +101,10 @@ export default function CodeGenerator({user}: {user: User}) {
onChange={(e) => setType(e.target.value as typeof user.type)} onChange={(e) => setType(e.target.value as typeof user.type)}
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"> className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
{Object.keys(USER_TYPE_LABELS) {Object.keys(USER_TYPE_LABELS)
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type)) .filter((x) => {
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, list, permissions, perm);
})
.map((type) => ( .map((type) => (
<option key={type} value={type}> <option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} {USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
@@ -77,7 +112,7 @@ export default function CodeGenerator({user}: {user: User}) {
))} ))}
</select> </select>
)} )}
{user && (user.type === "developer" || user.type === "admin" || user.type === "corporate") && ( {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<> <>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label> <label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
@@ -103,9 +138,11 @@ export default function CodeGenerator({user}: {user: User}) {
)} )}
</> </>
)} )}
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}> {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
Generate <Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}>
</Button> Generate
</Button>
)}
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label> <label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
<div <div
className={clsx( className={clsx(

View File

@@ -4,319 +4,306 @@ import Select from "@/components/Low/Select";
import useCodes from "@/hooks/useCodes"; import useCodes from "@/hooks/useCodes";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { Code, User } from "@/interfaces/user"; import {Code, User} from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user"; import {USER_TYPE_LABELS} from "@/resources/user";
import { import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import moment from "moment"; import moment from "moment";
import { useEffect, useState } from "react"; import {useEffect, useState, useMemo} from "react";
import { BsTrash } from "react-icons/bs"; import {BsTrash} from "react-icons/bs";
import { toast } from "react-toastify"; import {toast} from "react-toastify";
import ReactDatePicker from "react-datepicker";
import clsx from "clsx";
import {checkAccess} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
const columnHelper = createColumnHelper<Code>(); const columnHelper = createColumnHelper<Code>();
const CreatorCell = ({ id, users }: { id: string; users: User[] }) => { const CreatorCell = ({id, users}: {id: string; users: User[]}) => {
const [creatorUser, setCreatorUser] = useState<User>(); const [creatorUser, setCreatorUser] = useState<User>();
useEffect(() => { useEffect(() => {
setCreatorUser(users.find((x) => x.id === id)); setCreatorUser(users.find((x) => x.id === id));
}, [id, users]); }, [id, users]);
return ( return (
<> <>
{(creatorUser?.type === "corporate" {(creatorUser?.type === "corporate" ? creatorUser?.corporateInformation?.companyInformation?.name : creatorUser?.name || "N/A") || "N/A"}{" "}
? creatorUser?.corporateInformation?.companyInformation?.name {creatorUser && `(${USER_TYPE_LABELS[creatorUser.type]})`}
: creatorUser?.name || "N/A") || "N/A"}{" "} </>
{creatorUser && `(${USER_TYPE_LABELS[creatorUser.type]})`} );
</>
);
}; };
export default function CodeList({ user }: { user: User }) { export default function CodeList({user}: {user: User}) {
const [selectedCodes, setSelectedCodes] = useState<string[]>([]); const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>( const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>(user?.type === "corporate" ? user : undefined);
user?.type === "corporate" ? user : undefined, const [filterAvailability, setFilterAvailability] = useState<"in-use" | "unused">();
);
const [filterAvailability, setFilterAvailability] = useState<
"in-use" | "unused"
>();
const [filteredCodes, setFilteredCodes] = useState<Code[]>([]); const {permissions} = usePermissions(user?.id || "");
const { users } = useUsers(); // const [filteredCodes, setFilteredCodes] = useState<Code[]>([]);
const { codes, reload } = useCodes(
user?.type === "corporate" ? user?.id : undefined,
);
useEffect(() => { const {users} = useUsers();
let result = [...codes]; const {codes, reload} = useCodes(user?.type === "corporate" ? user?.id : undefined);
if (filteredCorporate)
result = result.filter((x) => x.creator === filteredCorporate.id);
if (filterAvailability)
result = result.filter((x) =>
filterAvailability === "in-use" ? !!x.userId : !x.userId,
);
setFilteredCodes(result); const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate());
}, [codes, filteredCorporate, filterAvailability]); const [endDate, setEndDate] = useState<Date | null>(moment().endOf("day").toDate());
const filteredCodes = useMemo(() => {
return codes.filter((x) => {
// TODO: if the expiry date is missing, it does not make sense to filter by date
// so we need to find a way to handle this edge case
if (startDate && endDate && x.expiryDate) {
const date = moment(x.expiryDate);
if (date.isBefore(startDate) || date.isAfter(endDate)) {
return false;
}
}
if (filteredCorporate && x.creator !== filteredCorporate.id) return false;
if (filterAvailability) {
if (filterAvailability === "in-use" && !x.userId) return false;
if (filterAvailability === "unused" && x.userId) return false;
}
const toggleCode = (id: string) => { return true;
setSelectedCodes((prev) => });
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], }, [codes, startDate, endDate, filteredCorporate, filterAvailability]);
);
};
const toggleAllCodes = (checked: boolean) => { const toggleCode = (id: string) => {
if (checked) setSelectedCodes((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
return setSelectedCodes( };
filteredCodes.filter((x) => !x.userId).map((x) => x.code),
);
return setSelectedCodes([]); const toggleAllCodes = (checked: boolean) => {
}; if (checked) return setSelectedCodes(filteredCodes.filter((x) => !x.userId).map((x) => x.code));
const deleteCodes = async (codes: string[]) => { return setSelectedCodes([]);
if ( };
!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)
)
return;
const params = new URLSearchParams(); const deleteCodes = async (codes: string[]) => {
codes.forEach((code) => params.append("code", code)); if (!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)) return;
axios const params = new URLSearchParams();
.delete(`/api/code?${params.toString()}`) codes.forEach((code) => params.append("code", code));
.then(() => {
toast.success(`Deleted the codes!`);
setSelectedCodes([]);
})
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Code not found!");
return;
}
if (reason.response.status === 403) { axios
toast.error("You do not have permission to delete this code!"); .delete(`/api/code?${params.toString()}`)
return; .then(() => {
} toast.success(`Deleted the codes!`);
setSelectedCodes([]);
})
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Code not found!");
return;
}
toast.error("Something went wrong, please try again later."); if (reason.response.status === 403) {
}) toast.error("You do not have permission to delete this code!");
.finally(reload); return;
}; }
const deleteCode = async (code: Code) => { toast.error("Something went wrong, please try again later.");
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`)) })
return; .finally(reload);
};
axios const deleteCode = async (code: Code) => {
.delete(`/api/code/${code.code}`) if (!confirm(`Are you sure you want to delete this "${code.code}" code?`)) return;
.then(() => toast.success(`Deleted the "${code.code}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Code not found!");
return;
}
if (reason.response.status === 403) { axios
toast.error("You do not have permission to delete this code!"); .delete(`/api/code/${code.code}`)
return; .then(() => toast.success(`Deleted the "${code.code}" exam`))
} .catch((reason) => {
if (reason.response.status === 404) {
toast.error("Code not found!");
return;
}
toast.error("Something went wrong, please try again later."); if (reason.response.status === 403) {
}) toast.error("You do not have permission to delete this code!");
.finally(reload); return;
}; }
const defaultColumns = [ toast.error("Something went wrong, please try again later.");
columnHelper.accessor("code", { })
id: "code", .finally(reload);
header: () => ( };
<Checkbox
disabled={filteredCodes.filter((x) => !x.userId).length === 0}
isChecked={
selectedCodes.length ===
filteredCodes.filter((x) => !x.userId).length &&
filteredCodes.filter((x) => !x.userId).length > 0
}
onChange={(checked) => toggleAllCodes(checked)}
>
{""}
</Checkbox>
),
cell: (info) =>
!info.row.original.userId ? (
<Checkbox
isChecked={selectedCodes.includes(info.getValue())}
onChange={() => toggleCode(info.getValue())}
>
{""}
</Checkbox>
) : null,
}),
columnHelper.accessor("code", {
header: "Code",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("creationDate", {
header: "Creation Date",
cell: (info) =>
info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A",
}),
columnHelper.accessor("email", {
header: "Invited E-mail",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("creator", {
header: "Creator",
cell: (info) => <CreatorCell id={info.getValue()} users={users} />,
}),
columnHelper.accessor("userId", {
header: "Availability",
cell: (info) =>
info.getValue() ? (
<span className="flex gap-1 items-center text-mti-green">
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
</span>
) : (
<span className="flex gap-1 items-center text-mti-red">
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
</span>
),
}),
{
header: "",
id: "actions",
cell: ({ row }: { row: { original: Code } }) => {
return (
<div className="flex gap-4">
{!row.original.userId && (
<div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteCode(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
];
const table = useReactTable({ const allowedToDelete = checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "deleteCodes");
data: filteredCodes,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
return ( const defaultColumns = [
<> columnHelper.accessor("code", {
<div className="flex items-center justify-between pb-4 pt-1"> id: "codeCheckbox",
<div className="flex items-center gap-4"> header: () => (
<Select <Checkbox
className="!w-96 !py-1" disabled={filteredCodes.filter((x) => !x.userId).length === 0}
disabled={user?.type === "corporate"} isChecked={
isClearable selectedCodes.length === filteredCodes.filter((x) => !x.userId).length && filteredCodes.filter((x) => !x.userId).length > 0
placeholder="Corporate" }
value={ onChange={(checked) => toggleAllCodes(checked)}>
filteredCorporate {""}
? { </Checkbox>
label: `${ ),
filteredCorporate.type === "corporate" cell: (info) =>
? filteredCorporate.corporateInformation !info.row.original.userId ? (
?.companyInformation?.name || filteredCorporate.name <Checkbox isChecked={selectedCodes.includes(info.getValue())} onChange={() => toggleCode(info.getValue())}>
: filteredCorporate.name {""}
} (${USER_TYPE_LABELS[filteredCorporate.type]})`, </Checkbox>
value: filteredCorporate.id, ) : null,
} }),
: null columnHelper.accessor("code", {
} header: "Code",
options={users cell: (info) => info.getValue(),
.filter((x) => }),
["admin", "developer", "corporate"].includes(x.type), columnHelper.accessor("creationDate", {
) header: "Creation Date",
.map((x) => ({ cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A"),
label: `${x.type === "corporate" ? x.corporateInformation?.companyInformation?.name || x.name : x.name} (${ }),
USER_TYPE_LABELS[x.type] columnHelper.accessor("email", {
})`, header: "Invited E-mail",
value: x.id, cell: (info) => info.getValue() || "N/A",
user: x, }),
}))} columnHelper.accessor("creator", {
onChange={(value) => header: "Creator",
setFilteredCorporate( cell: (info) => <CreatorCell id={info.getValue()} users={users} />,
value ? users.find((x) => x.id === value?.value) : undefined, }),
) columnHelper.accessor("userId", {
} header: "Availability",
/> cell: (info) =>
<Select info.getValue() ? (
className="!w-96 !py-1" <span className="flex gap-1 items-center text-mti-green">
placeholder="Availability" <div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
isClearable </span>
options={[ ) : (
{ label: "In Use", value: "in-use" }, <span className="flex gap-1 items-center text-mti-red">
{ label: "Unused", value: "unused" }, <div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
]} </span>
onChange={(value) => ),
setFilterAvailability( }),
value ? (value.value as typeof filterAvailability) : undefined, {
) header: "",
} id: "actions",
/> cell: ({row}: {row: {original: Code}}) => {
</div> return (
<div className="flex gap-4 items-center"> <div className="flex gap-4">
<span>{selectedCodes.length} code(s) selected</span> {allowedToDelete && !row.original.userId && (
<Button <div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteCode(row.original)}>
disabled={selectedCodes.length === 0} <BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
variant="outline" </div>
color="red" )}
className="!py-1 px-10" </div>
onClick={() => deleteCodes(selectedCodes)} );
> },
Delete },
</Button> ];
</div>
</div> const table = useReactTable({
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full"> data: filteredCodes,
<thead> columns: defaultColumns,
{table.getHeaderGroups().map((headerGroup) => ( getCoreRowModel: getCoreRowModel(),
<tr key={headerGroup.id}> });
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}> return (
{header.isPlaceholder <>
? null <div className="flex items-center justify-between pb-4 pt-1">
: flexRender( <div className="flex items-center gap-4">
header.column.columnDef.header, <Select
header.getContext(), className="!w-96 !py-1"
)} disabled={user?.type === "corporate"}
</th> isClearable
))} placeholder="Corporate"
</tr> value={
))} filteredCorporate
</thead> ? {
<tbody className="px-2"> label: `${
{table.getRowModel().rows.map((row) => ( filteredCorporate.type === "corporate"
<tr ? filteredCorporate.corporateInformation?.companyInformation?.name || filteredCorporate.name
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" : filteredCorporate.name
key={row.id} } (${USER_TYPE_LABELS[filteredCorporate.type]})`,
> value: filteredCorporate.id,
{row.getVisibleCells().map((cell) => ( }
<td className="px-4 py-2" key={cell.id}> : null
{flexRender(cell.column.columnDef.cell, cell.getContext())} }
</td> options={users
))} .filter((x) => ["admin", "developer", "corporate"].includes(x.type))
</tr> .map((x) => ({
))} label: `${x.type === "corporate" ? x.corporateInformation?.companyInformation?.name || x.name : x.name} (${
</tbody> USER_TYPE_LABELS[x.type]
</table> })`,
</> value: x.id,
); user: x,
}))}
onChange={(value) => setFilteredCorporate(value ? users.find((x) => x.id === value?.value) : undefined)}
/>
<Select
className="!w-96 !py-1"
placeholder="Availability"
isClearable
options={[
{label: "In Use", value: "in-use"},
{label: "Unused", value: "unused"},
]}
onChange={(value) => setFilterAvailability(value ? (value.value as typeof filterAvailability) : undefined)}
/>
<ReactDatePicker
dateFormat="dd/MM/yyyy"
className="px-4 py-6 w-full text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
selected={startDate}
startDate={startDate}
endDate={endDate}
selectsRange
showMonthDropdown
filterDate={(date: Date) => moment(date).isSameOrBefore(moment(new Date()))}
onChange={([initialDate, finalDate]: [Date, Date]) => {
setStartDate(initialDate ?? moment("01/01/2023").toDate());
if (finalDate) {
// basicly selecting a final day works as if I'm selecting the first
// minute of that day. this way it covers the whole day
setEndDate(moment(finalDate).endOf("day").toDate());
return;
}
setEndDate(null);
}}
/>
</div>
{allowedToDelete && (
<div className="flex gap-4 items-center">
<span>{selectedCodes.length} code(s) selected</span>
<Button
disabled={selectedCodes.length === 0}
variant="outline"
color="red"
className="!py-1 px-10"
onClick={() => deleteCodes(selectedCodes)}>
Delete
</Button>
</div>
)}
</div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</>
);
} }

View File

@@ -7,336 +7,301 @@ import useCodes from "@/hooks/useCodes";
import useDiscounts from "@/hooks/useDiscounts"; import useDiscounts from "@/hooks/useDiscounts";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { Discount } from "@/interfaces/paypal"; import {Discount} from "@/interfaces/paypal";
import { Code, User } from "@/interfaces/user"; import {Code, User} from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user"; import {USER_TYPE_LABELS} from "@/resources/user";
import { import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import clsx from "clsx";
import moment from "moment"; import moment from "moment";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import { BsPencil, BsTrash } from "react-icons/bs"; import ReactDatePicker from "react-datepicker";
import { toast } from "react-toastify"; import {BsPencil, BsTrash} from "react-icons/bs";
import {toast} from "react-toastify";
const columnHelper = createColumnHelper<Discount>(); const columnHelper = createColumnHelper<Discount>();
const DiscountCreator = ({ const DiscountCreator = ({discount, onClose}: {discount?: Discount; onClose: () => void}) => {
discount, const [percentage, setPercentage] = useState(discount?.percentage);
onClose, const [domain, setDomain] = useState(discount?.domain);
}: { const [validUntil, setValidUntil] = useState(discount?.validUntil);
discount?: Discount;
onClose: () => void;
}) => {
const [percentage, setPercentage] = useState(discount?.percentage);
const [domain, setDomain] = useState(discount?.domain);
const submit = async () => { const submit = async () => {
const body = { percentage, domain }; const body = {percentage, domain, validUntil: validUntil?.toISOString() || undefined};
if (discount) { if (discount) {
return axios return axios
.patch(`/api/discounts/${discount.id}`, body) .patch(`/api/discounts/${discount.id}`, body)
.then(() => { .then(() => {
toast.success("Discount has been edited successfully!"); toast.success("Discount has been edited successfully!");
onClose(); onClose();
}) })
.catch(() => { .catch(() => {
toast.error("Something went wrong, please try again later!"); toast.error("Something went wrong, please try again later!");
}); });
} }
return axios return axios
.post(`/api/discounts`, body) .post(`/api/discounts`, body)
.then(() => { .then(() => {
toast.success("New discount has been created successfully!"); toast.success("New discount has been created successfully!");
onClose(); onClose();
}) })
.catch(() => { .catch(() => {
toast.error("Something went wrong, please try again later!"); toast.error("Something went wrong, please try again later!");
}); });
}; };
return ( return (
<div className="flex flex-col gap-8 py-8"> <div className="flex flex-col gap-8 py-8">
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="w-full grid grid-cols-1 gap-8">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">Domain *</label>
Domain * <div className="flex gap-4 items-center">
</label> <Input
<div className="flex gap-4 items-center"> defaultValue={domain}
<Input placeholder="encoach.com"
defaultValue={domain} name="domain"
placeholder="encoach.com" type="text"
name="domain" onChange={(e) => setDomain(e.replaceAll("@", ""))}
type="text" />
onChange={(e) => setDomain(e.replaceAll("@", ""))} </div>
/> </div>
</div> <div className="flex flex-col gap-3">
</div> <label className="font-normal text-base text-mti-gray-dim">Percentage (in %) *</label>
<div className="flex flex-col gap-3"> <div className="flex gap-4 items-center">
<label className="font-normal text-base text-mti-gray-dim"> <Input
Percentage (in %) * defaultValue={percentage}
</label> placeholder="20"
<div className="flex gap-4 items-center"> name="percentage"
<Input type="number"
defaultValue={percentage} onChange={(e) => setPercentage(parseFloat(e))}
placeholder="20" />
name="percentage" </div>
type="number" </div>
onChange={(e) => setPercentage(parseFloat(e))} <div className="flex flex-col gap-3 w-full">
/> <label className="font-normal text-base text-mti-gray-dim">Valid Until</label>
</div> <div className="flex gap-4 items-center w-full">
</div> <ReactDatePicker
</div> wrapperClassName="w-full z-[900]"
<div className="flex w-full justify-end items-center gap-8 mt-8"> calendarClassName="z-[900]"
<Button popperClassName="z-[900]"
variant="outline" isClearable
color="red" className={clsx(
className="w-full max-w-[200px]" "flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
onClick={onClose} "hover:border-mti-purple tooltip",
> "transition duration-300 ease-in-out",
Cancel )}
</Button> filterDate={(date) => moment(date).isAfter(new Date())}
<Button dateFormat="dd/MM/yyyy"
className="w-full max-w-[200px]" selected={validUntil}
onClick={submit} onChange={(date) => setValidUntil(date ? moment(date).endOf("day").toDate() : undefined)}
disabled={!percentage || !domain} />
> </div>
Submit </div>
</Button> </div>
</div> <div className="flex w-full justify-end items-center gap-8 mt-8">
</div> <Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
); Cancel
</Button>
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!percentage || !domain}>
Submit
</Button>
</div>
</div>
);
}; };
export default function DiscountList({ user }: { user: User }) { export default function DiscountList({user}: {user: User}) {
const [selectedDiscounts, setSelectedDiscounts] = useState<string[]>([]); const [selectedDiscounts, setSelectedDiscounts] = useState<string[]>([]);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [editingDiscount, setEditingDiscount] = useState<Discount>(); const [editingDiscount, setEditingDiscount] = useState<Discount>();
const [filteredDiscounts, setFilteredDiscounts] = useState<Discount[]>([]); const [filteredDiscounts, setFilteredDiscounts] = useState<Discount[]>([]);
const { users } = useUsers(); const {users} = useUsers();
const { discounts, reload } = useDiscounts(); const {discounts, reload} = useDiscounts();
useEffect(() => { useEffect(() => {
setFilteredDiscounts(discounts); setFilteredDiscounts(discounts);
}, [discounts]); }, [discounts]);
const toggleDiscount = (id: string) => { const toggleDiscount = (id: string) => {
setSelectedDiscounts((prev) => setSelectedDiscounts((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], };
);
};
const toggleAllDiscounts = (checked: boolean) => { const toggleAllDiscounts = (checked: boolean) => {
if (checked) if (checked) return setSelectedDiscounts(filteredDiscounts.map((x) => x.id));
return setSelectedDiscounts(filteredDiscounts.map((x) => x.id));
return setSelectedDiscounts([]); return setSelectedDiscounts([]);
}; };
const deleteDiscounts = async (discounts: string[]) => { const deleteDiscounts = async (discounts: string[]) => {
if ( if (!confirm(`Are you sure you want to delete these ${discounts.length} discount(s)?`)) return;
!confirm(
`Are you sure you want to delete these ${discounts.length} discount(s)?`,
)
)
return;
const params = new URLSearchParams(); const params = new URLSearchParams();
discounts.forEach((code) => params.append("discount", code)); discounts.forEach((code) => params.append("discount", code));
axios axios
.delete(`/api/discounts?${params.toString()}`) .delete(`/api/discounts?${params.toString()}`)
.then(() => toast.success(`Deleted the discount(s)!`)) .then(() => toast.success(`Deleted the discount(s)!`))
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 404) { if (reason.response.status === 404) {
toast.error("Discount not found!"); toast.error("Discount not found!");
return; return;
} }
if (reason.response.status === 403) { if (reason.response.status === 403) {
toast.error("You do not have permission to delete this discount!"); toast.error("You do not have permission to delete this discount!");
return; return;
} }
toast.error("Something went wrong, please try again later."); toast.error("Something went wrong, please try again later.");
}) })
.finally(reload); .finally(reload);
}; };
const deleteDiscount = async (discount: Discount) => { const deleteDiscount = async (discount: Discount) => {
if ( if (!confirm(`Are you sure you want to delete this "${discount.id}" discount?`)) return;
!confirm(
`Are you sure you want to delete this "${discount.id}" discount?`,
)
)
return;
axios axios
.delete(`/api/discounts/${discount.id}`) .delete(`/api/discounts/${discount.id}`)
.then(() => toast.success(`Deleted the "${discount.id}" discount`)) .then(() => toast.success(`Deleted the "${discount.id}" discount`))
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 404) { if (reason.response.status === 404) {
toast.error("Code not found!"); toast.error("Code not found!");
return; return;
} }
if (reason.response.status === 403) { if (reason.response.status === 403) {
toast.error("You do not have permission to delete this discount!"); toast.error("You do not have permission to delete this discount!");
return; return;
} }
toast.error("Something went wrong, please try again later."); toast.error("Something went wrong, please try again later.");
}) })
.finally(reload); .finally(reload);
}; };
const defaultColumns = [ const defaultColumns = [
columnHelper.accessor("id", { columnHelper.accessor("id", {
id: "id", id: "id",
header: () => ( header: () => (
<Checkbox <Checkbox
disabled={filteredDiscounts.length === 0} disabled={filteredDiscounts.length === 0}
isChecked={ isChecked={selectedDiscounts.length === filteredDiscounts.length && filteredDiscounts.length > 0}
selectedDiscounts.length === filteredDiscounts.length && onChange={(checked) => toggleAllDiscounts(checked)}>
filteredDiscounts.length > 0 {""}
} </Checkbox>
onChange={(checked) => toggleAllDiscounts(checked)} ),
> cell: (info) => (
{""} <Checkbox isChecked={selectedDiscounts.includes(info.getValue())} onChange={() => toggleDiscount(info.getValue())}>
</Checkbox> {""}
), </Checkbox>
cell: (info) => ( ),
<Checkbox }),
isChecked={selectedDiscounts.includes(info.getValue())} columnHelper.accessor("id", {
onChange={() => toggleDiscount(info.getValue())} header: "ID",
> cell: (info) => info.getValue(),
{""} }),
</Checkbox> columnHelper.accessor("domain", {
), header: "Domain",
}), cell: (info) => `@${info.getValue()}`,
columnHelper.accessor("id", { }),
header: "ID", columnHelper.accessor("percentage", {
cell: (info) => info.getValue(), header: "Percentage",
}), cell: (info) => `${info.getValue()}%`,
columnHelper.accessor("domain", { }),
header: "Domain", columnHelper.accessor("validUntil", {
cell: (info) => `@${info.getValue()}`, header: "Valid Until",
}), cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : ""),
columnHelper.accessor("percentage", { }),
header: "Percentage", {
cell: (info) => `${info.getValue()}%`, header: "",
}), id: "actions",
{ cell: ({row}: {row: {original: Discount}}) => {
header: "", return (
id: "actions", <div className="flex gap-4">
cell: ({ row }: { row: { original: Discount } }) => { <div
return ( data-tip="Delete"
<div className="flex gap-4"> className="cursor-pointer tooltip"
<div onClick={() => {
data-tip="Delete" setEditingDiscount(row.original);
className="cursor-pointer tooltip" }}>
onClick={() => { <BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
setEditingDiscount(row.original); </div>
}} <div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteDiscount(row.original)}>
> <BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" /> </div>
</div> </div>
<div );
data-tip="Delete" },
className="cursor-pointer tooltip" },
onClick={() => deleteDiscount(row.original)} ];
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
</div>
);
},
},
];
const table = useReactTable({ const table = useReactTable({
data: filteredDiscounts, data: filteredDiscounts,
columns: defaultColumns, columns: defaultColumns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
const closeModal = () => { const closeModal = () => {
setIsCreating(false); setIsCreating(false);
setEditingDiscount(undefined); setEditingDiscount(undefined);
reload(); reload();
}; };
return ( return (
<> <>
<Modal <Modal
isOpen={isCreating || !!editingDiscount} isOpen={isCreating || !!editingDiscount}
onClose={closeModal} onClose={closeModal}
title={ title={editingDiscount ? `Editing ${editingDiscount.id}` : "New Discount"}>
editingDiscount ? `Editing ${editingDiscount.id}` : "New Discount" <DiscountCreator onClose={closeModal} discount={editingDiscount} />
} </Modal>
> <div className="flex items-center justify-end pb-4 pt-1">
<DiscountCreator onClose={closeModal} discount={editingDiscount} /> <div className="flex gap-4 items-center">
</Modal> <span>{selectedDiscounts.length} code(s) selected</span>
<div className="flex items-center justify-end pb-4 pt-1"> <Button
<div className="flex gap-4 items-center"> disabled={selectedDiscounts.length === 0}
<span>{selectedDiscounts.length} code(s) selected</span> variant="outline"
<Button color="red"
disabled={selectedDiscounts.length === 0} className="!py-1 px-10"
variant="outline" onClick={() => deleteDiscounts(selectedDiscounts)}>
color="red" Delete
className="!py-1 px-10" </Button>
onClick={() => deleteDiscounts(selectedDiscounts)} </div>
> </div>
Delete <table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
</Button> <thead>
</div> {table.getHeaderGroups().map((headerGroup) => (
</div> <tr key={headerGroup.id}>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full"> {headerGroup.headers.map((header) => (
<thead> <th className="p-4 text-left" key={header.id}>
{table.getHeaderGroups().map((headerGroup) => ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
<tr key={headerGroup.id}> </th>
{headerGroup.headers.map((header) => ( ))}
<th className="p-4 text-left" key={header.id}> </tr>
{header.isPlaceholder ))}
? null </thead>
: flexRender( <tbody className="px-2">
header.column.columnDef.header, {table.getRowModel().rows.map((row) => (
header.getContext(), <tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
)} {row.getVisibleCells().map((cell) => (
</th> <td className="px-4 py-2" key={cell.id}>
))} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</tr> </td>
))} ))}
</thead> </tr>
<tbody className="px-2"> ))}
{table.getRowModel().rows.map((row) => ( </tbody>
<tr </table>
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" <button
key={row.id} onClick={() => setIsCreating(true)}
> className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
{row.getVisibleCells().map((cell) => ( New Discount
<td className="px-4 py-2" key={cell.id}> </button>
{flexRender(cell.column.columnDef.cell, cell.getContext())} </>
</td> );
))}
</tr>
))}
</tbody>
</table>
<button
onClick={() => setIsCreating(true)}
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"
>
New Discount
</button>
</>
);
} }

View File

@@ -1,154 +1,223 @@
import {PERMISSIONS} from "@/constants/userPermissions"; import { useMemo } from "react";
import { PERMISSIONS } from "@/constants/userPermissions";
import useExams from "@/hooks/useExams"; import useExams from "@/hooks/useExams";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {Exam} from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import {Type, User} from "@/interfaces/user"; import { Type, 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 {countExercises} from "@/utils/moduleUtils"; import { countExercises } from "@/utils/moduleUtils";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table"; import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {BsCheck, BsTrash, BsUpload} from "react-icons/bs"; import { BsCheck, BsTrash, BsUpload } from "react-icons/bs";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
const CLASSES: {[key in Module]: string} = { const CLASSES: { [key in Module]: string } = {
reading: "text-ielts-reading", reading: "text-ielts-reading",
listening: "text-ielts-listening", listening: "text-ielts-listening",
speaking: "text-ielts-speaking", speaking: "text-ielts-speaking",
writing: "text-ielts-writing", writing: "text-ielts-writing",
level: "text-ielts-level", level: "text-ielts-level",
}; };
const columnHelper = createColumnHelper<Exam>(); const columnHelper = createColumnHelper<Exam>();
export default function ExamList({user}: {user: User}) { export default function ExamList({ user }: { user: User }) {
const {exams, reload} = useExams(); const { exams, reload } = useExams();
const { users } = useUsers();
const setExams = useExamStore((state) => state.setExams); const parsedExams = useMemo(() => {
const setSelectedModules = useExamStore((state) => state.setSelectedModules); return exams.map((exam) => {
if (exam.createdBy) {
const user = users.find((u) => u.id === exam.createdBy);
if (!user) return exam;
const router = useRouter(); return {
...exam,
createdBy: user.type === "developer" ? "system" : user.name,
};
}
const loadExam = async (module: Module, examId: string) => { return exam;
const exam = await getExamById(module, examId.trim()); });
if (!exam) { }, [exams, users]);
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
toastId: "invalid-exam-id",
});
return; const setExams = useExamStore((state) => state.setExams);
} const setSelectedModules = useExamStore((state) => state.setSelectedModules);
setExams([exam]); const router = useRouter();
setSelectedModules([module]);
router.push("/exercises"); const loadExam = async (module: Module, examId: string) => {
}; const exam = await getExamById(module, examId.trim());
if (!exam) {
toast.error(
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
{
toastId: "invalid-exam-id",
}
);
const deleteExam = async (exam: Exam) => { return;
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return; }
axios setExams([exam]);
.delete(`/api/exam/${exam.module}/${exam.id}`) setSelectedModules([module]);
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
if (reason.response.status === 403) { router.push("/exercises");
toast.error("You do not have permission to delete this exam!"); };
return;
}
toast.error("Something went wrong, please try again later."); const deleteExam = async (exam: Exam) => {
}) if (
.finally(reload); !confirm(
}; `Are you sure you want to delete this ${capitalize(exam.module)} exam?`
)
)
return;
const getTotalExercises = (exam: Exam) => { axios
if (exam.module === "reading" || exam.module === "listening") { .delete(`/api/exam/${exam.module}/${exam.id}`)
return countExercises(exam.parts.flatMap((x) => x.exercises)); .then(() => toast.success(`Deleted the "${exam.id}" exam`))
} .catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
return countExercises(exam.exercises); if (reason.response.status === 403) {
}; toast.error("You do not have permission to delete this exam!");
return;
}
const defaultColumns = [ toast.error("Something went wrong, please try again later.");
columnHelper.accessor("id", { })
header: "ID", .finally(reload);
cell: (info) => info.getValue(), };
}),
columnHelper.accessor("module", {
header: "Module",
cell: (info) => <span className={CLASSES[info.getValue()]}>{capitalize(info.getValue())}</span>,
}),
columnHelper.accessor((x) => getTotalExercises(x), {
header: "Exercises",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("minTimer", {
header: "Timer",
cell: (info) => <>{info.getValue()} minute(s)</>,
}),
{
header: "",
id: "actions",
cell: ({row}: {row: {original: Exam}}) => {
return (
<div className="flex gap-4">
<div
data-tip="Load exam"
className="cursor-pointer tooltip"
onClick={async () => await loadExam(row.original.module, row.original.id)}>
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
{PERMISSIONS.examManagement.delete.includes(user.type) && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
];
const table = useReactTable({ const getTotalExercises = (exam: Exam) => {
data: exams, if (
columns: defaultColumns, exam.module === "reading" ||
getCoreRowModel: getCoreRowModel(), exam.module === "listening" ||
}); exam.module === "level"
) {
return countExercises(exam.parts.flatMap((x) => x.exercises));
}
return ( return countExercises(exam.exercises);
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full"> };
<thead>
{table.getHeaderGroups().map((headerGroup) => ( const defaultColumns = [
<tr key={headerGroup.id}> columnHelper.accessor("id", {
{headerGroup.headers.map((header) => ( header: "ID",
<th className="p-4 text-left" key={header.id}> cell: (info) => info.getValue(),
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} }),
</th> columnHelper.accessor("module", {
))} header: "Module",
</tr> cell: (info) => (
))} <span className={CLASSES[info.getValue()]}>
</thead> {capitalize(info.getValue())}
<tbody className="px-2"> </span>
{table.getRowModel().rows.map((row) => ( ),
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}> }),
{row.getVisibleCells().map((cell) => ( columnHelper.accessor((x) => getTotalExercises(x), {
<td className="px-4 py-2" key={cell.id}> header: "Exercises",
{flexRender(cell.column.columnDef.cell, cell.getContext())} cell: (info) => info.getValue(),
</td> }),
))} columnHelper.accessor("minTimer", {
</tr> header: "Timer",
))} cell: (info) => <>{info.getValue()} minute(s)</>,
</tbody> }),
</table> columnHelper.accessor("createdAt", {
); header: "Created At",
cell: (info) => {
const value = info.getValue();
if (value) {
return new Date(value).toLocaleDateString();
}
return null;
},
}),
columnHelper.accessor("createdBy", {
header: "Created By",
cell: (info) => info.getValue(),
}),
{
header: "",
id: "actions",
cell: ({ row }: { row: { original: Exam } }) => {
return (
<div className="flex gap-4">
<div
data-tip="Load exam"
className="cursor-pointer tooltip"
onClick={async () =>
await loadExam(row.original.module, row.original.id)
}
>
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
{PERMISSIONS.examManagement.delete.includes(user.type) && (
<div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteExam(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
];
const table = useReactTable({
data: parsedExams,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
return (
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
} }

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