Compare commits

..

244 Commits

Author SHA1 Message Date
Tiago Ribeiro
3ce97b4dcd Removed sorting in exchange for filtering 2024-09-11 16:33:05 +01:00
Tiago Ribeiro
7bfd000213 Solved a problem where it was only getting the first 25 students 2024-09-11 11:29:48 +01:00
Tiago Ribeiro
2a10933206 Solved some problems with the excel of master statistical 2024-09-10 12:00:14 +01:00
Tiago Ribeiro
33a46c227b Solved a bug for the master statistical 2024-09-10 10:55:02 +01:00
Tiago Ribeiro
85c8f622ee Added Student ID to the Master Statistical 2024-09-09 14:43:05 +01:00
Tiago Ribeiro
b9c097d42c Had a bug in pagination 2024-09-09 09:07:30 +01:00
Tiago Ribeiro
192132559b Solved a problem with the download of the excel 2024-09-09 08:19:32 +01:00
Tiago Ribeiro
6d1e8a9788 Had some errors on updating groups 2024-09-09 00:06:51 +01:00
Tiago Ribeiro
1c61d50a5c Improved some of the querying for the assignments 2024-09-09 00:02:34 +01:00
Tiago Ribeiro
9f0ba418e5 Added filtering and pagination for the assignment creator 2024-09-08 23:24:27 +01:00
Tiago Ribeiro
6fd2e64e04 Merge branch 'main' of bitbucket.org:ecropdev/ielts-ui 2024-09-08 23:09:25 +01:00
carlos.mesquita
2c01e6b460 Merged in feature/training-content (pull request #101)
Feature/training content

Approved-by: Tiago Ribeiro
2024-09-08 22:08:32 +00:00
Tiago Ribeiro
6e0c4d4361 Added search per exam 2024-09-08 23:07:47 +01:00
Tiago Ribeiro
745eef981f Added some more pagination 2024-09-08 23:02:48 +01:00
carlos.mesquita
7a33f42bcd Merged main into feature/training-content 2024-09-08 21:49:22 +00:00
Carlos Mesquita
02564c8426 Had the type hardcoded 2024-09-08 22:47:43 +01:00
Tiago Ribeiro
eab6ab03b7 Not shown when not completed 2024-09-08 22:46:09 +01:00
Carlos Mesquita
6f534662e1 Shuffles bugfix 2024-09-08 22:45:24 +01:00
Tiago Ribeiro
fbc7abdabb Solved a bug 2024-09-08 22:35:14 +01:00
Tiago Ribeiro
b7349b5df8 Tried to solve some more issues with counts 2024-09-08 19:56:44 +01:00
Tiago Ribeiro
298901a642 Updated the UserList to show the corporates 2024-09-08 19:50:59 +01:00
Tiago Ribeiro
88eafafe12 Stopped sessions from being cached 2024-09-08 19:36:04 +01:00
Tiago Ribeiro
31a01a3157 Allow admins create other admins 2024-09-08 19:27:12 +01:00
Tiago Ribeiro
a5b3a7e94d Solved a problem with the _id 2024-09-08 19:25:14 +01:00
Tiago Ribeiro
49e8237e99 A bit of error handling I guess 2024-09-08 18:54:44 +01:00
Tiago Ribeiro
d5769c2cb9 Updated more of the page 2024-09-08 18:39:52 +01:00
Tiago Ribeiro
e49a325074 Had a small error on the groups 2024-09-08 18:00:47 +01:00
Tiago Ribeiro
e6528392a2 Changed the totals of the admin pretty much 2024-09-08 16:06:54 +01:00
Tiago Ribeiro
620e4dd787 Solved some problems, bypassed some stuff 2024-09-08 11:35:09 +01:00
João Ramos
e3847baadb Merged in bug-fixing-8-sep-24 (pull request #99)
Bug fixing 8 sep 24

Approved-by: Tiago Ribeiro
2024-09-08 09:21:53 +00:00
Joao Ramos
5b8631ab6a Readded master statistical to corporate 2024-09-08 01:30:12 +01:00
Joao Ramos
f9f29eabb3 Fixed an issue after merging 2024-09-08 01:23:56 +01:00
Tiago Ribeiro
898edb152f Moved the Dockerfile env 2024-09-07 23:59:58 +01:00
Tiago Ribeiro
bf0d696b2f Some hard coding at least for now 2024-09-07 23:51:04 +01:00
Tiago Ribeiro
d91b1c14e7 Removed an error 2024-09-07 23:41:01 +01:00
Tiago Ribeiro
cdd42b2f07 Merge branch 'main' into migration-mongodb 2024-09-07 23:09:37 +01:00
João Ramos
34bc9df9ea Merged in ENCOA-200_StatisticalCorporate (pull request #97)
ENCOA-200: Added Master Statistical to Corporate

Approved-by: Tiago Ribeiro
2024-09-07 21:57:22 +00:00
Tiago Ribeiro
15cc7c8cc9 Improved a bit of the pagination 2024-09-07 22:48:52 +01:00
Carlos Mesquita
b4ab620c78 lint warnings 2024-09-07 22:42:22 +01:00
Carlos Mesquita
6e4ef249b8 Finished refactoring 2024-09-07 22:39:14 +01:00
Carlos Mesquita
c2b4bb29d6 Refactored /api/users 2024-09-07 21:50:29 +01:00
Carlos Mesquita
cab469007b Refactored /api/tickets /api/training 2024-09-07 21:25:51 +01:00
Carlos Mesquita
d6782bd86e Refactored /api/paypal, /api/permissions, /api/reset /api/sessions, /api/stats 2024-09-07 20:43:55 +01:00
Tiago Ribeiro
6251f8f4db Continued migrating more and more files 2024-09-07 18:29:20 +01:00
Joao Ramos
fb9d11f38d ENCOA-200: Added Master Statistical to Corporate 2024-09-07 18:19:25 +01:00
Carlos Mesquita
bb8dca69cf Merge conflict 2024-09-07 18:09:14 +01:00
Carlos Mesquita
53b31b306d /api/packages refactored, forgot to commit some changes 2024-09-07 18:08:03 +01:00
Tiago Ribeiro
d173cdb02a Updated the Grading System 2024-09-07 18:00:05 +01:00
Tiago Ribeiro
07f0ea25bb Merge branch 'migration-mongodb' of bitbucket.org:ecropdev/ielts-ui into migration-mongodb 2024-09-07 17:55:33 +01:00
Carlos Mesquita
e7ee55d608 Merge branch 'migration-mongodb' of https://bitbucket.org/ecropdev/ielts-ui into migration-mongodb 2024-09-07 17:54:19 +01:00
Carlos Mesquita
7fa4edf37d /api/groups and /api/invites refactored, fixed some inserts/updates in which I didn't include the id 2024-09-07 17:54:10 +01:00
Tiago Ribeiro
49022394b0 Did the same treatment to Corporate and Teacher dashboards 2024-09-07 17:53:16 +01:00
Tiago Ribeiro
3be0d158e3 Improved the performance of the MasterCorporate 2024-09-07 17:34:41 +01:00
Tiago Ribeiro
56f374bbfe Updated the pagination on the useUsers and migrading the grading 2024-09-07 16:53:58 +01:00
Carlos Mesquita
417c9176fe /api/exam refactor 2024-09-07 16:39:07 +01:00
Carlos Mesquita
e3400e8564 /api/evaluate refactor 2024-09-07 16:17:24 +01:00
Carlos Mesquita
d680905a87 Merge branch 'migration-mongodb' of https://bitbucket.org/ecropdev/ielts-ui into migration-mongodb 2024-09-07 16:03:32 +01:00
Carlos Mesquita
c07e3f86fb Refactored discounts and replaced my previous commit id queries to use id not _id 2024-09-07 16:03:26 +01:00
Tiago Ribeiro
238a25aaeb Updated the make_user to use MongoDB 2024-09-07 16:01:03 +01:00
Carlos Mesquita
171231cd21 Refactored codes 2024-09-07 15:41:09 +01:00
Carlos Mesquita
6ed342bb6f Merge branch 'migration-mongodb' of https://bitbucket.org/ecropdev/ielts-ui into migration-mongodb 2024-09-07 15:15:17 +01:00
Carlos Mesquita
6f7ef1abef Refactored pages/api/assignments to mongodb 2024-09-07 15:13:41 +01:00
Tiago Ribeiro
e33fa00fa3 Updated the groups and users 2024-09-07 15:13:13 +01:00
Tiago Ribeiro
c0b814081e Updated the register endpoint to use MongoDB 2024-09-07 13:58:52 +01:00
Tiago Ribeiro
e8b7c5ff80 Started migrating the DB to MongoDB 2024-09-07 13:37:08 +01:00
Tiago Ribeiro
8c94bcac52 Corrected a problem with too many participants 2024-09-07 13:02:47 +01:00
carlos.mesquita
8803a8c166 Merged in feature/training-content (pull request #95)
ENCOA-69: Training update, most of the styles in the old tips were standardized, before all the styles were hardcoded into the tip
2024-09-07 11:42:06 +00:00
João Ramos
2f63fd196b Merged in ENCOA-183_MasterCorporateGrouping (pull request #96)
ENCOA-183 MasterCorporateGrouping

Approved-by: Tiago Ribeiro
2024-09-07 11:41:29 +00:00
Joao Ramos
42471170ce Improved grouping for master statistical 2024-09-07 12:38:28 +01:00
Carlos Mesquita
2bf9afca9c Merge remote-tracking branch 'origin/develop' into feature/training-content 2024-09-07 11:38:18 +01:00
Carlos Mesquita
9c41ddee60 Training update, most of the styles in the old tips were standardized, before all the styles were hardcoded into the tip, the new tips may still have some hardcoded styles but the vast majority only uses standard html or custom ones that are picked up in FormatTip to attribute styles 2024-09-07 11:37:04 +01:00
Joao Ramos
9993c7a8a7 ENCOA-183: Initial test changes for corporates grouped by name 2024-09-07 11:04:00 +01:00
João Ramos
a22c9d102f Merged in ENCOA-131_MasterStatistical (pull request #94)
Added level part display on excel and a pseudo sorting

Approved-by: Tiago Ribeiro
2024-09-07 08:13:29 +00:00
Joao Ramos
2d0cb8eefb Added level part display on excel and a pseudo sorting 2024-09-07 00:14:56 +01:00
Joao Ramos
58448a391f Fixed infinite loop on the dashboards 2024-09-06 23:48:18 +01:00
Tiago Ribeiro
f6550e6a36 Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-09-06 17:07:45 +01:00
Tiago Ribeiro
cfe297cc38 Continued improving this 2024-09-06 17:06:49 +01:00
Tiago Ribeiro
4530e4079f Did the same to all of the dashboards 2024-09-06 15:35:26 +01:00
Tiago Ribeiro
de35e1a8b7 Updated the MasterCorporate with the improved queries 2024-09-06 14:57:23 +01:00
Tiago Ribeiro
a6bf53e84c Disabled the review buttons 2024-09-06 14:45:44 +01:00
carlos.mesquita
d7ffdc3031 Merged in feature/level-file-upload (pull request #93)
Small bug fix

Approved-by: Tiago Ribeiro
2024-09-06 10:24:12 +00:00
carlos.mesquita
98f2527fed Merged develop into feature/level-file-upload 2024-09-06 10:23:32 +00:00
Carlos Mesquita
d8bf10eaea Small bug fix 2024-09-06 11:21:35 +01:00
Tiago Ribeiro
f9216637df Extracted the function 2024-09-06 10:39:16 +01:00
Tiago Ribeiro
08945bfbdd Forgot to add this one 2024-09-06 10:13:30 +01:00
Tiago Ribeiro
b92a4285c9 Removed the user itself 2024-09-06 10:12:19 +01:00
Tiago Ribeiro
271ca7069e Updated it so it also appears to teachers 2024-09-06 10:10:50 +01:00
carlos.mesquita
08ed8fcb32 Merged in feature/level-file-upload (pull request #92)
ENCOA-182, ENCOA-185, ENCOA-177, ENCOA-168, ENCOA-186, ENCOA-176, ENCOA-189, ENCOA-167

Approved-by: Tiago Ribeiro
2024-09-06 08:53:29 +00:00
João Ramos
17f678a3ac Merged in ENCOA-131_MasterStatistical (pull request #91)
ENCOA-131 MasterStatistical

Approved-by: Tiago Ribeiro
2024-09-06 08:53:07 +00:00
Carlos Mesquita
6bd9816edd Merge remote-tracking branch 'origin/develop' into feature/level-file-upload 2024-09-06 09:41:01 +01:00
Carlos Mesquita
77ac15c2bb ENCOA-182, ENCOA-185, ENCOA-177, ENCOA-168, ENCOA-186, ENCOA-176, ENCOA-189, ENCOA-167 2024-09-06 09:39:38 +01:00
Tiago Ribeiro
55cc9765e2 Updated the backend so the users list only returns the correct ones 2024-09-06 09:33:30 +01:00
Joao Ramos
e433a150a9 Added level export to excel 2024-09-05 23:30:03 +01:00
Joao Ramos
a61ad2cc7e Added an export feature for the master statisticl screen 2024-09-05 22:42:46 +01:00
Tiago Ribeiro
680f4cfa95 Made it so it will show always the corporate 2024-09-05 19:55:19 +01:00
Tiago Ribeiro
311824e8b7 ENCOA-175: When a Corporate Create another Corporate, the created corporate should have access to the same students and teachers as the creator 2024-09-05 19:24:28 +01:00
Tiago Ribeiro
2fb73cc3a3 ENCOA-166: Increased the probability of this being fixed even more 2024-09-05 18:54:36 +01:00
Joao Ramos
70de97766e Added search to table 2024-09-05 18:25:39 +01:00
Tiago Ribeiro
c7ff11d0fc ENCOA-172: Updated it so if an assignment has already been started, they can't start it again 2024-09-05 17:33:15 +01:00
Tiago Ribeiro
e312af36bb Added the assignment to the Session Card 2024-09-05 17:25:22 +01:00
Tiago Ribeiro
ac980023b5 Negated a simple change 2024-09-05 16:59:39 +01:00
Tiago Ribeiro
3b43803b7e ENCOA-180: Prevented users from checking their results if not released yet 2024-09-05 16:54:45 +01:00
Tiago Ribeiro
c8be2f1255 Prevented sessions from appearing when an assignment is done 2024-09-05 16:52:16 +01:00
Tiago Ribeiro
b6b5f3a9f1 Solved a problem related to not showing corporates if they are not in the correct group 2024-09-05 12:02:46 +01:00
Tiago Ribeiro
6ce81b300a Removed a part of the make_user 2024-09-05 11:35:53 +01:00
Tiago Ribeiro
3d3c4448ae Removed the group for when it's a corporate creating another corporate 2024-09-04 18:50:35 +01:00
Tiago Ribeiro
cb2c1641f5 Made the corporate name being the creator's one 2024-09-04 17:40:26 +01:00
Tiago Ribeiro
7bcd0f863f ENCOA-133: Teachers within a Group should be able to view the Group assignments 2024-09-04 15:26:42 +01:00
Tiago Ribeiro
2d95cbd3dc ENCOA-137: Top right side Next Button on the exams 2024-09-04 15:10:17 +01:00
Tiago Ribeiro
49aac93618 Updated the look of the assignments 2024-09-04 14:50:47 +01:00
Tiago Ribeiro
28ad7944e0 Solved a bug with a page crashing 2024-09-04 14:34:18 +01:00
Tiago Ribeiro
d4553501b8 ENCOA-164: Corporate created by another Corporate Should be linked to the Master Corporate of the Creator Accoubnt 2024-09-04 11:59:14 +01:00
Tiago Ribeiro
4654c21d92 ENCOA-154: Increase the number of questions per page in all modules (Priority in Level Test) 2024-09-04 11:41:48 +01:00
Tiago Ribeiro
becc91d8ea Some general improvements 2024-09-04 11:17:54 +01:00
Tiago Ribeiro
06cb4485f4 ENCOA-163: Groups should only display first 5 names on the table 2024-09-04 09:56:12 +01:00
Tiago Ribeiro
9b22fb259c ENCOA-159: Sidebar should be minimized as default 2024-09-04 09:31:03 +01:00
carlos.mesquita
f2137efaa0 Merged in feature/level-file-upload (pull request #90)
ENCOA-149, ENCOA-150, ENCOA-152, ENCOA-153, ENCOA-155, ENCOA-156, ENCOA-157, ENCOA-158, ENCOA-161

Approved-by: Tiago Ribeiro
2024-09-04 08:10:40 +00:00
João Ramos
00834fec7b Merged in features-03-Sep-24 (pull request #89)
ENCOA-146 + Bug fixing

Approved-by: Tiago Ribeiro
2024-09-04 08:10:12 +00:00
carlos.mesquita
ef84052909 Merged develop into feature/level-file-upload 2024-09-04 01:34:36 +00:00
Carlos Mesquita
6ea80dd0da Merge branch 'feature/level-file-upload' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-09-04 02:27:31 +01:00
Carlos Mesquita
5168e70edc ENCOA-149, ENCOA-150, ENCOA-152, ENCOA-153, ENCOA-155, ENCOA-156, ENCOA-157, ENCOA-158, ENCOA-161 -> Updated the mc buttons, no longer shows a context only div on parts that have context, removed line numbers on lines between paragraphs, applied bold and underline to 'not correct' in underline prompts, added another pop up to confirm submission. 2024-09-04 02:26:22 +01:00
João Ramos
a7719dbb55 Merged develop into features-03-Sep-24 2024-09-03 22:25:16 +00:00
Joao Ramos
25e6cb36a9 Improvements on start button 2024-09-03 23:23:18 +01:00
Joao Ramos
a7c1ea0409 Imporvements on started management of assignment 2024-09-03 22:56:43 +01:00
Joao Ramos
8aed075553 Disabled no longer makes sense for an assignment on the student 2024-09-03 22:42:12 +01:00
carlos.mesquita
3fc581aaac Merged in feature/level-file-upload (pull request #88)
ENCOA-147: Uploading a batch of multiple users is now unrestricted by firebase auth request quotas

Approved-by: Tiago Ribeiro
2024-09-03 21:11:33 +00:00
Tiago Ribeiro
020f689af6 Merged develop into feature/level-file-upload 2024-09-03 20:36:55 +00:00
Joao Ramos
04c9ff24ea Changed the criteria for the filter on student assignment 2024-09-03 21:15:59 +01:00
Joao Ramos
105c03fa09 Fied an issue with the future with autostart 2024-09-03 21:15:06 +01:00
Joao Ramos
547e0fc530 Fixed an issue with the default values 2024-09-03 21:14:20 +01:00
Joao Ramos
bf7793e103 Minor fix regarding future assignments 2024-09-03 20:51:11 +01:00
Carlos Mesquita
60554d8e16 ENCOA-147: Uploading a batch of multiple users is now unrestricted by firebase auth request quotas 2024-09-03 20:13:04 +01:00
Joao Ramos
5d26af511c Initial improvements on assets for assignments validation 2024-09-03 20:10:32 +01:00
Joao Ramos
12104e797a Fixed an issue preventing the user from seeing data regarding the assignment 2024-09-03 20:02:30 +01:00
Joao Ramos
d307c61cd7 Added a feature to automatically start an exam 2024-09-03 19:23:45 +01:00
Joao Ramos
6774b2d0b6 Fixed label 2024-09-03 19:12:32 +01:00
Tiago Ribeiro
fa53382c08 Hidden the level score when not released 2024-09-03 17:43:33 +01:00
Tiago Ribeiro
67929655f4 Solved some race conditions related to the previous commit 2024-09-03 17:00:42 +01:00
João Ramos
e8c47941d0 Merged in ENCOA-139_ReleaseBug (pull request #87)
ENCOA-139 Release Improvements
2024-09-03 15:47:17 +00:00
carlos.mesquita
82d0a0556f Merged in feature/level-file-upload (pull request #85)
Feature/level file upload
2024-09-03 15:46:39 +00:00
Tiago Ribeiro
7cd18b07bb ENCOA-130: Add owners to exams 2024-09-03 16:31:29 +01:00
Joao Ramos
aca8ad2d14 Added an option to release automatically 2024-09-02 23:56:51 +01:00
Joao Ramos
4bb80919ad Fixd an error accessing a property that might not be defined, others were already using ? 2024-09-02 23:50:41 +01:00
Joao Ramos
5ed851878a Reenabled information display after Exam 2024-09-02 23:48:56 +01:00
Carlos Mesquita
763452e3cc Merge remote-tracking branch 'origin/develop' into feature/level-file-upload 2024-09-02 22:21:40 +01:00
Carlos Mesquita
063a73691a Exhaustive-deps warning 2024-09-02 22:20:02 +01:00
Carlos Mesquita
caddf87231 Previous Level exams were being broken by the part divider changes, fixed it. 2024-09-02 22:18:33 +01:00
Tiago Ribeiro
0f38e01283 let the users wait 2024-09-02 21:31:26 +01:00
Tiago Ribeiro
640b6f0e4d Updated the user card to allow master corporate to edit without setting a price 2024-09-02 19:56:12 +01:00
Tiago Ribeiro
f43d562405 Solved a problem with the corporate when making user 2024-09-02 18:12:02 +01:00
Tiago Ribeiro
39752cbb1d Wrong number in the corporate amount 2024-09-02 17:48:55 +01:00
Tiago Ribeiro
229d93c03e Fixed a problem 2024-09-02 17:18:53 +01:00
Tiago Ribeiro
b0ab8a8fce Stuff 2024-09-02 16:09:44 +01:00
Tiago Ribeiro
90cb705ad2 Corrected an oopsie related to the amount of level exams shown 2024-09-02 15:38:37 +01:00
Tiago Ribeiro
65fa6e64e6 Removed unnecessary code 2024-09-02 15:32:15 +01:00
Tiago Ribeiro
7c0e7ef53e Solved a bug related to the Batch Create User 2024-09-02 14:28:38 +01:00
Tiago Ribeiro
b6c3754b40 Allow users to edit others 2024-09-02 12:30:23 +01:00
Tiago Ribeiro
4e3c947d2a Solved a bug related to the creation of users 2024-09-02 11:59:12 +01:00
Tiago Ribeiro
abcb1afd48 ENCOA-120: Prevented flicker when leaving page 2024-09-02 11:21:38 +01:00
Tiago Ribeiro
0b88d6bcd1 Improved a tiny bit of the performance with Student Dashboard 2024-08-30 11:56:35 +01:00
Tiago Ribeiro
fef5bf44de Removed some console.logs 2024-08-30 11:25:21 +01:00
carlos.mesquita
2c43d48bbd Merged in feature/training-content (pull request #84)
Patched a bug where the user would be locked out of continuing if he closed the unawnsered modal

Approved-by: Tiago Ribeiro
2024-08-30 10:07:58 +00:00
Carlos Mesquita
4865b47393 ENCOA-138 Changed the 'Confirm Submission' title and the 'Submit' button to red and increased the font size of the modal 2024-08-30 10:17:24 +01:00
Carlos Mesquita
3892fe1a67 Merge remote-tracking branch 'origin/develop' into feature/training-content 2024-08-30 09:25:38 +01:00
Tiago Ribeiro
39710aaea1 Improved the overall stability and speed of the app 2024-08-29 23:21:20 +01:00
Tiago Ribeiro
b57e11bec4 ENCOA-129: When Creating a Single user Corporate OR Master Corporate it should have the field Department to be configured 2024-08-29 15:18:55 +01:00
Tiago Ribeiro
fdc8f98b21 ENCOA-132: Create user is not linking to the Corporate Account 2024-08-29 15:06:32 +01:00
Tiago Ribeiro
2b71f2467c ENCOA-126: Corporate should not be allowed to edit is own name 2024-08-29 13:18:11 +01:00
Tiago Ribeiro
cd1caf0f53 ENCOA-123: Improved the options of users for the Group Creator 2024-08-29 13:00:39 +01:00
Tiago Ribeiro
3b77d3fc0b ENCOA-136: Profile Pictures of all accounts should be by Default Encoach Logo 2024-08-29 12:51:10 +01:00
Tiago Ribeiro
73525f1dc0 ENCOA-127: Change the name of the field Position to Department 2024-08-29 12:47:05 +01:00
Tiago Ribeiro
c256231cfc ENCOA-120: Prevent Assignment Creator from disappearing 2024-08-29 12:43:53 +01:00
Tiago Ribeiro
2fb41f7462 ENCOA-128: Display the position of the Corporate in the topbar 2024-08-29 12:31:34 +01:00
Tiago Ribeiro
aa96b13ec2 ENCOA-121 & ENCOA-122: Fixed a bug where updating the account information of a Corporate or Master Corporate was not working 2024-08-29 12:28:30 +01:00
Tiago Ribeiro
f9429d1056 ENCOA-125: Allow Master Corporate to change the company name of a Corporate 2024-08-29 12:19:44 +01:00
Tiago Ribeiro
af9462398a Increase the size and boldness of the timer 2024-08-28 14:58:07 +01:00
Tiago Ribeiro
6fd0b7aef3 Hotfix for a crash 2024-08-28 14:53:09 +01:00
Tiago Ribeiro
bc47f9c001 Updated the react-icons package 2024-08-28 14:25:29 +01:00
Carlos Mesquita
4ea3a844ed Merge remote-tracking branch 'origin/develop' into feature/training-content 2024-08-28 13:32:21 +01:00
Carlos Mesquita
ea8a3625ef Patched a bug where the user would be locked out of continuing if he closed the unawnsered modal 2024-08-28 13:28:21 +01:00
Tiago Ribeiro
3eb2f432fa Allowed assigners to release and start the assignment 2024-08-28 13:07:04 +01:00
Tiago Ribeiro
5d10e6564d Only shows results for an assignments that has been released 2024-08-28 12:27:26 +01:00
Tiago Ribeiro
e518323d99 Updated the generation to allow for private exams 2024-08-28 10:46:02 +01:00
Tiago Ribeiro
dbf262598f Added the ability to set an exam as private 2024-08-28 10:26:45 +01:00
Tiago Ribeiro
951ca5736e Added the ability for some exams to be private and not chosen randomly 2024-08-28 10:17:01 +01:00
João Ramos
7960e7d8fb Merged in bug-fixing-280824 (pull request #83)
Bug fixing 280824

Approved-by: Tiago Ribeiro
2024-08-28 08:57:52 +00:00
Tiago Ribeiro
99039f8bf3 Corrected a problem related to getting the corporate of a user 2024-08-28 09:57:00 +01:00
Joao Ramos
3c7df4e33c Added default value in case on the user not being found in the DB 2024-08-28 07:39:51 +01:00
Joao Ramos
614a7a2a29 Improved asset download criteria 2024-08-28 07:38:38 +01:00
João Ramos
ec67f91263 Merged in bug-fixing-270824 (pull request #82)
Fixed an use case where the export of Excel failed if no students had answered it

Approved-by: Tiago Ribeiro
2024-08-27 22:20:52 +00:00
Joao Ramos
aa4e13a18d Fixed an use case where the export of Excel failed if no students had answered it 2024-08-27 23:11:40 +01:00
carlos.mesquita
23e26617e2 Merged in feature/training-content (pull request #81)
Feature/training content

Approved-by: Tiago Ribeiro
2024-08-27 21:43:03 +00:00
Carlos Mesquita
ef32226c6c Previous commit solved completion but messed up question modal, patched that and added the condition to show the submission modal when all questions are awnswered 2024-08-27 22:10:31 +01:00
Carlos Mesquita
c9174f37ef Forgot to toggle strict mode again 2024-08-27 17:13:19 +01:00
Carlos Mesquita
c99dbab4b6 Merge remote-tracking branch 'origin/develop' into feature/training-content 2024-08-27 17:10:57 +01:00
Carlos Mesquita
eb985014be Merge branch 'feature/training-content' of https://bitbucket.org/ecropdev/ielts-ui into feature/training-content 2024-08-27 17:09:33 +01:00
Carlos Mesquita
845bccbe2a ENCOA-107, ENCOA-115 Fixed completion percentage, brought back the line numbers, 'Level Exam' was replaced by 'Placement Test', Next/Back instructions with quotes, 'Submit' on last level question 2024-08-27 17:08:33 +01:00
Tiago Ribeiro
3ec886c31d ENCOA-109: Keep the same balance after deleting a user 2024-08-27 16:57:41 +01:00
Tiago Ribeiro
fa3929d5e9 Allow Master Corporate to pay for their subscription 2024-08-27 16:35:34 +01:00
Tiago Ribeiro
b7940087b5 ENCOA-109: Made the modal disappear when a user is created 2024-08-27 11:14:29 +01:00
Tiago Ribeiro
7fb0ed884c Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-08-27 11:04:22 +01:00
carlos.mesquita
d93852e230 Merged in feature/training-content (pull request #80)
Feature/training content

Approved-by: Tiago Ribeiro
2024-08-27 10:03:53 +00:00
Tiago Ribeiro
d04ea33616 Merged develop into feature/training-content 2024-08-27 10:03:44 +00:00
Tiago Ribeiro
eb38464aca ENCOA-112: Improve Answer Display 2024-08-27 11:02:39 +01:00
Tiago Ribeiro
cd85c71aec ENCOA-114: In Exam List and Group List provide Fuzzy Search Filter 2024-08-27 10:53:50 +01:00
Carlos Mesquita
c464375414 Merge remote-tracking branch 'origin/develop' into feature/training-content 2024-08-27 09:44:22 +01:00
Tiago Ribeiro
82233c7d53 ENCOA-111: Change Modules Assignment Display 2024-08-27 09:42:14 +01:00
Carlos Mesquita
cc5be99b0f Bug fix on fillblanks calculateScore 2024-08-27 09:40:20 +01:00
Tiago Ribeiro
65dc3e92d0 ENCOA-110: Reorder excel upload columns 2024-08-27 09:38:47 +01:00
Tiago Ribeiro
addd117834 There was a problem with the order of the modals 2024-08-27 09:35:37 +01:00
Carlos Mesquita
72b498eb85 Fixed a bug in fill blanks where the input would be reset by the re-rendering of lines 2024-08-27 09:20:00 +01:00
Tiago Ribeiro
0aba6355ed ENCOA-90: Updated the instances of Level test to use the Grading System 2024-08-27 00:18:01 +01:00
Tiago Ribeiro
a0b8521f0a ENCOA-90: Creating the ability for a corporate/master corporate to edit their grading system 2024-08-26 22:35:00 +01:00
Carlos Mesquita
eb7c539a0e exaustive-deps warning 2024-08-26 18:28:37 +01:00
Carlos Mesquita
22928ce283 ENCOA-94: Fixes the bug, refactored useStats to be useFilterRecordsByUser in order to not duplicate code and also refactored records.tsx and training.tsx to use a component which abstracts the user Selection for both stats and training. 2024-08-26 18:26:29 +01:00
carlos.mesquita
4a1a52bd61 Merged in feature/level-file-upload (pull request #78)
Shuffles fixed

Approved-by: Tiago Ribeiro
2024-08-25 17:36:17 +00:00
João Ramos
af00f49adc Merged in features-21-08-24 (pull request #79)
ENCOA: 86 + 101 + 102

Approved-by: Tiago Ribeiro
2024-08-25 17:35:59 +00:00
Joao Ramos
8d37e60f5d Merge branch 'develop' into features-21-08-24 2024-08-24 18:56:05 +01:00
Joao Ramos
74a53f55fd Added option to start exam which serves as an alternative to start date for the exam 2024-08-24 17:38:57 +01:00
Joao Ramos
101605ad88 Updated Master Statistical with user grade count 2024-08-24 11:06:44 +01:00
Joao Ramos
cf1b47fbd2 Major updates on Master Statistical 2024-08-24 10:53:11 +01:00
Carlos Mesquita
f9174c13c1 Merge branch 'feature/level-file-upload' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-24 01:03:37 +01:00
Tiago Ribeiro
032d20b4b2 ENCOA-96: License Distribuition system from Master Corporate to Corporate 2024-08-24 01:02:34 +01:00
Carlos Mesquita
2146ef1a92 Found the bug 2024-08-24 00:54:55 +01:00
carlos.mesquita
4928267036 Merged develop into feature/level-file-upload 2024-08-23 20:19:13 +00:00
Carlos Mesquita
f0f38b335f Part and MC question grid jump to, has a bug on next going to refactor the whole thing 2024-08-23 21:17:32 +01:00
Tiago Ribeiro
3e21538d02 ENCOA-98: Change the template on the Excel import Function 2024-08-23 16:59:08 +01:00
Tiago Ribeiro
33fd6ddf8f Removed the ability to change the user from the list 2024-08-23 12:10:44 +01:00
Tiago Ribeiro
1bb5405894 ENCOA-88: Create individual accounts 2024-08-23 12:02:35 +01:00
Joao Ramos
44adc142f6 Updated Master Statistical 2024-08-23 00:56:18 +01:00
Joao Ramos
4379716e9b ENCOA-86: Stats page now filters assignments PDF download based on released 2024-08-22 23:16:16 +01:00
Carlos Mesquita
b4b078c8c9 Missed some console.logs 2024-08-22 22:19:32 +01:00
Carlos Mesquita
6dbc2f5ed2 Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-22 22:05:53 +01:00
Carlos Mesquita
9a51096a94 Merge branch 'feature/level-file-upload' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-22 22:04:11 +01:00
Carlos Mesquita
1315e0b280 Shuffles fixed 2024-08-22 22:02:37 +01:00
Tiago Ribeiro
4505ea5ff8 ENCOA-100: Changed the login/register photo 2024-08-22 16:49:27 +01:00
Tiago Ribeiro
192324b891 ENCOA-99: Added a Student ID field to Students 2024-08-22 16:27:03 +01:00
Tiago Ribeiro
326d305a69 ENCOA-97: Now it allows the user to write the name of the country in english 2024-08-22 16:15:25 +01:00
Tiago Ribeiro
cfcff3cf3b ENCOA-93 2024-08-22 15:56:52 +01:00
Tiago Ribeiro
202632ff58 ENCOA-89, ENCOA-91, ENCOA-92, ENCOA-95
All changes related to permissions towards types of users
2024-08-22 12:27:15 +01:00
Joao Ramos
7116892f9a ENCOA-86: Added default value for new assignment 2024-08-21 22:24:46 +01:00
Joao Ramos
c6f40f625b ENCOA-86: Fixed broken labels on release 2024-08-21 22:20:38 +01:00
Joao Ramos
556f642112 ENCOA-86: Added an option to release exam results 2024-08-21 22:19:15 +01:00
João Ramos
22611121c6 Merged in ENCOA-83_MasterStatistical (pull request #76)
ENCOA-83 MasterStatistical

Approved-by: Tiago Ribeiro
2024-08-21 16:23:21 +00:00
228 changed files with 13976 additions and 10074 deletions

View File

@@ -23,6 +23,8 @@ COPY . .
# Uncomment the following line in case you want to disable telemetry during the build. # Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1 # ENV NEXT_TELEMETRY_DISABLED 1
ENV MONGODB_URI "mongodb+srv://user:JKpFBymv0WLv3STj@encoach.lz18a.mongodb.net/?retryWrites=true&w=majority&appName=EnCoach"
RUN yarn build RUN yarn build
# If using npm comment out above and use below instead # If using npm comment out above and use below instead

View File

@@ -1,7 +1,7 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000"; const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: false,
output: "standalone", output: "standalone",
async headers() { async headers() {
return [ return [

380
package-lock.json generated
View File

@@ -26,7 +26,8 @@
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@use-gesture/react": "^10.3.1", "@use-gesture/react": "^10.3.1",
"axios": "^1.3.5", "axios": "^1",
"axios-cache-interceptor": "^1",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"chart.js": "^4.2.1", "chart.js": "^4.2.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
@@ -41,6 +42,7 @@
"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",
"firebase-scrypt": "^2.2.0",
"formidable": "^3.5.0", "formidable": "^3.5.0",
"formidable-serverless": "^1.1.1", "formidable-serverless": "^1.1.1",
"framer-motion": "^9.0.2", "framer-motion": "^9.0.2",
@@ -49,6 +51,7 @@
"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",
"mongodb": "^6.8.1",
"next": "^14.2.5", "next": "^14.2.5",
"nodemailer": "^6.9.5", "nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0", "nodemailer-express-handlebars": "^6.1.0",
@@ -64,7 +67,7 @@
"react-diff-viewer": "^3.1.1", "react-diff-viewer": "^3.1.1",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-firebase-hooks": "^5.1.1", "react-firebase-hooks": "^5.1.1",
"react-icons": "^4.8.0", "react-icons": "^5.3.0",
"react-lineto": "^3.3.0", "react-lineto": "^3.3.0",
"react-media-recorder": "1.6.5", "react-media-recorder": "1.6.5",
"react-phone-number-input": "^3.3.6", "react-phone-number-input": "^3.3.6",
@@ -77,7 +80,7 @@
"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.2.5",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwind-scrollbar-hide": "^1.1.7", "tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -88,6 +91,7 @@
"zustand": "^4.3.6" "zustand": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@simbathesailor/use-what-changed": "^2.0.0",
"@types/blob-stream": "^0.1.33", "@types/blob-stream": "^0.1.33",
"@types/formidable": "^3.4.0", "@types/formidable": "^3.4.0",
"@types/howler": "^2.2.11", "@types/howler": "^2.2.11",
@@ -1945,6 +1949,14 @@
"prop-types": "^15.7.2" "prop-types": "^15.7.2"
} }
}, },
"node_modules/@mongodb-js/saslprep": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz",
"integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==",
"dependencies": {
"sparse-bitfield": "^3.0.3"
}
},
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "14.2.5", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
@@ -3035,6 +3047,15 @@
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz",
"integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==" "integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg=="
}, },
"node_modules/@simbathesailor/use-what-changed": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@simbathesailor/use-what-changed/-/use-what-changed-2.0.0.tgz",
"integrity": "sha512-ulBNrPSvfho9UN6zS2fii3AsdEcp2fMaKeqUZZeCNPaZbB6aXyTUhpEN9atjMAbu/eyK3AY8L4SYJUG62Ekocw==",
"dev": true,
"peerDependencies": {
"react": ">=16"
}
},
"node_modules/@swc/counter": { "node_modules/@swc/counter": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@@ -3454,6 +3475,19 @@
"@types/debounce": "*" "@types/debounce": "*"
} }
}, },
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="
},
"node_modules/@types/whatwg-url": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
"dependencies": {
"@types/webidl-conversions": "*"
}
},
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "5.51.0", "version": "5.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz",
@@ -4000,6 +4034,25 @@
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
"node_modules/axios-cache-interceptor": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-1.5.3.tgz",
"integrity": "sha512-kPgGId9XW7tR+VF7hgSkqF4f6FrV4ecCyKxjkD9v1hNJ4sXSAskocr7SMKaVHVvrbzVeruwB6yL6Y9/lY1ApKg==",
"dependencies": {
"cache-parser": "1.2.5",
"fast-defer": "1.1.8",
"object-code": "1.3.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/arthurfiorette/axios-cache-interceptor?sponsor=1"
},
"peerDependencies": {
"axios": "^1"
}
},
"node_modules/axobject-query": { "node_modules/axobject-query": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz",
@@ -4056,6 +4109,20 @@
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
"integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==" "integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw=="
}, },
"node_modules/babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
"dependencies": {
"core-js": "^2.4.0",
"regenerator-runtime": "^0.11.0"
}
},
"node_modules/babel-runtime/node_modules/regenerator-runtime": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -4240,6 +4307,14 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/bson": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/bson/-/bson-6.8.0.tgz",
"integrity": "sha512-iOJg8pr7wq2tg/zSlCCHMi3hMm5JTOxLTagf3zxhcenHsFp+c6uOs6K7W5UE7A4QIJGtqh/ZovFNMP4mOPJynQ==",
"engines": {
"node": ">=16.20.1"
}
},
"node_modules/buffer": { "node_modules/buffer": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -4303,6 +4378,11 @@
"node": ">=10.16.0" "node": ">=10.16.0"
} }
}, },
"node_modules/cache-parser": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/cache-parser/-/cache-parser-1.2.5.tgz",
"integrity": "sha512-Md/4VhAHByQ9frQ15WD6LrMNiVw9AEl/J7vWIXw+sxT6fSOpbtt6LHTp76vy8+bOESPBO94117Hm2bIjlI7XjA=="
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
@@ -4619,6 +4699,13 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/core-js": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
"hasInstallScript": true
},
"node_modules/core-util-is": { "node_modules/core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -6023,6 +6110,11 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
}, },
"node_modules/fast-defer": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/fast-defer/-/fast-defer-1.1.8.tgz",
"integrity": "sha512-lEJeOH5VL5R09j6AA0D4Uvq7AgsHw0dAImQQ+F3iSyHZuAxyQfWobsagGpTcOPvJr3urmKRHrs+Gs9hV+/Qm/Q=="
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.2.12", "version": "3.2.12",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
@@ -6224,6 +6316,17 @@
"@google-cloud/storage": "^6.9.5" "@google-cloud/storage": "^6.9.5"
} }
}, },
"node_modules/firebase-scrypt": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/firebase-scrypt/-/firebase-scrypt-2.2.0.tgz",
"integrity": "sha512-36vJZVPFepErsNw+nBjb9cpM9wYPtcxk1bKN//vLdVkNPhaw1cogzwxtMs0s+dYg1gvBDakg2Q4ch8zAWAvnxA==",
"dependencies": {
"babel-runtime": "^6.26.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/firebase/node_modules/@firebase/util": { "node_modules/firebase/node_modules/@firebase/util": {
"version": "1.9.3", "version": "1.9.3",
"resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.9.3.tgz", "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.9.3.tgz",
@@ -8339,6 +8442,11 @@
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
}, },
"node_modules/memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -8470,6 +8578,91 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/mongodb": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.8.1.tgz",
"integrity": "sha512-qsS+gl5EJb+VzJqUjXSZ5Y5rbuM/GZlZUEJ2OIVYP10L9rO9DQ0DGp+ceTzsmoADh6QYMWd9MSdG9IxRyYUkEA==",
"dependencies": {
"@mongodb-js/saslprep": "^1.1.5",
"bson": "^6.7.0",
"mongodb-connection-string-url": "^3.0.0"
},
"engines": {
"node": ">=16.20.1"
},
"peerDependencies": {
"@aws-sdk/credential-providers": "^3.188.0",
"@mongodb-js/zstd": "^1.1.0",
"gcp-metadata": "^5.2.0",
"kerberos": "^2.0.1",
"mongodb-client-encryption": ">=6.0.0 <7",
"snappy": "^7.2.2",
"socks": "^2.7.1"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-providers": {
"optional": true
},
"@mongodb-js/zstd": {
"optional": true
},
"gcp-metadata": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"snappy": {
"optional": true
},
"socks": {
"optional": true
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz",
"integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==",
"dependencies": {
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^13.0.0"
}
},
"node_modules/mongodb-connection-string-url/node_modules/tr46": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz",
"integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==",
"dependencies": {
"punycode": "^2.3.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"engines": {
"node": ">=12"
}
},
"node_modules/mongodb-connection-string-url/node_modules/whatwg-url": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz",
"integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==",
"dependencies": {
"tr46": "^4.1.1",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -8697,6 +8890,11 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-code": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/object-code/-/object-code-1.3.3.tgz",
"integrity": "sha512-/Ds4Xd5xzrtUOJ+xJQ57iAy0BZsZltOHssnDgcZ8DOhgh41q1YJCnTPnWdWSLkNGNnxYzhYChjc5dgC9mEERCA=="
},
"node_modules/object-hash": { "node_modules/object-hash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
@@ -9635,9 +9833,9 @@
} }
}, },
"node_modules/react-icons": { "node_modules/react-icons": {
"version": "4.10.1", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.10.1.tgz", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz",
"integrity": "sha512-/ngzDP/77tlCfqthiiGNZeYFACw85fUjZtLbedmJ5DTlNDIwETxhwBzdOJ21zj4iJdvc0J3y7yOsX3PpxAJzrw==", "integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==",
"peerDependencies": { "peerDependencies": {
"react": "*" "react": "*"
} }
@@ -10348,6 +10546,14 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"dependencies": {
"memory-pager": "^1.0.2"
}
},
"node_modules/stop-iteration-iterator": { "node_modules/stop-iteration-iterator": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
@@ -10632,10 +10838,11 @@
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==" "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
}, },
"node_modules/swr": { "node_modules/swr": {
"version": "2.1.3", "version": "2.2.5",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.1.3.tgz", "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz",
"integrity": "sha512-g3ApxIM4Fjbd6vvEAlW60hJlKcYxHb+wtehogTygrh6Jsw7wNagv9m4Oj5Gq6zvvZw0tcyhVGL9L0oISvl3sUw==", "integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==",
"dependencies": { "dependencies": {
"client-only": "^0.0.1",
"use-sync-external-store": "^1.2.0" "use-sync-external-store": "^1.2.0"
}, },
"peerDependencies": { "peerDependencies": {
@@ -13130,6 +13337,14 @@
"prop-types": "^15.7.2" "prop-types": "^15.7.2"
} }
}, },
"@mongodb-js/saslprep": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz",
"integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==",
"requires": {
"sparse-bitfield": "^3.0.3"
}
},
"@next/env": { "@next/env": {
"version": "14.2.5", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
@@ -13868,6 +14083,12 @@
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz",
"integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==" "integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg=="
}, },
"@simbathesailor/use-what-changed": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@simbathesailor/use-what-changed/-/use-what-changed-2.0.0.tgz",
"integrity": "sha512-ulBNrPSvfho9UN6zS2fii3AsdEcp2fMaKeqUZZeCNPaZbB6aXyTUhpEN9atjMAbu/eyK3AY8L4SYJUG62Ekocw==",
"dev": true
},
"@swc/counter": { "@swc/counter": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@@ -14254,6 +14475,19 @@
"@types/debounce": "*" "@types/debounce": "*"
} }
}, },
"@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="
},
"@types/whatwg-url": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
"requires": {
"@types/webidl-conversions": "*"
}
},
"@typescript-eslint/parser": { "@typescript-eslint/parser": {
"version": "5.51.0", "version": "5.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz",
@@ -14645,6 +14879,16 @@
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
"axios-cache-interceptor": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-1.5.3.tgz",
"integrity": "sha512-kPgGId9XW7tR+VF7hgSkqF4f6FrV4ecCyKxjkD9v1hNJ4sXSAskocr7SMKaVHVvrbzVeruwB6yL6Y9/lY1ApKg==",
"requires": {
"cache-parser": "1.2.5",
"fast-defer": "1.1.8",
"object-code": "1.3.3"
}
},
"axobject-query": { "axobject-query": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz",
@@ -14697,6 +14941,22 @@
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
"integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==" "integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw=="
}, },
"babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
"requires": {
"core-js": "^2.4.0",
"regenerator-runtime": "^0.11.0"
},
"dependencies": {
"regenerator-runtime": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
}
}
},
"balanced-match": { "balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -14820,6 +15080,11 @@
"update-browserslist-db": "^1.0.10" "update-browserslist-db": "^1.0.10"
} }
}, },
"bson": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/bson/-/bson-6.8.0.tgz",
"integrity": "sha512-iOJg8pr7wq2tg/zSlCCHMi3hMm5JTOxLTagf3zxhcenHsFp+c6uOs6K7W5UE7A4QIJGtqh/ZovFNMP4mOPJynQ=="
},
"buffer": { "buffer": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -14857,6 +15122,11 @@
"streamsearch": "^1.1.0" "streamsearch": "^1.1.0"
} }
}, },
"cache-parser": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/cache-parser/-/cache-parser-1.2.5.tgz",
"integrity": "sha512-Md/4VhAHByQ9frQ15WD6LrMNiVw9AEl/J7vWIXw+sxT6fSOpbtt6LHTp76vy8+bOESPBO94117Hm2bIjlI7XjA=="
},
"call-bind": { "call-bind": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
@@ -15083,6 +15353,11 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
}, },
"core-js": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ=="
},
"core-util-is": { "core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -16181,6 +16456,11 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
}, },
"fast-defer": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/fast-defer/-/fast-defer-1.1.8.tgz",
"integrity": "sha512-lEJeOH5VL5R09j6AA0D4Uvq7AgsHw0dAImQQ+F3iSyHZuAxyQfWobsagGpTcOPvJr3urmKRHrs+Gs9hV+/Qm/Q=="
},
"fast-glob": { "fast-glob": {
"version": "3.2.12", "version": "3.2.12",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
@@ -16352,6 +16632,14 @@
"uuid": "^9.0.0" "uuid": "^9.0.0"
} }
}, },
"firebase-scrypt": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/firebase-scrypt/-/firebase-scrypt-2.2.0.tgz",
"integrity": "sha512-36vJZVPFepErsNw+nBjb9cpM9wYPtcxk1bKN//vLdVkNPhaw1cogzwxtMs0s+dYg1gvBDakg2Q4ch8zAWAvnxA==",
"requires": {
"babel-runtime": "^6.26.0"
}
},
"flat-cache": { "flat-cache": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
@@ -17947,6 +18235,11 @@
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
}, },
"memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="
},
"merge2": { "merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -18035,6 +18328,49 @@
"moment": "^2.29.4" "moment": "^2.29.4"
} }
}, },
"mongodb": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.8.1.tgz",
"integrity": "sha512-qsS+gl5EJb+VzJqUjXSZ5Y5rbuM/GZlZUEJ2OIVYP10L9rO9DQ0DGp+ceTzsmoADh6QYMWd9MSdG9IxRyYUkEA==",
"requires": {
"@mongodb-js/saslprep": "^1.1.5",
"bson": "^6.7.0",
"mongodb-connection-string-url": "^3.0.0"
}
},
"mongodb-connection-string-url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz",
"integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==",
"requires": {
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^13.0.0"
},
"dependencies": {
"tr46": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz",
"integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==",
"requires": {
"punycode": "^2.3.0"
}
},
"webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
},
"whatwg-url": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz",
"integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==",
"requires": {
"tr46": "^4.1.1",
"webidl-conversions": "^7.0.0"
}
}
}
},
"ms": { "ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -18186,6 +18522,11 @@
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
}, },
"object-code": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/object-code/-/object-code-1.3.3.tgz",
"integrity": "sha512-/Ds4Xd5xzrtUOJ+xJQ57iAy0BZsZltOHssnDgcZ8DOhgh41q1YJCnTPnWdWSLkNGNnxYzhYChjc5dgC9mEERCA=="
},
"object-hash": { "object-hash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
@@ -18839,9 +19180,9 @@
"integrity": "sha512-y2UpWs82xs+39q5Rc/wq316ca52QsC0n8m801V+yM4IC4hbfOL4yQPVSh7w+ydstdvjN9F+lvs1WrO2VYxpmdA==" "integrity": "sha512-y2UpWs82xs+39q5Rc/wq316ca52QsC0n8m801V+yM4IC4hbfOL4yQPVSh7w+ydstdvjN9F+lvs1WrO2VYxpmdA=="
}, },
"react-icons": { "react-icons": {
"version": "4.10.1", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.10.1.tgz", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz",
"integrity": "sha512-/ngzDP/77tlCfqthiiGNZeYFACw85fUjZtLbedmJ5DTlNDIwETxhwBzdOJ21zj4iJdvc0J3y7yOsX3PpxAJzrw==" "integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg=="
}, },
"react-is": { "react-is": {
"version": "16.13.1", "version": "16.13.1",
@@ -19351,6 +19692,14 @@
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw=="
}, },
"sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"requires": {
"memory-pager": "^1.0.2"
}
},
"stop-iteration-iterator": { "stop-iteration-iterator": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
@@ -19564,10 +19913,11 @@
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==" "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
}, },
"swr": { "swr": {
"version": "2.1.3", "version": "2.2.5",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.1.3.tgz", "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz",
"integrity": "sha512-g3ApxIM4Fjbd6vvEAlW60hJlKcYxHb+wtehogTygrh6Jsw7wNagv9m4Oj5Gq6zvvZw0tcyhVGL9L0oISvl3sUw==", "integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==",
"requires": { "requires": {
"client-only": "^0.0.1",
"use-sync-external-store": "^1.2.0" "use-sync-external-store": "^1.2.0"
} }
}, },

View File

@@ -28,7 +28,8 @@
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@use-gesture/react": "^10.3.1", "@use-gesture/react": "^10.3.1",
"axios": "^1.3.5", "axios": "^1",
"axios-cache-interceptor": "^1",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"chart.js": "^4.2.1", "chart.js": "^4.2.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
@@ -43,6 +44,7 @@
"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",
"firebase-scrypt": "^2.2.0",
"formidable": "^3.5.0", "formidable": "^3.5.0",
"formidable-serverless": "^1.1.1", "formidable-serverless": "^1.1.1",
"framer-motion": "^9.0.2", "framer-motion": "^9.0.2",
@@ -51,6 +53,7 @@
"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",
"mongodb": "^6.8.1",
"next": "^14.2.5", "next": "^14.2.5",
"nodemailer": "^6.9.5", "nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0", "nodemailer-express-handlebars": "^6.1.0",
@@ -66,7 +69,7 @@
"react-diff-viewer": "^3.1.1", "react-diff-viewer": "^3.1.1",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-firebase-hooks": "^5.1.1", "react-firebase-hooks": "^5.1.1",
"react-icons": "^4.8.0", "react-icons": "^5.3.0",
"react-lineto": "^3.3.0", "react-lineto": "^3.3.0",
"react-media-recorder": "1.6.5", "react-media-recorder": "1.6.5",
"react-phone-number-input": "^3.3.6", "react-phone-number-input": "^3.3.6",
@@ -79,7 +82,7 @@
"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.2.5",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwind-scrollbar-hide": "^1.1.7", "tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -90,6 +93,7 @@
"zustand": "^4.3.6" "zustand": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@simbathesailor/use-what-changed": "^2.0.0",
"@types/blob-stream": "^0.1.33", "@types/blob-stream": "^0.1.33",
"@types/formidable": "^3.4.0", "@types/formidable": "^3.4.0",
"@types/howler": "^2.2.11", "@types/howler": "^2.2.11",

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

BIN
public/red-stock-photo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

View File

@@ -17,18 +17,22 @@ import moment from "moment";
interface Props { interface Props {
user: User; user: User;
mutateUser: KeyedMutator<User>; mutateUser: (user: User) => void;
} }
export default function DemographicInformationInput({user, mutateUser}: Props) { export default function DemographicInformationInput({user, mutateUser}: Props) {
const [country, setCountry] = useState<string>(); const [country, setCountry] = useState(user.demographicInformation?.country);
const [phone, setPhone] = useState<string>(); const [phone, setPhone] = useState(user.demographicInformation?.phone);
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined); const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
const [gender, setGender] = useState<Gender>(); const [gender, setGender] = useState<Gender>();
const [employment, setEmployment] = useState<EmploymentStatus>(); const [employment, setEmployment] = useState<EmploymentStatus>();
const [position, setPosition] = useState<string>();
const [timezone, setTimezone] = useState<string>(moment.tz.guess()); const [timezone, setTimezone] = useState<string>(moment.tz.guess());
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [position, setPosition] = useState(
user.type === "corporate" || user.type === "mastercorporate"
? user.demographicInformation?.position
: user.demographicInformation?.employment,
);
const [companyName, setCompanyName] = useState<string>(); const [companyName, setCompanyName] = useState<string>();
const [commercialRegistration, setCommercialRegistration] = useState<string>(); const [commercialRegistration, setCommercialRegistration] = useState<string>();
@@ -38,7 +42,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
setIsLoading(true); setIsLoading(true);
axios axios
.patch("/api/users/update", { .patch<{user: User}>("/api/users/update", {
demographicInformation: { demographicInformation: {
country, country,
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`, phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
@@ -50,7 +54,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
}, },
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined, agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
}) })
.then((response) => mutateUser((response.data as {user: User}).user)) .then((response) => mutateUser(response.data.user))
.catch(() => { .catch(() => {
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"}); toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
}) })
@@ -85,7 +89,15 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
<label className="font-normal text-base text-mti-gray-dim">Country *</label> <label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} /> <CountrySelect value={country} onChange={setCountry} />
</div> </div>
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required /> <Input
type="tel"
name="phone"
label="Phone number"
onChange={(e) => setPhone(e)}
value={phone}
placeholder="Enter phone number"
required
/>
</div> </div>
{user.type === "student" && ( {user.type === "student" && (
<Input <Input
@@ -106,7 +118,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
<GenderInput value={gender} onChange={setGender} /> <GenderInput value={gender} onChange={setGender} />
{user.type === "corporate" && ( {user.type === "corporate" && (
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required /> <Input name="position" onChange={setPosition} type="text" label="Department" placeholder="CEO, Head of Marketing..." required />
)} )}
{user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />} {user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />}
</form> </form>

View File

@@ -0,0 +1,84 @@
import React, { useRef, useEffect, useState } from 'react';
import { animated, useSpring } from '@react-spring/web';
import clsx from 'clsx';
interface MCDropdownProps {
id: string;
options: { [key: string]: string };
onSelect: (value: string) => void;
selectedValue?: string;
className?: string;
width: number;
isOpen: boolean;
onToggle: (id: string) => void;
}
const MCDropdown: React.FC<MCDropdownProps> = ({
id,
options,
onSelect,
selectedValue,
className = "relative",
width,
isOpen,
onToggle,
}) => {
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState(0);
useEffect(() => {
if (contentRef.current) {
setContentHeight(contentRef.current.scrollHeight);
}
}, [options]);
const springProps = useSpring({
height: isOpen ? contentHeight : 0,
opacity: isOpen ? 1 : 0,
config: { tension: 300, friction: 30 }
});
return (
<div className={`${className} inline-block`} style={{ width: `${width}px` }}>
<button
onClick={() => onToggle(id)}
className={
clsx("rounded-full hover:text-white transition duration-300 ease-in-out px-5 py-2 text-center w-full flex items-center justify-between",
selectedValue ? "bg-mti-purple text-white" : "bg-mti-purple-ultralight text-mti-purple-light"
)}
>
<span className="truncate p-1">{selectedValue || 'Select an option'}</span>
<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, width: `${width}px` }}
className="absolute z-10 mt-1 overflow-hidden bg-white rounded-md shadow-lg"
>
<div ref={contentRef}>
{Object.entries(options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => (
<div
key={key}
onClick={() => {
onSelect(value);
onToggle(id);
}}
className="p-4 hover:bg-mti-purple-ultralight cursor-pointer whitespace-nowrap"
>
<span>{value}</span>
</div>
))}
</div>
</animated.div>
</div>
);
};
export default MCDropdown;

View File

@@ -1,51 +1,58 @@
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam"; import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import { Fragment, useEffect, useState } from "react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { CommonProps } from ".."; import { CommonProps } from "..";
import Button from "../../Low/Button"; import Button from "../../Low/Button";
import { v4 } from "uuid"; import { v4 } from "uuid";
import MCDropdown from "./MCDropdown";
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
id, id,
type, type,
prompt, prompt,
solutions, solutions,
text, text,
words, words,
userSolutions, userSolutions,
variant, variant,
onNext, onNext,
onBack, onBack,
}) => { }) => {
const { shuffleMaps, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state); const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions); const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
const dropdownRef = useRef<HTMLDivElement>(null);
const [currentMCSelection, setCurrentMCSelection] = useState<{ id: string, selection: FillBlanksMCOption }>(); const excludeWordMCType = (x: any) => {
return typeof x === "string" ? x : (x as { letter: string; word: string });
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]);
useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
let correctWords: any; let correctWords: any;
if (exam && exam.module === "level" && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") { if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words; correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
} }
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setOpenDropdownId(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const calculateScore = () => { const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
@@ -55,8 +62,8 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
const option = correctWords!.find((w: any) => { const option = correctWords!.find((w: any) => {
if (typeof w === "string") { if (typeof w === "string") {
return w.toLowerCase() === x.solution.toLowerCase(); return w.toLowerCase() === x.solution.toLowerCase();
} else if ('letter' in w) { } else if ("letter" in w) {
return w.word.toLowerCase() === x.solution.toLowerCase(); return w.letter.toLowerCase() === x.solution.toLowerCase();
} else { } else {
return w.id.toString() === x.id.toString(); return w.id.toString() === x.id.toString();
} }
@@ -65,179 +72,168 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
if (typeof option === "string") { if (typeof option === "string") {
return solution.toLowerCase() === option.toLowerCase(); return solution.toLowerCase() === option.toLowerCase();
} else if ('letter' in option) { } else if ("letter" in option) {
return solution.toLowerCase() === option.word.toLowerCase(); return solution.toLowerCase() === option.word.toLowerCase();
} else if ('options' in option) { } else if ("options" in option) {
return option.options[solution as keyof typeof option.options] == x.solution; return option.options[solution as keyof typeof option.options] == x.solution;
} }
return false; return false;
}).length; }).length;
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing }; 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) => { const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id: id, solution: value }]);
}
const getShuffles = () => { const renderLines = useCallback(
let shuffle = {}; (line: string) => {
if (shuffleMaps.length !== 0) { return (
shuffle = { <div className="text-xl leading-5" key={v4()} ref={dropdownRef}>
shuffleMaps: shuffleMaps.filter((map) => {reactStringReplace(line, /({{\d+}})/g, (match) => {
answers.some(answer => answer.id === map.id) const id = match.replaceAll(/[\{\}]/g, "");
) const userSolution = answers.find((x) => x.id === id);
} const styles = clsx(
} "rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center w-fit",
return shuffle; !userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
} userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
);
return ( const currentSelection = words.find((x) => {
<> if (typeof x !== "string" && "id" in x) {
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20"> return (x as FillBlanksMCOption).id.toString() == id.toString();
{false && <span className="text-sm w-full leading-6"> }
{prompt.split("\\n").map((line, index) => ( return false;
<Fragment key={index}> }) as FillBlanksMCOption;
{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 return variant === "mc" ? (
className={clsx( <MCDropdown
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300", id={id}
!!answers.find((x) => x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id) && options={currentSelection.options}
"bg-mti-purple-dark text-white", onSelect={(value) => onSelection(id, value)}
)} selectedValue={userSolution?.solution}
key={v4()} className="inline-block py-2 px-1"
onClick={() => onSelection(currentMCSelection.id, value)} width={220}
> isOpen={openDropdownId === id}
{value} onToggle={()=> setOpenDropdownId(prevId => prevId === id ? null : id)}
</button>;*/ />
})} ) : (
</div> <input
</div> className={styles}
)} onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
</> value={userSolution?.solution}
) : ( />
<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) => { </div >
v = excludeWordMCType(v); );
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`; },
[variant, words, answers, openDropdownId],
);
return ( const memoizedLines = useMemo(() => {
<span return text.split("\\n").map((line, index) => (
className={clsx( <p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300", {renderLines(line)}
!!answers.find((x) => x.solution.toLowerCase() === (typeof v === "string" ? v : ("letter" in v ? v.letter : "")).toLowerCase()) && <br />
"bg-mti-purple-dark text-white", </p>
)} ));
key={v4()} // eslint-disable-next-line react-hooks/exhaustive-deps
> }, [text, variant, renderLines]);
{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 const onSelection = (questionID: string, value: string) => {
color="purple" setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]);
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })} };
className="max-w-[200px] self-end w-full">
Next useEffect(() => {
</Button> if (variant === "mc") {
</div> setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
</> }
); // eslint-disable-next-line react-hooks/exhaustive-deps
} }, [answers]);
return (
<div className="flex flex-col gap-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })}
className="max-w-[200px] w-full"
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
Previous Page
</Button>
<Button
color="purple"
onClick={() => {
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
}}
className="max-w-[200px] self-end w-full">
Next Page
</Button>
</div>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
{variant !== "mc" && (
<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">{memoizedLines}</span>
{variant !== "mc" && (
<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, shuffleMaps: shuffleMaps })}
className="max-w-[200px] w-full"
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
Previous Page
</Button>
<Button
color="purple"
onClick={() => {
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
}}
className="max-w-[200px] self-end w-full">
Next Page
</Button>
</div>
</div>
);
};
export default FillBlanks; export default FillBlanks;

View File

@@ -152,139 +152,8 @@ export default function InteractiveSpeaking({
}; };
return ( return (
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16"> <div className="flex justify-between w-full gap-8">
<div className="flex flex-col gap-3">
<span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
</div>
{prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4 w-full items-center">
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
<source src={prompts[questionIndex].video_url} />
</video>
</div>
)}
</div>
<ReactMediaRecorder
audio
key={questionIndex}
onStop={(blob) => setMediaBlob(blob)}
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">
<p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8">
{status === "idle" && (
<>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && (
<BsMicFill
onClick={() => {
setRecordingDuration(0);
startRecording();
setIsRecording(true);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
)}
</>
)}
{status === "recording" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{Math.floor(recordingDuration / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.padStart(2, "0")}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPauseCircle
onClick={() => {
setIsRecording(false);
pauseRecording();
}}
className="text-red-500 w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{status === "paused" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{Math.floor(recordingDuration / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.padStart(2, "0")}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPlayCircle
onClick={() => {
setIsRecording(true);
resumeRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{status === "stopped" && mediaBlobUrl && (
<>
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center">
<BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={() => {
setRecordingDuration(0);
clearBlobUrl();
setMediaBlob(undefined);
}}
/>
<BsMicFill
onClick={() => {
clearBlobUrl();
setRecordingDuration(0);
startRecording();
setIsRecording(true);
setMediaBlob(undefined);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
</div>
</>
)}
</div>
</div>
)}
/>
<div className="self-end flex justify-between w-full gap-8">
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full"> <Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
@@ -292,6 +161,148 @@ export default function InteractiveSpeaking({
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"} {questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button> </Button>
</div> </div>
<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 gap-3">
<span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
</div>
{prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4 w-full items-center">
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
<source src={prompts[questionIndex].video_url} />
</video>
</div>
)}
</div>
<ReactMediaRecorder
audio
key={questionIndex}
onStop={(blob) => setMediaBlob(blob)}
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">
<p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8">
{status === "idle" && (
<>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && (
<BsMicFill
onClick={() => {
setRecordingDuration(0);
startRecording();
setIsRecording(true);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
)}
</>
)}
{status === "recording" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{Math.floor(recordingDuration / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.padStart(2, "0")}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPauseCircle
onClick={() => {
setIsRecording(false);
pauseRecording();
}}
className="text-red-500 w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{status === "paused" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{Math.floor(recordingDuration / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.padStart(2, "0")}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPlayCircle
onClick={() => {
setIsRecording(true);
resumeRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{status === "stopped" && mediaBlobUrl && (
<>
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center">
<BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={() => {
setRecordingDuration(0);
clearBlobUrl();
setMediaBlob(undefined);
}}
/>
<BsMicFill
onClick={() => {
clearBlobUrl();
setRecordingDuration(0);
startRecording();
setIsRecording(true);
setMediaBlob(undefined);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
</div>
</>
)}
</div>
</div>
)}
/>
<div className="self-end flex justify-between w-full gap-8">
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back
</Button>
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -68,6 +68,13 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
useEffect(() => {
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]);
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
if (event.over && event.over.id.toString().startsWith("droppable")) { if (event.over && event.over.id.toString().startsWith("droppable")) {
const optionID = event.active.id.toString().replace("draggable_option_", ""); const optionID = event.active.id.toString().replace("draggable_option_", "");
@@ -93,7 +100,24 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
}, [hasExamEnded]); }, [hasExamEnded]);
return ( return (
<> <div className="flex flex-col gap-4 mt-4">
<div className="flex justify-between w-full gap-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>
<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"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
@@ -143,6 +167,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -1,12 +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, ShuffleMap} 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 reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { CommonProps } from "."; import {CommonProps} from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
import { v4 } from "uuid"; import {v4} from "uuid";
function Question({ function Question({
id, id,
@@ -18,13 +18,12 @@ function Question({
}: MultipleChoiceQuestion & { }: MultipleChoiceQuestion & {
userSolution: string | undefined; userSolution: string | undefined;
onSelectOption?: (option: string) => void; onSelectOption?: (option: string) => void;
showSolution?: boolean, showSolution?: boolean;
}) { }) {
const renderPrompt = (prompt: string) => { const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => { return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", ""); const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u>{word}</u> : null; return word.length > 0 ? <u key={v4()}>{word}</u> : null;
}); });
}; };
@@ -49,7 +48,9 @@ function Question({
"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", "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 key={v4()} className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>
{option.id.toString()}
</span>
<img src={option.src!} alt={`Option ${option.id.toString()}`} /> <img src={option.src!} alt={`Option ${option.id.toString()}`} />
</div> </div>
))} ))}
@@ -60,7 +61,7 @@ function Question({
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-base select-none", "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() && "!bg-mti-purple-light !text-white",
)}> )}>
<span className="font-semibold">{option.id.toString()}.</span> <span className="font-semibold">{option.id.toString()}.</span>
<span>{option.text}</span> <span>{option.text}</span>
@@ -71,37 +72,38 @@ function Question({
); );
} }
export default function MultipleChoice({ id, prompt, type, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) { export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions); const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const { const {questionIndex, exerciseIndex, exam, shuffles, hasExamEnded, partIndex, setQuestionIndex, setCurrentSolution} = useExamStore(
questionIndex, (state) => state,
exam, );
shuffleMaps,
hasExamEnded, const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
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( if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
[...storeUserSolutions.filter((x) => x.exercise !== id), {
exercise: id, solutions: answers, score: calculateScore(), type
}]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers]);
useEffect(() => {
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]);
const onSelectOption = (option: string) => { const onSelectOption = (option: string, question: MultipleChoiceQuestion) => {
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 }]); };
useEffect(() => {
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]);
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
if (originalPosition === originalSolution) {
return newPosition;
}
}
return originalSolution;
}; };
const calculateScore = () => { const calculateScore = () => {
@@ -112,75 +114,108 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
}); });
let isSolutionCorrect; let isSolutionCorrect;
if (shuffleMaps.length == 0) { if (!shuffleMaps) {
isSolutionCorrect = matchingQuestion?.solution === x.option; isSolutionCorrect = matchingQuestion?.solution === x.option;
} else { } else {
const shuffleMap = shuffleMaps.find((map) => map.id == x.question) const shuffleMap = shuffleMaps.find((map) => map.questionID == x.question);
isSolutionCorrect = shuffleMap?.map[x.option] == matchingQuestion?.solution; if (shuffleMap) {
isSolutionCorrect = getShuffledSolution(x.option, shuffleMap) == matchingQuestion?.solution;
} else {
isSolutionCorrect = matchingQuestion?.solution === x.option;
}
} }
return isSolutionCorrect || false; return isSolutionCorrect || false;
}).length; }).length;
const missing = total - correct; const missing = total - answers!.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length;
return {total, correct, missing};
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 + 1 >= questions.length - 1) {
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() }); onNext({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
} else { } else {
setQuestionIndex(questionIndex + 1); setQuestionIndex(questionIndex + 2);
} }
scrollToTop(); scrollToTop();
}; };
const back = () => { const back = () => {
if (questionIndex === 0) { if (questionIndex === 0) {
onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() }); onBack({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
} else { } else {
setQuestionIndex(questionIndex - 1); if (exam?.module === "level" && typeof exam.parts[0].intro !== "undefined" && questionIndex === 0) return;
setQuestionIndex(questionIndex - 2);
} }
scrollToTop(); scrollToTop();
}; };
return ( return (
<> <div className="flex flex-col gap-4">
<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 justify-between w-full gap-8">
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/} <Button
{questionIndex < questions.length && ( color="purple"
<Question variant="outline"
{...questions[questionIndex]} onClick={back}
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option} className="max-w-[200px] w-full"
onSelectOption={onSelectOption} disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
/>
)}
</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={back} className="max-w-[200px] w-full"
disabled={
exam && exam.module === "level" && typeof exam.parts[0].intro === "string" && questionIndex === 0}
>
Back Back
</Button> </Button>
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full"> <Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
Next {exam &&
exam.module === "level" &&
partIndex === exam.parts.length - 1 &&
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
questionIndex + 1 >= questions.length - 1
? "Submit"
: "Next"}
</Button> </Button>
</div> </div>
</>
<div className="flex flex-col gap-4 mt-4 mb-20">
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
{questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
onSelectOption={(option) => onSelectOption(option, questions[questionIndex])}
/>
)}
</div>
{questionIndex + 1 < questions.length && (
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<Question
{...questions[questionIndex + 1]}
userSolution={answers.find((x) => questions[questionIndex + 1].id === x.question)?.option}
onSelectOption={(option) => onSelectOption(option, questions[questionIndex + 1])}
/>
</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={back}
className="max-w-[200px] w-full"
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
Back
</Button>
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
{exam &&
exam.module === "level" &&
partIndex === exam.parts.length - 1 &&
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
questionIndex + 1 >= questions.length - 1
? "Submit"
: "Next"}
</Button>
</div>
</div>
); );
} }

View File

@@ -1,20 +1,20 @@
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"; 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, suffix, 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>();
@@ -28,7 +28,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
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.", "");
@@ -42,8 +42,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
}, },
}; };
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;
} }
@@ -52,7 +52,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
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);
} }
@@ -79,8 +79,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
const next = async () => { const next = async () => {
onNext({ onNext({
exercise: id, exercise: id,
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [], solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: { correct: 0, total: 100, missing: 0 }, score: {correct: 0, total: 100, missing: 0},
type, type,
}); });
}; };
@@ -88,8 +88,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
const back = async () => { const back = async () => {
onBack({ onBack({
exercise: id, exercise: id,
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [], solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: { correct: 0, total: 100, missing: 0 }, score: {correct: 0, total: 100, missing: 0},
type, type,
}); });
}; };
@@ -98,7 +98,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
const newText = e.target.value; const newText = e.target.value;
const words = newText.match(/\S+/g); const words = newText.match(/\S+/g);
const wordCount = words ? words.length : 0; const wordCount = words ? words.length : 0;
if (wordCount <= 100) { if (wordCount <= 100) {
setInputText(newText); setInputText(newText);
} else { } else {
@@ -110,188 +110,14 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
if (count > 100) break; if (count > 100) break;
lastIndex = match.index! + match[0].length; lastIndex = match.index! + match[0].length;
} }
setInputText(newText.slice(0, lastIndex)); setInputText(newText.slice(0, lastIndex));
} }
}; };
return ( return (
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col gap-4 mt-4 w-full">
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}> <div className="flex justify-between w-full gap-8">
<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 gap-3">
<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 && (
<span className="font-regular">
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
<span>{line}</span>
<br />
</Fragment>
))}
</span>
)}
</div>
<div className="flex flex-col gap-6 items-center">
{video_url && (
<div className="flex flex-col gap-4 w-full items-center">
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
<source src={video_url} />
</video>
</div>
)}
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
</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
audio
onStop={(blob) => setMediaBlob(blob)}
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">
<p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8">
{status === "idle" && !mediaBlob && (
<>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && (
<BsMicFill
onClick={() => {
setRecordingDuration(0);
startRecording();
setIsRecording(true);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
)}
</>
)}
{status === "recording" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{Math.floor(recordingDuration / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.padStart(2, "0")}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPauseCircle
onClick={() => {
setIsRecording(false);
pauseRecording();
}}
className="text-red-500 w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{status === "paused" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{Math.floor(recordingDuration / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.padStart(2, "0")}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPlayCircle
onClick={() => {
setIsRecording(true);
resumeRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
<>
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center">
<BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={() => {
setRecordingDuration(0);
clearBlobUrl();
setMediaBlob(undefined);
}}
/>
<BsMicFill
onClick={() => {
clearBlobUrl();
setRecordingDuration(0);
startRecording();
setIsRecording(true);
setMediaBlob(undefined);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
</div>
</>
)}
</div>
</div>
)}
/>
<div className="self-end flex justify-between w-full gap-8">
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full"> <Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
@@ -299,6 +125,193 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
Next Next
</Button> </Button>
</div> </div>
<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 gap-3">
<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 && (
<span className="font-regular">
{text.split("\\n").map((line, index) => (
<Fragment key={index}>
<span>{line}</span>
<br />
</Fragment>
))}
</span>
)}
</div>
<div className="flex flex-col gap-6 items-center">
{video_url && (
<div className="flex flex-col gap-4 w-full items-center">
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
<source src={video_url} />
</video>
</div>
)}
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
</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
audio
onStop={(blob) => setMediaBlob(blob)}
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">
<p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8">
{status === "idle" && !mediaBlob && (
<>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && (
<BsMicFill
onClick={() => {
setRecordingDuration(0);
startRecording();
setIsRecording(true);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
)}
</>
)}
{status === "recording" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{Math.floor(recordingDuration / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.padStart(2, "0")}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPauseCircle
onClick={() => {
setIsRecording(false);
pauseRecording();
}}
className="text-red-500 w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{status === "paused" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{Math.floor(recordingDuration / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.padStart(2, "0")}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPlayCircle
onClick={() => {
setIsRecording(true);
resumeRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
<>
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center">
<BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={() => {
setRecordingDuration(0);
clearBlobUrl();
setMediaBlob(undefined);
}}
/>
<BsMicFill
onClick={() => {
clearBlobUrl();
setRecordingDuration(0);
startRecording();
setIsRecording(true);
setMediaBlob(undefined);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
</div>
</>
)}
</div>
</div>
)}
/>
<div className="self-end flex justify-between w-full gap-8">
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back
</Button>
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -8,6 +8,7 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions); const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
useEffect(() => { useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
@@ -28,6 +29,11 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
return {total, correct, missing}; return {total, correct, missing};
}; };
useEffect(() => {
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]);
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => { const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
const answer = answers.find((x) => x.id === questionId); const answer = answers.find((x) => x.id === questionId);
if (answer && answer.solution === solution) { if (answer && answer.solution === solution) {
@@ -39,7 +45,24 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
}; };
return ( return (
<> <div className="flex flex-col gap-4 mt-4">
<div className="flex justify-between w-full gap-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>
<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"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
@@ -116,6 +139,6 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -49,7 +49,7 @@ function Blank({
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) { export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions); const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const {hasExamEnded, setCurrentSolution} = useExamStore((state) => state);
useEffect(() => { useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
@@ -70,6 +70,11 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
return {total, correct, missing}; return {total, correct, missing};
}; };
useEffect(() => {
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]);
const renderLines = (line: string) => { const renderLines = (line: string) => {
return ( return (
<span className="text-base leading-5"> <span className="text-base leading-5">
@@ -87,7 +92,24 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
}; };
return ( return (
<> <div className="flex flex-col gap-4">
<div className="flex justify-between w-full gap-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>
<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"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
@@ -123,6 +145,6 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -84,7 +84,34 @@ export default function Writing({
}, [inputText, wordCounter]); }, [inputText, wordCounter]);
return ( return (
<> <div className="flex flex-col gap-4 mt-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
disabled={!isSubmitEnabled}
onClick={() =>
onNext({
exercise: id,
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
score: {correct: 100, total: 100, missing: 0},
type,
module: "writing",
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{attachment && ( {attachment && (
<Transition show={isModalOpen} as={Fragment}> <Transition show={isModalOpen} as={Fragment}>
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50"> <Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
@@ -170,6 +197,6 @@ export default function Writing({
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
import {FillBlanksExercise} from "@/interfaces/exam"; import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import React from "react"; import React from "react";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import clsx from "clsx";
interface Props { interface Props {
exercise: FillBlanksExercise; exercise: FillBlanksExercise;
@@ -8,11 +9,16 @@ interface Props {
} }
const FillBlanksEdit = (props: Props) => { const FillBlanksEdit = (props: Props) => {
const {exercise, updateExercise} = props; const { exercise, updateExercise } = props;
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
};
return ( return (
<> <>
<Input <Input
type="text" type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
label="Prompt" label="Prompt"
name="prompt" name="prompt"
required required
@@ -24,18 +30,18 @@ const FillBlanksEdit = (props: Props) => {
} }
/> />
<Input <Input
type="text" type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
label="Text" label="Text"
name="text" name="text"
required required
value={exercise.text} value={exercise.text}
onChange={(value) => onChange={(value) =>
updateExercise({ updateExercise({
text: value, text: exercise?.variant && exercise.variant === "mc" ? value : value,
}) })
} }
/> />
<h1>Solutions</h1> <h1 className="mt-4">Solutions</h1>
<div className="w-full flex flex-wrap -mx-2"> <div className="w-full flex flex-wrap -mx-2">
{exercise.solutions.map((solution, index) => ( {exercise.solutions.map((solution, index) => (
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2"> <div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
@@ -47,33 +53,75 @@ const FillBlanksEdit = (props: Props) => {
value={solution.solution} value={solution.solution}
onChange={(value) => onChange={(value) =>
updateExercise({ updateExercise({
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? {...sol, solution: value} : sol)), solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? { ...sol, solution: value } : sol)),
}) })
} }
/> />
</div> </div>
))} ))}
</div> </div>
<h1>Words</h1> <h1 className="mt-4">Words</h1>
<div className="w-full flex flex-wrap -mx-2"> <div className={clsx(exercise?.variant && exercise.variant === "mc" ? "w-full flex flex-row" : "w-full flex flex-wrap -mx-2")}>
{exercise.words.map((word, index) => ( {exercise?.variant && exercise.variant === "mc" && typeCheckWordsMC(exercise.words) ?
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2"> (
<Input <div className="flex flex-col w-full">
type="text" {exercise.words.flatMap((mcOptions, wordIndex) =>
label={`Word ${index + 1}`} <>
name="word" <label className="font-semibold">{`Word ${wordIndex + 1}`}</label>
required <div className="flex flex-row">
value={typeof word === "string" ? word : ("word" in word ? word.word : "")} {Object.entries(mcOptions.options).map(([key, value], optionIndex) => (
onChange={(value) => <div key={`${wordIndex}-${optionIndex}-${key}`} className="flex sm:w-1/2 lg:w-1/4 px-2 mb-4">
updateExercise({ <Input
words: exercise.words.map((sol, idx) => type="text"
index === idx ? (typeof word === "string" ? value : {...word, word: value}) : sol, label={`Option ${key}`}
), name="word"
}) required
} value={value}
/> onChange={(newValue) =>
</div> updateExercise({
))} words: exercise.words.map((word, idx) =>
idx === wordIndex
? {
...(word as FillBlanksMCOption),
options: {
...(word as FillBlanksMCOption).options,
[key]: newValue
}
}
: word
)
})
}
/>
</div>
))}
</div>
</>
)}
</div>
)
:
(
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> </div>
</> </>
); );

View File

@@ -1,39 +1,47 @@
import { useCallback } from "react"; import React, { useCallback } from "react";
import { HighlightConfig, HighlightTarget } from "@/training/TrainingInterfaces";
const HighlightContent: React.FC<{ interface HighlightedContentProps {
html: string; html: string;
highlightPhrases: string[], highlightConfigs: HighlightConfig[];
firstOccurence?: boolean contentType: HighlightTarget;
}> = ({ currentSegmentIndex?: number;
}
const HighlightedContent: React.FC<HighlightedContentProps> = ({
html, html,
highlightPhrases, highlightConfigs,
firstOccurence = false contentType,
currentSegmentIndex
}) => { }) => {
const createHighlightedContent = useCallback(() => { 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; let highlightedHtml = html;
highlightConfigs.forEach(config => {
if (config.targets.includes(contentType) || config.targets.includes('all')) {
const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
if (firstOccurence) { const regex = new RegExp(config.phrases.map(escapeRegExp).join('|'), 'g');
highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
} else { if (contentType === 'segment' && currentSegmentIndex !== undefined) {
highlightedHtml = html.replace(globalRegex, (match) => `<span style="background-color: yellow;">${match}</span>`); const segments = highlightedHtml.split('</div>');
} segments[currentSegmentIndex] = segments[currentSegmentIndex].replace(regex, (match) => {
return `<span style="background-color: #FFFACD;">${match}</span>`;
});
highlightedHtml = segments.join('</div>');
} else {
highlightedHtml = highlightedHtml.replace(regex, (match) => {
return `<span style="background-color: #FFFACD;">${match}</span>`;
});
}
}
});
return { __html: highlightedHtml }; return { __html: highlightedHtml };
}, [html, highlightPhrases, firstOccurence]); }, [html, highlightConfigs, contentType, currentSegmentIndex]);
return <div dangerouslySetInnerHTML={createHighlightedContent()} />; return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
}; };
export default HighlightContent; export default HighlightedContent;

View File

@@ -1,51 +1,77 @@
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table"; import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
import {useMemo, useState} from "react";
import Button from "./Low/Button";
const SIZE = 25;
export default function List<T>({data, columns}: {data: T[]; columns: any[]}) { export default function List<T>({data, columns}: {data: T[]; columns: any[]}) {
const [page, setPage] = useState(0);
const items = useMemo(() => data.slice(page * SIZE, (page + 1) * SIZE > data.length ? data.length : (page + 1) * SIZE), [data, page]);
const table = useReactTable({ const table = useReactTable({
data, data: items,
columns: columns, columns: columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
}); });
return ( return (
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full"> <div className="w-full h-full flex flex-col gap-2">
<thead> <div className="w-full flex gap-2 justify-between">
{table.getHeaderGroups().map((headerGroup) => ( <Button className="w-full max-w-[200px]" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}>
<tr key={headerGroup.id}> Previous Page
{headerGroup.headers.map((header) => ( </Button>
<th key={header.id} colSpan={header.colSpan}> <div className="flex items-center gap-4 w-fit">
{header.isPlaceholder ? null : ( <span className="opacity-80">
<> {page * SIZE + 1} - {(page + 1) * SIZE > data.length ? data.length : (page + 1) * SIZE} / {data.length}
<div </span>
{...{ <Button className="w-[200px]" disabled={(page + 1) * SIZE >= data.length} onClick={() => setPage((prev) => prev + 1)}>
className: header.column.getCanSort() ? "cursor-pointer select-none py-4 text-left first:pl-4" : "", Next Page
onClick: header.column.getToggleSortingHandler(), </Button>
}}> </div>
{flexRender(header.column.columnDef.header, header.getContext())} </div>
{{
asc: " 🔼", <table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
desc: " 🔽", <thead>
}[header.column.getIsSorted() as string] ?? null} {table.getHeaderGroups().map((headerGroup) => (
</div> <tr key={headerGroup.id}>
</> {headerGroup.headers.map((header) => (
)} <th key={header.id} colSpan={header.colSpan}>
</th> {header.isPlaceholder ? null : (
))} <>
</tr> <div
))} {...{
</thead> className: header.column.getCanSort()
<tbody className="px-2"> ? "cursor-pointer select-none py-4 text-left first:pl-4"
{table.getRowModel().rows.map((row) => ( : "",
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}> onClick: header.column.getToggleSortingHandler(),
{row.getVisibleCells().map((cell) => ( }}>
<td className="px-4 py-2" key={cell.id}> {flexRender(header.column.columnDef.header, header.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())} {{
</td> asc: " 🔼",
))} desc: " 🔽",
</tr> }[header.column.getIsSorted() as string] ?? null}
))} </div>
</tbody> </>
</table> )}
</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>
</div>
); );
} }

View File

@@ -9,6 +9,7 @@ interface Props {
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
isLoading?: boolean; isLoading?: boolean;
padding?: string;
onClick?: () => void; onClick?: () => void;
type?: "button" | "reset" | "submit"; type?: "button" | "reset" | "submit";
} }
@@ -21,6 +22,7 @@ export default function Button({
className, className,
children, children,
type, type,
padding = "py-4 px-6",
onClick, onClick,
}: Props) { }: Props) {
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = { const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
@@ -61,7 +63,8 @@ export default function Button({
type={type} type={type}
onClick={onClick} onClick={onClick}
className={clsx( className={clsx(
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer", "rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer select-none",
padding,
colorClassNames[color][variant], colorClassNames[color][variant],
className, className,
)} )}

View File

@@ -11,14 +11,16 @@ interface Props {
export default function Checkbox({isChecked, onChange, children, disabled}: Props) { export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
return ( return (
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => { <div
if(disabled) return; className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer"
onChange(!isChecked); onClick={() => {
}}> if (disabled) return;
onChange(!isChecked);
}}>
<input type="checkbox" className="hidden" /> <input type="checkbox" className="hidden" />
<div <div
className={clsx( className={clsx(
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white", "w-6 h-6 min-w-6 min-h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
isChecked && "!bg-mti-purple-light ", isChecked && "!bg-mti-purple-light ",
)}> )}>

View File

@@ -1,8 +1,8 @@
import clsx from "clsx"; import clsx from "clsx";
import {useState} from "react"; import { useState } from "react";
interface Props { interface Props {
type: "email" | "text" | "password" | "tel" | "number"; type: "email" | "text" | "password" | "tel" | "number" | "textarea";
roundness?: "full" | "xl"; roundness?: "full" | "xl";
required?: boolean; required?: boolean;
label?: string; label?: string;
@@ -11,6 +11,7 @@ interface Props {
value?: string | number; value?: string | number;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
max?: number;
name: string; name: string;
onChange: (value: string) => void; onChange: (value: string) => void;
} }
@@ -23,6 +24,7 @@ export default function Input({
required = false, required = false,
value, value,
defaultValue, defaultValue,
max,
className, className,
roundness = "full", roundness = "full",
disabled = false, disabled = false,
@@ -30,6 +32,20 @@ export default function Input({
}: Props) { }: Props) {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
if (type === "textarea") {
return (
<textarea
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl min-h-[200px]"
onChange={(e) => onChange(e.target.value)}
value={value}
placeholder={placeholder}
spellCheck={false}
/>
);
}
if (type === "password") { if (type === "password") {
return ( return (
<div className="relative flex flex-col gap-3 w-full"> <div className="relative flex flex-col gap-3 w-full">
@@ -72,6 +88,7 @@ export default function Input({
name={name} name={name}
disabled={disabled} disabled={disabled}
value={value} value={value}
max={max}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
min={type === "number" ? 0 : undefined} min={type === "number" ? 0 : undefined}
placeholder={placeholder} placeholder={placeholder}

View File

@@ -1,30 +1,55 @@
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import useExamStore from "@/stores/examStore"; import { moduleLabels } from "@/utils/moduleUtils";
import {moduleLabels} from "@/utils/moduleUtils";
import clsx from "clsx"; import clsx from "clsx";
import {motion} from "framer-motion"; import { ReactNode, useState } from "react";
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 Timer from "./Timer"; import Timer from "./Timer";
import { Exercise, LevelExam, MultipleChoiceExercise, ShuffleMap, UserSolution } from "@/interfaces/exam";
import { BsFillGrid3X3GapFill } from "react-icons/bs";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
import Modal from "../Modal";
import React from "react";
interface Props { interface Props {
minTimer: number; minTimer: number;
module: Module; module: Module;
examLabel?: string;
label?: string; label?: string;
exerciseIndex: number; exerciseIndex: number;
totalExercises: number; totalExercises: number;
disableTimer?: boolean; disableTimer?: boolean;
partLabel?: string; partLabel?: string;
showTimer?: boolean; showTimer?: boolean;
showSolutions?: boolean;
currentExercise?: Exercise;
runOnClick?: ((questionIndex: number) => void) | undefined;
} }
export default function ModuleTitle({ export default function ModuleTitle({
minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel, showTimer = true minTimer,
module,
label,
examLabel,
exerciseIndex,
totalExercises,
disableTimer = false,
partLabel,
showTimer = true,
showSolutions = false,
runOnClick = undefined
}: Props) { }: Props) {
const {
userSolutions,
partIndex,
exam
} = useExamStore((state) => state);
const examExerciseIndex = useExamStore((state) => state.exerciseIndex)
const moduleIcon: {[key in Module]: ReactNode} = { const [isOpen, setIsOpen] = useState(false);
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" />,
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />, listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
writing: <BsPen className="text-ielts-writing w-6 h-6" />, writing: <BsPen className="text-ielts-writing w-6 h-6" />,
@@ -32,24 +57,97 @@ export default function ModuleTitle({
level: <BsClipboard className="text-ielts-level w-6 h-6" />, level: <BsClipboard className="text-ielts-level w-6 h-6" />,
}; };
const isMultipleChoiceLevelExercise = () => {
if (exam?.module === 'level' && typeof partIndex === "number" && partIndex > -1) {
const currentExercise = (exam as LevelExam).parts[partIndex].exercises[examExerciseIndex];
return currentExercise && currentExercise.type === 'multipleChoice';
}
return false;
};
const renderMCQuestionGrid = () => {
if (!isMultipleChoiceLevelExercise() && !userSolutions) return null;
const currentExercise = (exam as LevelExam).parts[partIndex!].exercises[examExerciseIndex] as MultipleChoiceExercise;
const userSolution = userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!;
const answeredQuestions = new Set(userSolution.solutions.map(sol => sol.question.toString()));
const exerciseOffset = Number(currentExercise.questions[0].id);
const lastExercise = exerciseOffset + (currentExercise.questions.length - 1);
const getQuestionColor = (questionId: string, solution: string, userQuestionSolution: string | undefined) => {
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
if (foundMap) return foundMap;
return userSolution.shuffleMaps?.find(map => map.questionID.toString() === questionId.toString()) || null;
}, null as ShuffleMap | null);
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
if (!userSolutions) return "";
if (!userQuestionSolution) {
return "!bg-mti-gray-davy !border--mti-gray-davy !text-mti-gray-davy !text-white hover:!bg-gray-700";
}
return userQuestionSolution === newSolution ?
"!bg-mti-purple-light !text-mti-purple-light !text-white hover:!bg-mti-purple-dark" :
"!bg-mti-rose-light !border-mti-rose-light !text-mti-rose-light !text-white hover:!bg-mti-rose-dark";
}
return (
<>
<h3 className="text-xl font-semibold mb-4 text-center">{`Part ${partIndex + 1} (Questions ${exerciseOffset} - ${lastExercise})`}</h3>
<div className="grid grid-cols-5 gap-3 px-4 py-2">
{currentExercise.questions.map((_, index) => {
const questionNumber = exerciseOffset + index;
const isAnswered = answeredQuestions.has(questionNumber.toString());
const solution = currentExercise.questions.find((x) => x.id.toString() == questionNumber.toString())!.solution;
const userQuestionSolution = currentExercise.userSolutions?.find((x) => x.question.toString() == questionNumber.toString())?.option;
return (
<Button
variant={showSolutions ? "solid" : (isAnswered ? "solid" : "outline")}
key={index}
className={clsx(
"w-12 h-12 flex items-center justify-center rounded-lg text-sm font-bold transition-all duration-200 ease-in-out",
(showSolutions ?
getQuestionColor(questionNumber.toString(), solution, userQuestionSolution) :
(isAnswered ?
"bg-mti-purple-light border-mti-purple-light text-white hover:bg-mti-purple-dark hover:border-mti-purple-dark" :
"bg-white border-gray-400 hover:bg-gray-100 hover:text-gray-700"
)
)
)}
onClick={() => { if (typeof runOnClick !== "undefined") { runOnClick(index); } setIsOpen(false); }}
>
{questionNumber}
</Button>
);
})}
</div>
<p className="mt-4 text-sm text-gray-600 text-center">
Click a question number to jump to that question
</p>
</>
);
};
return ( return (
<> <>
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />} {showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
<div className="w-full"> <div className="w-full">
{partLabel && ( {partLabel && (
<div className="text-3xl space-y-4"> <div className="text-3xl space-y-4">
{partLabel.split("\n\n").map((line, index) => { {partLabel.split("\n\n").map((partInstructions, index) => {
if (index == 0) if (index === 0)
return ( return (
<p key={index} className="font-bold"> <p key={index} className="font-bold">
{line} {partInstructions}
</p> </p>
); );
else else
return ( return (
<p key={index} className="text-2xl font-semibold"> <div key={index} className="text-2xl font-semibold flex flex-col gap-2">
{line} {partInstructions.split("\\n").map((line, lineIndex) => (
</p> <span key={lineIndex} dangerouslySetInnerHTML={{__html: line.replace('that is not correct', 'that is <span class="font-bold"><u>not correct</u></span>')}}></span>
))}
</div>
); );
})} })}
</div> </div>
@@ -59,7 +157,10 @@ export default function ModuleTitle({
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<div className="w-full flex justify-between"> <div className="w-full flex justify-between">
<span className="text-base font-semibold"> <span className="text-base font-semibold">
{moduleLabels[module]} exam {label && `- ${label}`} {module === "level"
? (examLabel ? examLabel : "Placement Test")
: `${moduleLabels[module]} exam${label ? ` - ${label}` : ''}`
}
</span> </span>
<span className="text-sm font-semibold self-end"> <span className="text-sm font-semibold self-end">
Question {exerciseIndex}/{totalExercises} Question {exerciseIndex}/{totalExercises}
@@ -67,8 +168,24 @@ export default function ModuleTitle({
</div> </div>
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" /> <ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
</div> </div>
{isMultipleChoiceLevelExercise() && (
<>
<Button variant="outline" onClick={() => setIsOpen(true)} padding="p-2" className="rounded-lg">
<BsFillGrid3X3GapFill size={24} />
</Button>
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white shadow-xl transition-all"
>
<>
{renderMCQuestionGrid()}
</>
</Modal>
</>
)}
</div> </div>
</div> </div>
</> </>
); );
} }

View File

@@ -0,0 +1,206 @@
import { User } from "@/interfaces/user";
import { checkAccess } from "@/utils/permissions";
import Select from "../Low/Select";
import { ReactNode, useEffect, useState } from "react";
import clsx from "clsx";
import useUsers from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups";
import useRecordStore from "@/stores/recordStore";
type TimeFilter = "months" | "weeks" | "days";
type Filter = TimeFilter | "assignments" | undefined;
interface Props {
user: User;
filterState: {
filter: Filter,
setFilter: React.Dispatch<React.SetStateAction<Filter>>
},
assignments?: boolean;
children?: ReactNode
}
const defaultSelectableCorporate = {
value: "",
label: "All",
};
const RecordFilter: React.FC<Props> = ({
user,
filterState,
assignments = true,
children
}) => {
const { filter, setFilter } = filterState;
const [statsUserId, setStatsUserId] = useRecordStore((state) => [
state.selectedUser,
state.setSelectedUser
]);
const { users } = useUsers();
const { groups: allGroups } = useGroups({});
const { groups } = useGroups({ admin: user?.id, userType: user?.type });
const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => {
setFilter((prev) => (prev === value ? undefined : value));
};
const selectableCorporates = [
defaultSelectableCorporate,
...users
.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id))
.filter((x) => x.type === "corporate")
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
})),
];
const [selectedCorporate, setSelectedCorporate] = useState<string>(defaultSelectableCorporate.value);
const getUsersList = (): User[] => {
if (selectedCorporate) {
const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate);
const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants);
const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[];
return userListWithUsers.filter((x) => x);
}
return user.type !== "mastercorporate" ? users : users.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id));
};
const corporateFilteredUserList = getUsersList();
const getSelectedUser = () => {
if (selectedCorporate) {
const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId);
return userInCorporate || corporateFilteredUserList[0];
}
return users.find((x) => x.id === statsUserId) || user;
};
const selectedUser = getSelectedUser();
const selectedUserSelectValue = selectedUser
? {
value: selectedUser.id,
label: `${selectedUser.name} - ${selectedUser.email}`,
}
: {
value: "",
label: "",
};
return (
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
<div className="xl:w-3/4">
{checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && (
<>
<label className="font-normal text-base text-mti-gray-dim">Corporate</label>
<Select
options={selectableCorporates}
value={selectableCorporates.find((x) => x.value === selectedCorporate)}
onChange={(value) => setSelectedCorporate(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,
}),
}}></Select>
<label className="font-normal text-base text-mti-gray-dim">User</label>
<Select
options={corporateFilteredUserList.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
value={selectedUserSelectValue}
onChange={(value) => setStatsUserId(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,
}),
}}
/>
</>
)}
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && !children && (
<>
<label className="font-normal text-base text-mti-gray-dim">User</label>
<Select
options={users
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
value={selectedUserSelectValue}
onChange={(value) => setStatsUserId(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,
}),
}}
/>
</>
)}
{children}
</div>
<div className="flex gap-4 w-full justify-center xl:justify-end">
{assignments && (
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "assignments" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("assignments")}>
Assignments
</button>
)}
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "months" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("months")}>
Last month
</button>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "weeks" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("weeks")}>
Last week
</button>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "days" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("days")}>
Last day
</button>
</div>
</div>
);
}
export default RecordFilter;

View File

@@ -40,61 +40,71 @@ export default function SessionCard({
}; };
return ( return (
<div className="border-mti-gray-anti-flash flex w-64 flex-col gap-3 rounded-xl border p-4 text-black"> <div className="border-mti-gray-anti-flash flex w-64 flex-col justify-between gap-3 rounded-xl border p-4 text-black">
<span className="flex gap-1"> <div className="flex flex-col gap-3">
<b>ID:</b> <span className="flex gap-1">
{session.sessionId} <b>ID:</b>
</span> {session.sessionId}
<span className="flex gap-1"> </span>
<b>Date:</b> <span className="flex gap-1">
{moment(session.date).format("DD/MM/YYYY - HH:mm")} <b>Date:</b>
</span> {moment(session.date).format("DD/MM/YYYY - HH:mm")}
<div className="flex w-full items-center justify-between"> </span>
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2"> {session.assignment && (
{session.selectedModules.sort(sortByModuleName).map((module) => ( <span className="flex flex-col gap-0">
<div <b>Assignment:</b>
key={module} {session.assignment.name}
data-tip={capitalize(module)} </span>
className={clsx( )}
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
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="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />}
</div>
))}
</div>
</div> </div>
<div className="flex items-center gap-2 w-full"> <div className="flex flex-col gap-3">
<button <div className="flex w-full items-center justify-between">
onClick={async () => await loadSession(session)} <div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
disabled={isLoading} {session.selectedModules.sort(sortByModuleName).map((module) => (
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"> <div
{!isLoading && "Resume"} key={module}
{isLoading && ( data-tip={capitalize(module)}
<div className="flex items-center justify-center"> className={clsx(
<BsArrowRepeat className="animate-spin text-white" size={25} /> "-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
</div> module === "reading" && "bg-ielts-reading",
)} module === "listening" && "bg-ielts-listening",
</button> module === "writing" && "bg-ielts-writing",
<button module === "speaking" && "bg-ielts-speaking",
onClick={deleteSession} module === "level" && "bg-ielts-level",
disabled={isLoading} )}>
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"> {module === "reading" && <BsBook className="h-4 w-4" />}
{!isLoading && "Delete"} {module === "listening" && <BsHeadphones className="h-4 w-4" />}
{isLoading && ( {module === "writing" && <BsPen className="h-4 w-4" />}
<div className="flex items-center justify-center"> {module === "speaking" && <BsMegaphone className="h-4 w-4" />}
<BsArrowRepeat className="animate-spin text-white" size={25} /> {module === "level" && <BsClipboard className="h-4 w-4" />}
</div> </div>
)} ))}
</button> </div>
</div>
<div className="flex items-center gap-2 w-full">
<button
onClick={async () => await loadSession(session)}
disabled={isLoading}
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
{!isLoading && "Resume"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
<button
onClick={deleteSession}
disabled={isLoading}
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
{!isLoading && "Delete"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,313 @@
import React from "react";
import {BsClock, BsXCircle} from "react-icons/bs";
import clsx from "clsx";
import {Stat, User} from "@/interfaces/user";
import {Module, Step} 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;
gradingSystem?: Step[];
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,
gradingSystem,
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 shouldRenderPDFIcon = () => {
if (assignment) {
return assignment.released;
}
return true;
};
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">
{!!assignment && (assignment.released || assignment.released === undefined) && (
<span className={textColor}>
Level{" "}
{(
aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length
).toFixed(1)}
</span>
)}
{shouldRenderPDFIcon() && 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")}>
{!!assignment &&
(assignment.released || assignment.released === undefined) &&
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 || (!!assignment && !assignment.released)) && "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={() => {
if (!!assignment && !assignment.released) return;
if (examNumber === undefined) return selectExam();
return;
}}
style={{
...(width !== undefined && {width}),
...(height !== undefined && {height}),
}}
data-tip={isDisabled ? "This exam is still being evaluated..." : "This exam is still locked by its assigner..."}
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

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

View File

@@ -7,10 +7,11 @@ interface Props {
onClose: () => void; onClose: () => void;
title?: string; title?: string;
className?: string; className?: string;
titleClassName?: string;
children?: ReactElement; children?: ReactElement;
} }
export default function Modal({isOpen, title, className, onClose, children}: Props) { export default function Modal({isOpen, title, className, titleClassName, 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}>
@@ -41,7 +42,7 @@ export default function Modal({isOpen, title, className, onClose, children}: Pro
className, className,
)}> )}>
{title && ( {title && (
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900"> <Dialog.Title as="h3" className={clsx(titleClassName ? titleClassName : "text-lg font-medium leading-6 text-gray-900")}>
{title} {title}
</Dialog.Title> </Dialog.Title>
)} )}

View File

@@ -1,24 +1,34 @@
import {Step} from "@/interfaces";
import {getGradingLabel, getLevelLabel} from "@/utils/score";
import clsx from "clsx"; import clsx from "clsx";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs"; import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
const ModuleBadge: React.FC<{ module: string; level?: number }> = ({ module, level }) => ( const ModuleBadge: React.FC<{module: string; level?: number; gradingSystem?: Step[]; className?: string}> = ({
<div module,
className={clsx( level,
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl", gradingSystem,
module === "reading" && "bg-ielts-reading", className,
module === "listening" && "bg-ielts-listening", }) => (
module === "writing" && "bg-ielts-writing", <div
module === "speaking" && "bg-ielts-speaking", className={clsx(
module === "level" && "bg-ielts-level", "flex gap-2 justify-center 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 === "reading" && <BsBook className="w-4 h-4" />} module === "listening" && "bg-ielts-listening",
{module === "listening" && <BsHeadphones className="w-4 h-4" />} module === "writing" && "bg-ielts-writing",
{module === "writing" && <BsPen className="w-4 h-4" />} module === "speaking" && "bg-ielts-speaking",
{module === "speaking" && <BsMegaphone className="w-4 h-4" />} module === "level" && "bg-ielts-level",
{module === "level" && <BsClipboard className="w-4 h-4" />} className,
{/* do not switch to level && it will convert the 0.0 to 0*/} )}>
{level !== undefined && (<span className="text-sm">{level.toFixed(1)}</span>)} {module === "reading" && <BsBook className="w-4 h-4" />}
</div> {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">{module === "level" && gradingSystem ? getGradingLabel(level, gradingSystem) : level.toFixed(1)}</span>
)}
</div>
); );
export default ModuleBadge; export default ModuleBadge;

View File

@@ -1,219 +1,165 @@
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 {Module} from "@/interfaces";
import Badge from "./Low/Badge"; import Badge from "./Low/Badge";
import { import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
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({ export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
user, const [isMenuOpen, setIsMenuOpen] = useState(false);
path, const [disablePaymentPage, setDisablePaymentPage] = useState(true);
navDisabled = false, const [isTicketOpen, setIsTicketOpen] = useState(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)) if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light";
return "!bg-mti-red-ultralight border-mti-red-light"; if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(3, "days").isAfter(momentDate)) if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
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") if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false);
return setDisablePaymentPage(false); isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
isUserFromCorporate(user.id).then((result) => }, [user]);
setDisablePaymentPage(result)
);
}, [user]);
const badges = [ const badges = [
{ {
module: "reading", module: "reading",
icon: () => <BsBook className="h-4 w-4 text-white" />, icon: () => <BsBook className="h-4 w-4 text-white" />,
achieved: user.levels.reading >= user.desiredLevels.reading, achieved: user.levels?.reading || 0 >= user.desiredLevels?.reading || 9,
}, },
{ {
module: "listening", module: "listening",
icon: () => <BsHeadphones className="h-4 w-4 text-white" />, icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
achieved: user.levels.listening >= user.desiredLevels.listening, achieved: user.levels?.listening || 0 >= user.desiredLevels?.listening || 9,
}, },
{ {
module: "writing", module: "writing",
icon: () => <BsPen className="h-4 w-4 text-white" />, icon: () => <BsPen className="h-4 w-4 text-white" />,
achieved: user.levels.writing >= user.desiredLevels.writing, achieved: user.levels?.writing || 0 >= user.desiredLevels?.writing || 9,
}, },
{ {
module: "speaking", module: "speaking",
icon: () => <BsMegaphone className="h-4 w-4 text-white" />, icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
achieved: user.levels.speaking >= user.desiredLevels.speaking, achieved: user.levels?.speaking || 0 >= user.desiredLevels?.speaking || 9,
}, },
{ {
module: "level", module: "level",
icon: () => <BsClipboard className="h-4 w-4 text-white" />, icon: () => <BsClipboard className="h-4 w-4 text-white" />,
achieved: user.levels.level >= user.desiredLevels.level, achieved: user.levels?.level || 0 >= user.desiredLevels?.level || 9,
}, },
]; ];
return ( return (
<> <>
<Modal <Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
isOpen={isTicketOpen} <TicketSubmission user={user} page={router.asPath} onClose={() => setIsTicketOpen(false)} />
onClose={() => setIsTicketOpen(false)} </Modal>
title="Submit a ticket"
>
<TicketSubmission
user={user}
page={router.asPath}
onClose={() => setIsTicketOpen(false)}
/>
</Modal>
{user && ( {user && (
<MobileMenu <MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
disableNavigation={disableNavigation} )}
path={path} <header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
isOpen={isMenuOpen} <Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
onClose={() => setIsMenuOpen(false)} <img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
user={user} <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">
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4"> {user.type === "student" &&
<Link badges.map((badge) => (
href={disableNavigation ? "" : "/"} <div
className=" flex items-center gap-8 md:px-8" key={badge.module}
> className={`${
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" /> badge.achieved ? `bg-ielts-${badge.module}` : "bg-mti-gray-anti-flash"
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1> } flex h-8 w-8 items-center justify-center rounded-full`}>
</Link> {badge.icon()}
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6"> </div>
{user.type === "student" && ))}
badges.map((badge) => ( {/* OPEN TICKET SYSTEM */}
<div <button
key={badge.module} className={clsx(
className={`${badge.achieved ? `bg-ielts-${badge.module}`: 'bg-mti-gray-anti-flash'} flex h-8 w-8 items-center justify-center rounded-full`} "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",
{badge.icon()} )}
</div> data-tip="Submit a help/feedback ticket"
))} onClick={() => setIsTicketOpen(true)}>
{/* OPEN TICKET SYSTEM */} <BsQuestionCircleFill />
<button </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() && ( {showExpirationDate() && (
<Link <Link
href={ href={!!user.subscriptionExpirationDate && !disablePaymentPage ? "/payment" : ""}
!!user.subscriptionExpirationDate && !disablePaymentPage data-tip="Expiry date"
? "/payment" 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",
data-tip="Expiry date" !user.subscriptionExpirationDate
className={clsx( ? "bg-mti-green-ultralight border-mti-green-light"
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none", : expirationDateColor(user.subscriptionExpirationDate),
"tooltip tooltip-bottom transition duration-300 ease-in-out", "border-mti-gray-platinum bg-white",
!user.subscriptionExpirationDate )}>
? "bg-mti-green-ultralight border-mti-green-light" {!user.subscriptionExpirationDate && "Unlimited"}
: expirationDateColor(user.subscriptionExpirationDate), {user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
"border-mti-gray-platinum bg-white" </Link>
)} )}
> <Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
{!user.subscriptionExpirationDate && "Unlimited"} <img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
{user.subscriptionExpirationDate && <span className="-md:hidden text-right">
moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")} {(user.type === "corporate" || user.type === "mastercorporate") && !!user.corporateInformation?.companyInformation?.name
</Link> ? `${user.corporateInformation?.companyInformation.name} |`
)} : ""}{" "}
<Link {user.name} | {USER_TYPE_LABELS[user.type]}
href={disableNavigation ? "" : "/profile"} {user.type === "corporate" &&
className="-md:hidden flex items-center justify-end gap-6" !!user.demographicInformation?.position &&
> ` | ${user.demographicInformation?.position || "N/A"}`}
<img </span>
src={user.profilePicture} </Link>
alt={user.name} <div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
className="h-10 w-10 rounded-full object-cover" <BsList className="text-mti-purple-light h-8 w-8" />
/> </div>
<span className="-md:hidden text-right"> </div>
{user.type === "corporate" {focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
? `${user.corporateInformation?.companyInformation.name} |` </header>
: ""}{" "} </>
{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,15 +1,28 @@
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { Fragment } from "react"; import { Fragment, useEffect, useState } from "react";
import Button from "./Low/Button"; import Button from "./Low/Button";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
blankQuestions?: boolean; type?: "module" | "blankQuestions" | "submit";
finishingWhat? : string; unanswered?: boolean;
onClose: (next?: boolean) => void; onClose: (next?: boolean) => void;
} }
export default function QuestionsModal({ isOpen, onClose, blankQuestions = true, finishingWhat = "module" }: Props) { export default function QuestionsModal({ isOpen, onClose, type = "module", unanswered = false }: Props) {
const [isClosing, setIsClosing] = useState(false);
const blockMultipleClicksClose = (x: boolean) => {
if (!isClosing) {
setIsClosing(true);
onClose(x);
}
setTimeout(() => {
setIsClosing(false);
}, 400);
}
return ( return (
<Transition show={isOpen} as={Fragment}> <Transition show={isOpen} as={Fragment}>
<Dialog onClose={() => onClose(false)} className="relative z-50"> <Dialog onClose={() => onClose(false)} className="relative z-50">
@@ -34,43 +47,71 @@ export default function QuestionsModal({ isOpen, onClose, blankQuestions = true,
leaveTo="opacity-0 scale-95"> leaveTo="opacity-0 scale-95">
<div className="fixed inset-0 flex items-center justify-center p-4"> <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.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
{blankQuestions ? ( {type === "module" && (
<> <>
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title> <Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
<span> <span>
Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be 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 of the current one, including your unanswered questions. <br /> able to change the answers of the current one, including your unanswered questions. <br />
<br /> <br />
Are you sure you want to continue without completing those questions? Are you sure you want to continue without completing those questions?
</span> </span>
<div className="w-full flex justify-between mt-8"> <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"> <Button color="purple" onClick={() => blockMultipleClicksClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
Go Back Go Back
</Button> </Button>
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full"> <Button color="purple" onClick={() => blockMultipleClicksClose(true)} className="max-w-[200px] self-end w-full">
Continue Continue
</Button> </Button>
</div> </div>
</> </>
): ( )}
{type === "blankQuestions" && (
<> <>
<Dialog.Title className="font-bold text-xl">Confirm Submission</Dialog.Title> <Dialog.Title className="font-bold text-2xl">Questions Unanswered</Dialog.Title>
<span> <div className="flex flex-col text-xl gap-2">
Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be <p>You have left some questions unanswered in the current part.</p>
able to review the answers of the current one. <br /> <p>If you wish to continue, you can still access this part later using the navigation bar at the top or the &quot;Back&quot; button.</p>
<br /> <p>Do you want to proceed to the next part, or would you like to go back and complete the unanswered questions in the current part?</p>
Are you sure you want to continue? </div>
</span> <div className="w-full flex justify-between mt-4">
<div className="w-full flex justify-between mt-8"> <Button color="purple" onClick={() => blockMultipleClicksClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
Go Back Go Back
</Button> </Button>
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full"> <Button color="purple" onClick={() => blockMultipleClicksClose(true)} className="max-w-[200px] self-end w-full">
Continue Continue
</Button> </Button>
</div> </div>
</> </>
)} )}
{type === "submit" && (
<>
<Dialog.Title className="font-bold text-3xl text-mti-rose-light">Confirm Submission</Dialog.Title>
<span className="text-xl">
{unanswered ? (
<>
By clicking &quot;Submit&quot;, you are finalizing your exam with some <b>questions left unanswered</b>. Once you submit, you will not be able to review or change any of your answers, including the unanswered ones. <br />
<br />
Are you sure you want to submit and complete the exam <b>with unanswered questions</b>?
</>
) : (
<>
By clicking &quot;Submit&quot;, you are finalizing your exam. Once you submit, you will not be able to review or change any of your answers. <br />
<br />
Are you sure you want to submit and complete the exam?
</>
)}
</span>
<div className="w-full flex justify-between mt-4">
<Button color="purple" onClick={() => blockMultipleClicksClose(false)} variant="outline" className="max-w-[200px] self-end w-full !text-xl">
Go Back
</Button>
<Button color="rose" onClick={() => blockMultipleClicksClose(true)} className="max-w-[200px] self-end w-full !text-xl">
Submit
</Button>
</div>
</>
)}
</Dialog.Panel> </Dialog.Panel>
</div> </div>
</Transition.Child> </Transition.Child>

View File

@@ -212,7 +212,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
)} )}
</div> </div>
<div className="fixed bottom-12 flex flex-col gap-0"> <div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8">
<div <div
role="button" role="button"
tabIndex={1} tabIndex={1}

View File

@@ -1,41 +1,30 @@
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam"; import {FillBlanksExercise, FillBlanksMCOption, ShuffleMap} 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"; import useExamStore from "@/stores/examStore";
export default function FillBlanksSolutions({ export default function FillBlanksSolutions({id, type, prompt, solutions, words, text, onNext, onBack}: FillBlanksExercise & CommonProps) {
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 storeUserSolutions = useExamStore((state) => state.userSolutions);
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const correctUserSolutions = storeUserSolutions.find( const correctUserSolutions = storeUserSolutions.find((solution) => solution.exercise === id)?.solutions;
(solution) => solution.exercise === id
)?.solutions; const shuffles = useExamStore((state) => state.shuffles);
const calculateScore = () => { const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = correctUserSolutions!.filter((x) => { const correct = correctUserSolutions!.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution; const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
console.log(solution);
if (!solution) return false; if (!solution) return false;
const option = words.find((w) => { const option = words.find((w) => {
if (typeof w === "string") { if (typeof w === "string") {
return w.toLowerCase() === x.solution.toLowerCase(); return w.toLowerCase() === x.solution.toLowerCase();
} else if ('letter' in w) { } else if ("letter" in w) {
return w.word.toLowerCase() === x.solution.toLowerCase(); return w.letter.toLowerCase() === x.solution.toLowerCase();
} else { } else {
return w.id.toString() === x.id.toString(); return w.id.toString() === x.id.toString();
} }
@@ -44,39 +33,38 @@ export default function FillBlanksSolutions({
if (typeof option === "string") { if (typeof option === "string") {
return solution.toLowerCase() === option.toLowerCase(); return solution.toLowerCase() === option.toLowerCase();
} else if ('letter' in option) { } else if ("letter" in option) {
return solution.toLowerCase() === option.word.toLowerCase(); return solution.toLowerCase() === option.word.toLowerCase();
} else if ('options' in option) { } else if ("options" in option) {
return option.options[solution as keyof typeof option.options] == x.solution; return option.options[solution as keyof typeof option.options] == x.solution;
} }
return false; return false;
}).length; }).length;
const missing = total - correctUserSolutions!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; const missing = total - correctUserSolutions!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing }; return {total, correct, missing};
}; };
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => { const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every( return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
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 questionId = match.replaceAll(/[\{\}]/g, "");
const userSolution = correctUserSolutions!.find((x) => x.id.toString() === id.toString()); const userSolution = correctUserSolutions!.find((x) => x.id.toString() === questionId.toString());
const answerSolution = solutions.find(sol => sol.id.toString() === id.toString())!.solution; const answerSolution = solutions.find((sol) => sol.id.toString() === questionId.toString())!.solution;
const questionShuffleMap = shuffles.find((x) => x.exerciseID == id)?.shuffles.find((y) => y.questionID == questionId);
const newAnswerSolution = questionShuffleMap
? questionShuffleMap.map[answerSolution].toLowerCase()
: answerSolution.toLowerCase();
if (!userSolution) { if (!userSolution) {
let answerText; let answerText;
if (typeCheckWordsMC(words)) { if (typeCheckWordsMC(words)) {
const options = words.find((x) => x.id.toString() === id.toString()); const options = words.find((x) => x.id.toString() === questionId.toString());
const correctKey = Object.keys(options!.options).find(key => const correctKey = Object.keys(options!.options).find((key) => key.toLowerCase() === newAnswerSolution);
key.toLowerCase() === answerSolution.toLowerCase()
);
answerText = options!.options[correctKey as keyof typeof options]; answerText = options!.options[correctKey as keyof typeof options];
} else { } else {
answerText = answerSolution; answerText = answerSolution;
@@ -95,37 +83,34 @@ export default function FillBlanksSolutions({
const userSolutionWord = words.find((w) => const userSolutionWord = words.find((w) =>
typeof w === "string" typeof w === "string"
? w.toLowerCase() === userSolution.solution.toLowerCase() ? w.toLowerCase() === userSolution.solution.toLowerCase()
: 'letter' in w : "letter" in w
? w.letter.toLowerCase() === userSolution.solution.toLowerCase() ? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
: 'options' in w : "options" in w
? w.id === userSolution.id ? w.id === userSolution.questionId
: false : false,
); );
const userSolutionText = const userSolutionText =
typeof userSolutionWord === "string" typeof userSolutionWord === "string"
? userSolutionWord ? userSolutionWord
: userSolutionWord && 'letter' in userSolutionWord : userSolutionWord && "letter" in userSolutionWord
? userSolutionWord.word ? userSolutionWord.word
: userSolutionWord && 'options' in userSolutionWord : userSolutionWord && "options" in userSolutionWord
? userSolution.solution ? userSolution.solution
: userSolution.solution; : userSolution.solution;
let correct; let correct;
let solutionText; let solutionText;
if (typeCheckWordsMC(words)) { if (typeCheckWordsMC(words)) {
const options = words.find((x) => x.id.toString() === id.toString()); const options = words.find((x) => x.id.toString() === questionId.toString());
if (options) { if (options) {
const correctKey = Object.keys(options.options).find(key => const correctKey = Object.keys(options.options).find((key) => key.toLowerCase() === newAnswerSolution);
key.toLowerCase() === answerSolution.toLowerCase()
);
correct = userSolution.solution == options.options[correctKey as keyof typeof options.options]; correct = userSolution.solution == options.options[correctKey as keyof typeof options.options];
solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution; solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution;
} else { } else {
correct = false; correct = false;
solutionText = answerSolution; solutionText = answerSolution;
} }
} else { } else {
correct = userSolutionText === answerSolution; correct = userSolutionText === answerSolution;
solutionText = answerSolution; solutionText = answerSolution;
@@ -168,7 +153,25 @@ export default function FillBlanksSolutions({
}; };
return ( return (
<> <div className="flex flex-col gap-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<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="bg-mti-gray-smoke rounded-xl px-5 py-6"> <span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{correctUserSolutions && {correctUserSolutions &&
@@ -199,18 +202,19 @@ export default function FillBlanksSolutions({
<Button <Button
color="purple" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })} onClick={() => onBack({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
className="max-w-[200px] w-full"> className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back Back
</Button> </Button>
<Button <Button
color="purple" color="purple"
onClick={() => onNext({ exercise: id, solutions: correctUserSolutions!, 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>
</div> </div>
</> </div>
); );
} }

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,
@@ -26,20 +26,24 @@ export default function InteractiveSpeaking({
const [solutionsURL, setSolutionsURL] = useState<string[]>([]); const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
const [diffNumber, setDiffNumber] = useState(0); const [diffNumber, setDiffNumber] = useState(0);
const tooltips: { [key: string]: string } = { 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.", "Grammatical Range and Accuracy":
"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.", "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.",
"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.", "Fluency and Coherence":
"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.", "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;
@@ -51,7 +55,41 @@ export default function InteractiveSpeaking({
}, [userSolutions]); }, [userSolutions]);
return ( return (
<> <div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}> <Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
<> <>
{userSolutions && {userSolutions &&
@@ -71,13 +109,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")}
@@ -122,13 +160,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)}`] && userSolutions[0].evaluation[`transcript_${index + 1}`] &&
userSolutions[0].evaluation[`fixed_text_${(index + 1)}`] && ( 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))}> onClick={() => setDiffNumber(index + 1)}>
View Correction View Correction
</Button> </Button>
)} )}
@@ -144,20 +182,24 @@ export default function InteractiveSpeaking({
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade; const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return ( return (
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom", <div
index === 0 && "tooltip-right" className={clsx(
)} key={key} data-tip={tooltips[key] || "No additional information available"}> "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} {key}: Level {grade}
</div> </div>
); );
})} })}
</div> </div>
{userSolutions[0].evaluation && {userSolutions[0].evaluation &&
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length > 0 ? ( 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( 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",
@@ -168,7 +210,7 @@ export default function InteractiveSpeaking({
General Feedback General Feedback
</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",
@@ -178,20 +220,26 @@ export default function InteractiveSpeaking({
}> }>
Evaluation Evaluation
</Tab> </Tab>
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => ( {Object.keys(userSolutions[0].evaluation)
<Tab .filter((x) => x.startsWith("perfect_answer"))
key={key} .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
Recommended Answer<br />(Prompt {index + 1}) ? "bg-white shadow"
</Tab> : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
))} )
}>
Recommended Answer
<br />
(Prompt {index + 1})
</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"> <Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
@@ -202,10 +250,16 @@ export default function InteractiveSpeaking({
return ( return (
<div key={key} className="flex flex-col gap-2"> <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}> <div
className={clsx(
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit",
)}
key={key}>
{key}: Level {grade} {key}: Level {grade}
</div> </div>
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>} {typeof taskResponse !== "number" && (
<span className="px-2 py-2">{taskResponse.comment}</span>
)}
</div> </div>
); );
})} })}
@@ -214,13 +268,18 @@ export default function InteractiveSpeaking({
<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>
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => ( {Object.keys(userSolutions[0].evaluation)
<Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> .filter((x) => x.startsWith("perfect_answer"))
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap"> .map((key, index) => (
{userSolutions[0].evaluation![`perfect_answer_${(index + 1)}`].answer.replaceAll(/\s{2,}/g, "\n\n")} <Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
</span> <span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
</Tab.Panel> {userSolutions[0].evaluation![`perfect_answer_${index + 1}`].answer.replaceAll(
))} /\s{2,}/g,
"\n\n",
)}
</span>
</Tab.Panel>
))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
) : ( ) : (
@@ -241,7 +300,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,
}) })
} }
@@ -266,6 +325,6 @@ export default function InteractiveSpeaking({
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -8,6 +8,7 @@ import Icon from "@mdi/react";
import {Fragment} from "react"; import {Fragment} from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
import Xarrow from "react-xarrows"; import Xarrow from "react-xarrows";
import useExamStore from "@/stores/examStore";
function QuestionSolutionArea({ function QuestionSolutionArea({
question, question,
@@ -61,6 +62,8 @@ export default function MatchSentencesSolutions({
onNext, onNext,
onBack, onBack,
}: MatchSentencesExercise & CommonProps) { }: MatchSentencesExercise & CommonProps) {
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const calculateScore = () => { const calculateScore = () => {
const total = sentences.length; const total = sentences.length;
const correct = userSolutions.filter( const correct = userSolutions.filter(
@@ -72,7 +75,25 @@ export default function MatchSentencesSolutions({
}; };
return ( return (
<> <div className="flex flex-col gap-4 mt-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<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"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
@@ -112,7 +133,8 @@ export default function MatchSentencesSolutions({
color="purple" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})} onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full"> className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back Back
</Button> </Button>
@@ -123,6 +145,6 @@ export default function MatchSentencesSolutions({
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -1,11 +1,11 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam"; import {MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap} 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 reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { CommonProps } from "."; import {CommonProps} from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
import {v4} from "uuid";
function Question({ function Question({
id, id,
@@ -14,37 +14,15 @@ 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 {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) => { const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
if (foundMap) return foundMap; if (foundMap) return foundMap;
return userSolution.shuffleMaps?.find(map => map.id === id) || null; return userSolution.shuffleMaps?.find((map) => map.questionID === id) || null;
}, null as ShuffleMap | null); }, null as ShuffleMap | null);
const questionOptions = questionShuffleMap ? getShuffledOptions(options as { id: string, text: string }[], questionShuffleMap) : options; const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
const newSolution = questionShuffleMap ? getShuffledSolution(solution, questionShuffleMap) : solution;
const renderPrompt = (prompt: string) => { const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => { return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
@@ -55,14 +33,14 @@ function Question({
const optionColor = (option: string) => { const optionColor = (option: string) => {
if (option === newSolution && !userSolution) { if (option === newSolution && !userSolution) {
return "!border-mti-gray-davy !text-mti-gray-davy"; return "!bg-mti-gray-davy !text-white";
} }
if (option === newSolution) { if (option === newSolution) {
return "!border-mti-purple-light !text-mti-purple-light"; return "!bg-mti-purple-light !text-white";
} }
return userSolution === option ? "!border-mti-rose-light !text-mti-rose-light" : ""; return userSolution === option ? "!bg-mti-rose-light !text-white" : "";
}; };
return ( return (
@@ -70,30 +48,38 @@ function Question({
{isNaN(Number(id)) ? ( {isNaN(Number(id)) ? (
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span> <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
) : ( ) : (
<span className="text-lg"> <span className="text-lg" key={v4()}>
<> <>
{id} - <span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span> {id} -{" "}
<span className="text-lg" key={v4()}>
{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}{" "}
</span>
</> </>
</span> </span>
)} )}
<div className="flex flex-wrap gap-4 justify-between"> <div className="flex flex-wrap gap-4 justify-between">
{variant === "image" && {variant === "image" &&
questionOptions.map((option) => ( options.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 select-none", "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", newSolution !== 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>
{"src" in option && <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" &&
questionOptions.map((option) => ( options.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-base select-none", 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>
@@ -103,48 +89,82 @@ function Question({
); );
} }
export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) { export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const { questionIndex, setQuestionIndex, partIndex, exam } = useExamStore((state) => state); const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const stats = useExamStore((state) => state.userSolutions);
const calculateScore = () => { const calculateScore = () => {
const total = questions.length; const total = questions.length;
const correct = userSolutions.filter( const questionShuffleMap = stats.find((x) => x.exercise == id)?.shuffleMaps;
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false, const correct = userSolutions.filter((x) => {
).length; if (questionShuffleMap) {
const shuffleMap = questionShuffleMap.find((y) => y.questionID === x.question);
const originalSol = questions.find((y) => y.id.toString() === x.question.toString())?.solution!;
return x.option == shuffleMap?.map[originalSol];
} else {
return questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false;
}
}).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 };
}; };
const next = () => { const next = () => {
if (questionIndex === questions.length - 1) { if (questionIndex + 1 >= questions.length - 1) {
onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type }); onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
} else { } else {
setQuestionIndex(questionIndex + 1); setQuestionIndex(questionIndex + 2);
} }
}; };
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(questionIndex - 1); setQuestionIndex(questionIndex - 2);
} }
}; };
return ( return (
<> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 w-full h-full mb-20"> <div className="flex justify-between w-full gap-8">
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8"> <Button
{/*<span className="text-xl font-semibold">{prompt}</span>*/} color="purple"
{userSolutions && questionIndex < questions.length && ( variant="outline"
<Question onClick={back}
{...questions[questionIndex]} className="max-w-[200px] w-full"
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option} disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
/> Back
</Button>
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<div className="flex flex-col gap-4 w-full h-full mb-20 mt-4">
<div className="flex flex-col gap-4 mt-2">
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
{/*<span className="text-xl font-semibold">{prompt}</span>*/}
{userSolutions && questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
/>
)}
</div>
{userSolutions && questionIndex + 1 < questions.length && (
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<Question
{...questions[questionIndex + 1]}
userSolution={userSolutions.find((x) => questions[questionIndex + 1].id === x.question)?.option}
/>
</div>
)} )}
</div> </div>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-purple" /> <div className="w-4 h-4 rounded-full bg-mti-purple" />
@@ -159,14 +179,15 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
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
disabled={ color="purple"
exam && typeof partIndex !== "undefined" && exam.module === "level" && variant="outline"
typeof exam.parts[0].intro === "string" && questionIndex === 0 && partIndex === 0} onClick={back}
> className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back Back
</Button> </Button>
@@ -174,6 +195,6 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

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, us
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,15 +32,53 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
} }
}, [userSolutions]); }, [userSolutions]);
const tooltips: { [key: string]: string } = { 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.", "Grammatical Range and Accuracy":
"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.", "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.",
"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.", "Fluency and Coherence":
"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.", "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 (
<> <div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}> <Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
<> <>
{userSolutions && {userSolutions &&
@@ -58,13 +96,13 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
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")}
@@ -138,20 +176,24 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade; const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return ( return (
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom", <div
index === 0 && "tooltip-right" className={clsx(
)} key={key} data-tip={tooltips[key] || "No additional information available"}> "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} {key}: Level {grade}
</div> </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( 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",
@@ -162,7 +204,7 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
General Feedback General Feedback
</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",
@@ -173,7 +215,7 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
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",
@@ -194,10 +236,16 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
return ( return (
<div key={key} className="flex flex-col gap-2"> <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}> <div
className={clsx(
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit",
)}
key={key}>
{key}: Level {grade} {key}: Level {grade}
</div> </div>
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>} {typeof taskResponse !== "number" && (
<span className="px-2 py-2">{taskResponse.comment}</span>
)}
</div> </div>
); );
})} })}
@@ -236,7 +284,7 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
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,
}) })
} }
@@ -261,6 +309,6 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -4,10 +4,13 @@ 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";
type Solution = "true" | "false" | "not_given"; type Solution = "true" | "false" | "not_given";
export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) { export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const calculateScore = () => { const calculateScore = () => {
const total = questions.length || 0; const total = questions.length || 0;
const correct = userSolutions.filter( const correct = userSolutions.filter(
@@ -37,7 +40,25 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
}; };
return ( return (
<> <div className="flex flex-col gap-4 mt-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<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"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
@@ -121,7 +142,8 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
color="purple" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})} onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full"> className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back Back
</Button> </Button>
@@ -132,6 +154,6 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -8,6 +8,7 @@ import reactStringReplace from "react-string-replace";
import {CommonProps} from "."; import {CommonProps} from ".";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import Button from "../Low/Button"; import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
function Blank({ function Blank({
id, id,
@@ -71,6 +72,8 @@ export default function WriteBlanksSolutions({
onNext, onNext,
onBack, onBack,
}: WriteBlanksExercise & CommonProps) { }: WriteBlanksExercise & CommonProps) {
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
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 = userSolutions.filter(
@@ -102,7 +105,25 @@ export default function WriteBlanksSolutions({
}; };
return ( return (
<> <div className="flex flex-col gap-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
<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"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
@@ -142,7 +163,8 @@ export default function WriteBlanksSolutions({
color="purple" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})} onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full"> className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back Back
</Button> </Button>
@@ -153,6 +175,6 @@ export default function WriteBlanksSolutions({
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -1,32 +1,70 @@
/* 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 useUser from "@/hooks/useUser";
import AIDetection from "../AIDetection"; 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 {user} = useUser();
const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined; const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined;
const tooltips: { [key: string]: string } = { 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.", "Lexical Resource":
"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.", "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.",
"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.", "Task Achievement":
"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.", "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 (
<> <div className="flex flex-col gap-4 mt-4">
<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: userSolutions,
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
{attachment && ( {attachment && (
<Transition show={isModalOpen} as={Fragment}> <Transition show={isModalOpen} as={Fragment}>
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50"> <Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
@@ -99,13 +137,13 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
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")}
@@ -135,10 +173,13 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade; const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return ( return (
<div className={clsx( <div
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom", className={clsx(
index === 0 && "tooltip-right" "bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
)} key={key} data-tip={tooltips[key] || "No additional information available"}> index === 0 && "tooltip-right",
)}
key={key}
data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade} {key}: Level {grade}
</div> </div>
); );
@@ -148,7 +189,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
<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( 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",
@@ -159,7 +200,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
General Feedback General Feedback
</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",
@@ -170,7 +211,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
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",
@@ -182,7 +223,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
</Tab> </Tab>
{aiEval && user?.type !== "student" && ( {aiEval && user?.type !== "student" && (
<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",
@@ -204,10 +245,16 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
return ( return (
<div key={key} className="flex flex-col gap-2"> <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}> <div
className={clsx(
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 w-fit",
)}
key={key}>
{key}: Level {grade} {key}: Level {grade}
</div> </div>
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>} {typeof taskResponse !== "number" && (
<span className="px-2 py-2">{taskResponse.comment}</span>
)}
</div> </div>
); );
})} })}
@@ -248,7 +295,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
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,
}) })
} }
@@ -273,6 +320,6 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
Next Next
</Button> </Button>
</div> </div>
</> </div>
); );
} }

View File

@@ -1,289 +0,0 @@
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

@@ -1,91 +1,43 @@
import React, { useState, useCallback } from "react"; import React from "react";
import ExerciseWalkthrough from "@/training/ExerciseWalkthrough"; import ExerciseWalkthrough from "@/training/ExerciseWalkthrough";
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces"; import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
import formatTip from "./FormatTip";
// This wrapper is just to test new exercises from the handbook, will be removed when all the tips and exercises are in firestore // 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 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 = {
const tip = { "category": "",
category: "Strategy", "embedding": "",
body: "<p>Look for <b>clues to the main idea</b> in the first (and sometimes second) sentence of a paragraph.</p>" "text": "",
} "html": "",
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>"; "id": "",
const rightTextData: WalkthroughConfigs[] = [ "verified": true,
{ "standalone": false,
"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>", "exercise": {
"wordDelay": 200, "question": "",
"holdDelay": 5000, "additional": "",
"highlight": [] "segments": []
}, }
{
"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 = { const mockTip: ITrainingTip = {
id: "some random id", id: "some random id",
tipCategory: tip.category, tipCategory: tip.category,
tipHtml: tip.body, tipHtml: tip.html,
standalone: false, standalone: tip.standalone,
exercise: { exercise: {
question: question, question: tip.exercise.question,
highlightable: leftText, additional: tip.exercise.additional,
segments: rightTextData segments: tip.exercise.segments as WalkthroughConfigs[]
} }
} }
return ( const formattedTip = formatTip(mockTip);
<div className="flex flex-col p-10"> return (
<ExerciseWalkthrough {...trainingTip} <ExerciseWalkthrough {...formatTip(trainingTip)}
/> />
</div> );
);
} }
export default TrainingExercise; export default TrainingExercise;

View File

@@ -1,19 +1,32 @@
import React, {useState, useEffect, useRef, useCallback} from "react"; import React, { useState, useEffect, useRef, useCallback } from 'react';
import {animated} from "@react-spring/web"; import { animated } from '@react-spring/web';
import {FaRegCirclePlay, FaRegCircleStop} from "react-icons/fa6"; import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6";
import HighlightContent from "../HighlightContent"; import HighlightContent from '../HighlightContent';
import {ITrainingTip, SegmentRef, TimelineEvent} from "./TrainingInterfaces"; import { ITrainingTip, SegmentRef, TimelineEvent, HighlightConfig, InsertHtmlConfig } from './TrainingInterfaces';
import Tip from './Tip';
interface HtmlState {
question: string;
additional: string;
walkthrough: string;
}
const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => { const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false); const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(0); const [currentTime, setCurrentTime] = useState<number>(0);
const [walkthroughHtml, setWalkthroughHtml] = useState<string>(""); const [currentHighlightConfigs, setCurrentHighlightConfigs] = useState<HighlightConfig[]>([]);
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
const [isPlaying, setIsPlaying] = useState<boolean>(false); const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [currentSegmentIndex, setCurrentSegmentIndex] = useState<number>(0);
const timelineRef = useRef<TimelineEvent[]>([]); const timelineRef = useRef<TimelineEvent[]>([]);
const animationRef = useRef<number | null>(null); const animationRef = useRef<number | null>(null);
const segmentsRef = useRef<SegmentRef[]>([]); const segmentsRef = useRef<SegmentRef[]>([]);
const [questionHtml, setQuestionHtml] = useState(tip.exercise?.question || '');
const [additionalHtml, setAdditionalHtml] = useState(tip.exercise?.additional || '');
const [walkthroughHtml, setWalkthroughHtml] = useState<string>('');
const [htmlStates, setHtmlStates] = useState<HtmlState[]>([]);
const lastProcessedInsertTime = useRef<number>(-1);
const toggleAutoPlay = useCallback(() => { const toggleAutoPlay = useCallback(() => {
setIsAutoPlaying((prev) => { setIsAutoPlaying((prev) => {
if (!prev && currentTime === getMaxTime()) { if (!prev && currentTime === getMaxTime()) {
@@ -33,23 +46,24 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
}, []); }, []);
const getMaxTime = (): number => { const getMaxTime = (): number => {
return ( return tip.exercise?.segments.reduce((sum, segment) =>
tip.exercise?.segments.reduce((sum, segment) => sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0) ?? 0 sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0
); ) ?? 0;
}; };
useEffect(() => { useEffect(() => {
const timeline: TimelineEvent[] = []; const timeline: TimelineEvent[] = [];
let currentTimePosition = 0; let currentTimePosition = 0;
segmentsRef.current = []; segmentsRef.current = [];
const newHtmlStates: HtmlState[] = [];
tip.exercise?.segments.forEach((segment, index) => { tip.exercise?.segments.forEach((segment, index) => {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, "text/html"); const doc = parser.parseFromString(segment.html, 'text/html');
const words: string[] = []; const words: string[] = [];
const walkTree = (node: Node) => { const walkTree = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) { if (node.nodeType === Node.TEXT_NODE) {
words.push(...(node.textContent?.split(/\s+/).filter((word) => word.length > 0) || [])); words.push(...(node.textContent?.split(/\s+/).filter(word => word.length > 0) || []));
} else if (node.nodeType === Node.ELEMENT_NODE) { } else if (node.nodeType === Node.ELEMENT_NODE) {
Array.from(node.childNodes).forEach(walkTree); Array.from(node.childNodes).forEach(walkTree);
} }
@@ -62,69 +76,116 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
...segment, ...segment,
words: words, words: words,
startTime: currentTimePosition, startTime: currentTimePosition,
endTime: currentTimePosition + textDuration, endTime: currentTimePosition + textDuration
}); });
timeline.push({ timeline.push({
type: "text", type: 'text',
start: currentTimePosition, start: currentTimePosition,
end: currentTimePosition + textDuration, end: currentTimePosition + textDuration,
segmentIndex: index, segmentIndex: index
}); });
currentTimePosition += textDuration; currentTimePosition += textDuration;
timeline.push({ timeline.push({
type: "highlight", type: 'highlight',
start: currentTimePosition, start: currentTimePosition,
end: currentTimePosition + segment.holdDelay, end: currentTimePosition + segment.holdDelay,
content: segment.highlight, content: segment.highlight,
segmentIndex: index, segmentIndex: index
}); });
if (segment.insertHTML && segment.insertHTML.length > 0) {
newHtmlStates.push({
question: questionHtml,
additional: additionalHtml,
walkthrough: walkthroughHtml
});
timeline.push({
type: 'insert',
start: currentTimePosition,
end: currentTimePosition + segment.holdDelay,
segmentIndex: index,
content: segment.insertHTML
});
}
currentTimePosition += segment.holdDelay; currentTimePosition += segment.holdDelay;
}); });
for (let i = 0; i < timeline.length; i++) {
if (timeline[i].type === 'insert') {
const nextInsertIndex = timeline.findIndex((event, index) => index > i && event.type === 'insert');
if (nextInsertIndex !== -1) {
timeline[i].end = timeline[nextInsertIndex].start;
} else {
timeline[i].end = currentTimePosition;
}
}
}
timelineRef.current = timeline; timelineRef.current = timeline;
}, [tip.exercise?.segments]); setHtmlStates(newHtmlStates);
}, [tip.exercise?.segments, questionHtml, additionalHtml, walkthroughHtml]);
const updateText = useCallback(() => { const updateText = useCallback(() => {
const currentEvent = timelineRef.current.find((event) => currentTime >= event.start && currentTime < event.end); const currentEvents = timelineRef.current.filter(
event => currentTime >= event.start && currentTime <= event.end
);
if (currentEvent) { if (currentTime < lastProcessedInsertTime.current) {
if (currentEvent.type === "text") { const lastInsertEvent = timelineRef.current
.filter(event => event.type === 'insert' && event.start <= currentTime)
.pop();
if (lastInsertEvent) {
const stateIndex = timelineRef.current.indexOf(lastInsertEvent);
if (stateIndex >= 0 && stateIndex < htmlStates.length) {
const previousState = htmlStates[stateIndex];
setQuestionHtml(previousState.question);
setAdditionalHtml(previousState.additional);
setWalkthroughHtml(previousState.walkthrough);
}
} else {
// If no previous insert event, revert to initial state
setQuestionHtml(tip.exercise?.question || '');
setAdditionalHtml(tip.exercise?.additional || '');
setWalkthroughHtml('');
}
}
currentEvents.forEach(currentEvent => {
if (currentEvent.type === 'text') {
const segment = segmentsRef.current[currentEvent.segmentIndex]; const segment = segmentsRef.current[currentEvent.segmentIndex];
const elapsedTime = currentTime - currentEvent.start; const elapsedTime = currentTime - currentEvent.start;
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length); const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
const previousSegmentsHtml = segmentsRef.current const previousSegmentsHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex) .slice(0, currentEvent.segmentIndex)
.map((seg) => seg.html) .map(seg => seg.html)
.join(""); .join('');
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, "text/html"); const doc = parser.parseFromString(segment.html, 'text/html');
let wordCount = 0; let wordCount = 0;
const walkTree = (node: Node, action: (node: Node) => void): boolean => { const walkTree = (node: Node, action: (node: Node) => void): boolean => {
if (node.nodeType === Node.TEXT_NODE && node.textContent) { if (node.nodeType === Node.TEXT_NODE && node.textContent) {
const words = node.textContent.split(/(\s+)/).filter((word) => word.length > 0); const words = node.textContent.split(/(\s+)/).filter(word => word.length > 0);
if (wordCount + words.filter((w) => !/\s+/.test(w)).length <= wordsToShow) { if (wordCount + words.filter(w => !/\s+/.test(w)).length <= wordsToShow) {
action(node.cloneNode(true)); action(node.cloneNode(true));
wordCount += words.filter((w) => !/\s+/.test(w)).length; wordCount += words.filter(w => !/\s+/.test(w)).length;
} else { } else {
const remainingWords = wordsToShow - wordCount; const remainingWords = wordsToShow - wordCount;
const newTextContent = words.reduce( const newTextContent = words.reduce((acc, word) => {
(acc, word) => { if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) { acc.text += word;
acc.text += word; acc.nonSpaceWords++;
acc.nonSpaceWords++; } else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) { acc.text += word;
acc.text += word; }
} return acc;
return acc; }, { text: '', nonSpaceWords: 0 }).text;
},
{text: "", nonSpaceWords: 0},
).text;
const newNode = node.cloneNode(false); const newNode = node.cloneNode(false);
newNode.textContent = newTextContent; newNode.textContent = newTextContent;
action(newNode); action(newNode);
@@ -133,38 +194,79 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
} else if (node.nodeType === Node.ELEMENT_NODE) { } else if (node.nodeType === Node.ELEMENT_NODE) {
const clone = node.cloneNode(false); const clone = node.cloneNode(false);
action(clone); action(clone);
Array.from(node.childNodes).some((child) => { Array.from(node.childNodes).some(child => {
return walkTree(child, (childNode) => (clone as Node).appendChild(childNode)); return walkTree(child, childNode => (clone as Node).appendChild(childNode));
}); });
} }
return wordCount >= wordsToShow; return wordCount >= wordsToShow;
}; };
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
walkTree(doc.body, (node) => fragment.appendChild(node)); walkTree(doc.body, node => fragment.appendChild(node));
const serializer = new XMLSerializer(); const serializer = new XMLSerializer();
const currentSegmentHtml = Array.from(fragment.childNodes) const currentSegmentHtml = Array.from(fragment.childNodes)
.map((node) => serializer.serializeToString(node)) .map(node => serializer.serializeToString(node))
.join(""); .join('');
const newHtml = previousSegmentsHtml + currentSegmentHtml; const newHtml = previousSegmentsHtml + currentSegmentHtml;
setWalkthroughHtml(newHtml); setWalkthroughHtml(newHtml);
setHighlightedPhrases([]); setCurrentSegmentIndex(currentEvent.segmentIndex);
} else if (currentEvent.type === "highlight") { setCurrentHighlightConfigs([]);
} else if (currentEvent.type === 'highlight') {
const newHtml = segmentsRef.current const newHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex + 1) .slice(0, currentEvent.segmentIndex + 1)
.map((seg) => seg.html) .map(seg => seg.html)
.join(""); .join('');
setWalkthroughHtml(newHtml); setWalkthroughHtml(newHtml);
setHighlightedPhrases(currentEvent.content || []); setCurrentSegmentIndex(currentEvent.segmentIndex);
setCurrentHighlightConfigs(currentEvent.content as HighlightConfig[] || []);
} else if (currentEvent.type === 'insert') {
const insertConfigs = currentEvent.content as InsertHtmlConfig[];
insertConfigs.forEach(config => {
switch (config.target) {
case 'question':
setQuestionHtml(prevHtml => insertHtmlContent(prevHtml, config));
break;
case 'additional':
setAdditionalHtml(prevHtml => insertHtmlContent(prevHtml, config));
break;
case 'segment':
setWalkthroughHtml(prevHtml => insertHtmlContent(prevHtml, config));
break;
}
});
lastProcessedInsertTime.current = currentTime;
} }
} });
}, [currentTime]); }, [currentTime, htmlStates, tip.exercise?.question, tip.exercise?.additional]);
useEffect(() => { useEffect(() => {
updateText(); updateText();
}, [currentTime, updateText]); }, [currentTime, updateText]);
const insertHtmlContent = (prevHtml: string, config: InsertHtmlConfig): string => {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = prevHtml;
const targetElement = tempDiv.querySelector(`#${config.targetId}`);
if (targetElement) {
switch (config.position) {
case 'append':
targetElement.insertAdjacentHTML('beforeend', config.html);
break;
case 'prepend':
targetElement.insertAdjacentHTML('afterbegin', config.html);
break;
case 'replace':
targetElement.innerHTML = config.html;
break;
}
}
return tempDiv.innerHTML;
};
useEffect(() => { useEffect(() => {
if (isAutoPlaying) { if (isAutoPlaying) {
const lastEvent = timelineRef.current[timelineRef.current.length - 1]; const lastEvent = timelineRef.current[timelineRef.current.length - 1];
@@ -219,62 +321,81 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
} }
}; };
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 ( return (
<div className="container mx-auto"> <div className="container mx-auto py-6">
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4"> <Tip category={tip.tipCategory} html={tip.tipHtml} />
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3> {!tip.standalone && (
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} /> <div className='flex flex-col space-y-4'>
</div> <div className='flex flex-row items-center space-x-4 py-4'>
<div className="flex flex-col space-y-4"> <button
<div className="flex flex-row items-center space-x-4 py-4"> onClick={toggleAutoPlay}
<button 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"
onClick={toggleAutoPlay} aria-label={isAutoPlaying ? 'Pause' : 'Play'}
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 ? (
{isAutoPlaying ? <FaRegCircleStop className="w-6 h-6" /> : <FaRegCirclePlay className="w-6 h-6" />} <FaRegCircleStop className="w-6 h-6" />
</button> ) : (
<input <FaRegCirclePlay className="w-6 h-6" />
type="range" )}
min="0" </button>
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0} <input
value={currentTime} type="range"
onChange={handleSliderChange} min="0"
onMouseDown={handleSliderMouseDown} max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
onMouseUp={handleSliderMouseUp} value={currentTime}
onTouchStart={handleSliderMouseDown} onChange={handleSliderChange}
onTouchEnd={handleSliderMouseUp} onMouseDown={handleSliderMouseDown}
className="flex-grow" onMouseUp={handleSliderMouseUp}
/> onTouchStart={handleSliderMouseDown}
</div> onTouchEnd={handleSliderMouseUp}
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4"> className='flex-grow'
<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>
<div className="flex-1"> <div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
<div className="bg-gray-50 rounded-lg shadow"> <div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
<div className="p-6 space-y-4"> <div className='flex-1 bg-white p-6 rounded-lg shadow space-y-6'>
<animated.div dangerouslySetInnerHTML={{__html: walkthroughHtml}} /> <div className="container mx-auto px-4">
<div id="question-container" className="border p-6 rounded-lg shadow-md">
<HighlightContent
html={questionHtml}
highlightConfigs={currentHighlightConfigs}
contentType="question"
/>
</div>
</div>
{tip.exercise?.additional && (<div className="container mx-auto px-4">
<div id="additional-container" className="border p-6 rounded-lg shadow-md">
<HighlightContent
html={additionalHtml}
highlightConfigs={currentHighlightConfigs}
contentType="additional"
/>
</div>
</div>
)}
</div>
<div className='flex-1'>
<div className='bg-gray-50 rounded-lg shadow'>
<div id="segment-container" className='p-6 space-y-4'>
<animated.div>
<HighlightContent
html={walkthroughHtml}
highlightConfigs={currentHighlightConfigs.filter(config =>
config.targets.includes('segment') || config.targets.includes('all')
)}
contentType="segment"
currentSegmentIndex={currentSegmentIndex}
/>
</animated.div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> )}
</div> </div>
); );
}; };
export default ExerciseWalkthrough; export default ExerciseWalkthrough;

View File

@@ -0,0 +1,201 @@
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
const colorOptions = [
'red', 'blue', 'green', 'purple', 'pink', 'indigo', 'teal', 'orange', 'cyan', 'emerald', 'sky', 'violet', 'fuchsia', 'rose', 'lime', 'slate'
]
const getRandomColors = (count: number) => {
const shuffled = [...colorOptions].sort(() => 0.5 - Math.random());
return shuffled.slice(0, count);
};
const classMap = {
"mainDiv": {
"tip": "flex-col gap-2",
"question": "flex-col gap-2",
"additional": "flex-col gap-8",
"segment": "p-4 rounded-lg mb-4 flex flex-col gap-2"
},
"h2": {
"tip": "mb-4 font-semibold text-lg",
"question": "text-lg font-semibold mb-4",
"additional": "text-2xl font-semibold mb-4",
"segment": "text-xl font-semibold"
}
}
const setClass = (element: Element, style: string) => {
element.setAttribute('class', style)
}
// DON'T OVERRIDE DIV AND SPAN STYLES
const processHtml = (section: string, html: string, color: string) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const mainDiv = doc.body.firstElementChild;
if (mainDiv && mainDiv.tagName === 'DIV') {
if (section === "segment") {
setClass(mainDiv, `bg-${color}-100 ${classMap["mainDiv"][section]}`);
} else {
setClass(mainDiv, classMap["mainDiv"][section as keyof typeof classMap["mainDiv"]]);
}
}
doc.querySelectorAll('h1').forEach(e => {
if (section === "additional") {
setClass(e, 'text-4xl font-bold mb-6')
} else {
setClass(e, 'text-xl font-semibold mb-4');
}
});
doc.querySelectorAll('h2').forEach(e => {
setClass(e, classMap["h2"][section as keyof typeof classMap["h2"]])
});
doc.querySelectorAll('h3').forEach(e => {
e.setAttribute('class', 'text-lg font-semibold mb-4')
})
doc.querySelectorAll('p').forEach(e => {
if (section === "segment") {
setClass(e, 'text-gray-700 leading-relaxed')
} else {
setClass(e, 'mb-4');
}
});
doc.querySelectorAll('label').forEach(e => {
if (section === "additional") {
setClass(e, 'font-semibold');
} else {
setClass(e, 'min-w-[16px] mr-1 font-semibold');
}
});
doc.querySelectorAll('ul').forEach(e => {
const hasLabel = Array.from(e.querySelectorAll('li')).some(li => li.querySelector('label'));
if (hasLabel) {
e.setAttribute('class', 'list-none space-y-2');
} else {
e.setAttribute('class', `list-disc pl-5 space-y-2`);
}
});
doc.querySelectorAll('ol').forEach(e => {
e.setAttribute('class', 'list-decimal pl-5 space-y-2');
})
doc.querySelectorAll('hz-row').forEach(e => {
e.setAttribute('class', `flex flex-row items-center mb-4 gap-2`);
})
if (section === "segment") {
doc.querySelectorAll('b').forEach(e => {
e.setAttribute('class', `text-${color}-700`);
});
}
doc.querySelectorAll('section').forEach(e => {
e.setAttribute('class', `mb-8`);
});
doc.querySelectorAll('option-box').forEach(e => {
e.setAttribute('class', `flex justify-center min-w-[32px] min-h-6 bg-gray-200 rounded`);
});
doc.querySelectorAll('option-box-grow').forEach(e => {
e.setAttribute('class', 'flex flex-grow ml-2 w-10 min-h-6 bg-gray-200 rounded px-4 py-2');
})
doc.querySelectorAll('option-box-blank').forEach(e => {
e.setAttribute('class', 'min-w-[32px] min-h-[32px] border border-gray-300 text-center mr-3 flex justify-center items-center');
})
doc.querySelectorAll('option-card').forEach(e => {
e.setAttribute('class', 'bg-gray-100 rounded-lg flex flex-col p-4')
})
doc.querySelectorAll('footer').forEach(e => {
e.setAttribute('class', `flex flex-col gap-2 text-sm`);
});
doc.querySelectorAll('single-line').forEach(e => {
e.setAttribute('class', `border-b border-black w-full h-4 inline-block`);
})
doc.querySelectorAll('padded-line').forEach(e => {
e.setAttribute('class', `my-2 inline-block w-full`);
})
doc.querySelectorAll('table').forEach(table => {
table.setAttribute('class', 'min-w-full bg-white border border-gray-300')
table.querySelectorAll('thead tr').forEach(tr => {
tr.setAttribute('class', 'bg-gray-100');
});
table.querySelectorAll('th').forEach(th => {
th.setAttribute('class', 'py-2 px-4 border-b font-semibold text-left');
});
table.querySelectorAll('tbody tr').forEach((tr, index) => {
if (index % 2 === 1) {
tr.setAttribute('class', 'bg-gray-50');
}
});
table.querySelectorAll('td').forEach(td => {
if (td === td.parentElement?.firstElementChild) {
td.setAttribute('class', 'py-2 px-4 border-b font-medium');
} else {
td.setAttribute('class', 'py-2 px-4 border-b');
}
});
});
doc.querySelectorAll('blockquote').forEach(e => {
setClass(e, `flex w-full justify-center ${section === "segment" ? "" : "mb-4"}`)
})
doc.querySelectorAll('items-between').forEach(e => {
setClass(e, 'flex flex-row justify-between mb-4')
})
return doc.body.innerHTML;
}
const formatTip = (tip: ITrainingTip): ITrainingTip => {
if (tip.exercise && tip.exercise.segments) {
const colors = getRandomColors(tip.exercise.segments.length);
const processedSegments: WalkthroughConfigs[] = tip.exercise.segments.map((segment, index) => ({
...segment,
html: processHtml("segment", segment.html, colors[index])
}));
return {
id: tip.id,
tipCategory: tip.tipCategory,
tipHtml: processHtml("tip", tip.tipHtml, ""),
standalone: tip.standalone,
exercise: {
question: processHtml("question", tip.exercise.question, ""),
additional: tip.exercise.additional ? processHtml("additional", tip.exercise.additional, "") : undefined,
segments: processedSegments
}
};
}
return {
id: tip.id,
tipCategory: tip.tipCategory,
tipHtml: processHtml("tip", tip.tipHtml, ""),
standalone: tip.standalone,
exercise: undefined
};
};
export default formatTip;

View File

@@ -0,0 +1,83 @@
import React, { useMemo } from 'react';
import { FaChessKnight, FaLink, FaPen } from 'react-icons/fa';
import { IoLanguage } from 'react-icons/io5';
import { MdOutlineCategory } from 'react-icons/md';
import { GiSkills } from 'react-icons/gi';
import { BiBookReader } from 'react-icons/bi';
type CategoryConfig = {
[key: string]: {
headerColor: string;
bodyColor: string;
textColor: string;
icon: any;
}
};
const categoryConfig : CategoryConfig = {
'Strategy': {
headerColor: 'bg-yellow-400',
bodyColor: 'bg-yellow-100',
textColor: 'text-yellow-900',
icon: FaChessKnight
},
'Word Partners': {
headerColor: 'bg-purple-700',
bodyColor: 'bg-purple-200',
textColor: 'text-purple-900',
icon: MdOutlineCategory
},
'Word Link': {
headerColor: 'bg-green-600',
bodyColor: 'bg-green-100',
textColor: 'text-green-900',
icon: FaLink
},
'CT Focus': {
headerColor: 'bg-purple-700',
bodyColor: 'bg-purple-200',
textColor: 'text-purple-900',
icon: GiSkills
},
'Reading Skill': {
headerColor: 'bg-orange-200',
bodyColor: 'bg-orange-100',
textColor: 'text-orange-900',
icon: BiBookReader
},
'Language for Writing': {
headerColor: 'bg-orange-200',
bodyColor: 'bg-orange-100',
textColor: 'text-orange-900',
icon: IoLanguage
},
'Writing Skill': {
headerColor: 'bg-orange-200',
bodyColor: 'bg-orange-100',
textColor: 'text-orange-900',
icon: FaPen
}
};
const Tip: React.FC<{ category: string; html: string }> = ({ category, html }) => {
const { headerColor, bodyColor, textColor, icon: Icon } = useMemo(() =>
categoryConfig[category] || categoryConfig['Strategy'], [category]
);
return (
<div className="rounded-lg overflow-hidden shadow-md mb-4">
<div className={`px-4 py-3 ${headerColor}`}>
<h2 className="font-bold text-white text-xl flex items-center">
<Icon className="ml-2 mr-2" size={24} />
{category === "CT Focus" ? "Critical Thinking" : category}
</h2>
</div>
<div className={`p-6 ${bodyColor}`}>
<p className={`text-lg ${textColor}`} dangerouslySetInnerHTML={{ __html: html }} />
</div>
</div>
);
};
export default Tip;

View File

@@ -3,6 +3,7 @@ import { Stat } from "@/interfaces/user";
export interface ITrainingContent { export interface ITrainingContent {
id: string; id: string;
created_at: number; created_at: number;
user: string;
exams: { exams: {
id: string; id: string;
date: number; date: number;
@@ -28,7 +29,7 @@ export interface ITrainingTip {
standalone: boolean; standalone: boolean;
exercise?: { exercise?: {
question: string; question: string;
highlightable: string; additional?: string;
segments: WalkthroughConfigs[] segments: WalkthroughConfigs[]
} }
} }
@@ -37,16 +38,31 @@ export interface WalkthroughConfigs {
html: string; html: string;
wordDelay: number; wordDelay: number;
holdDelay: number; holdDelay: number;
highlight: string[]; highlight?: HighlightConfig[];
insertHTML?: InsertHtmlConfig[];
}
export type HighlightTarget = 'question' | 'additional' | 'segment' | 'all';
export interface HighlightConfig {
targets: HighlightTarget[];
phrases: string[];
}
export interface InsertHtmlConfig {
target: 'question' | 'additional' | 'segment';
targetId: string;
html: string;
position: 'append' | 'prepend' | 'replace';
} }
export interface TimelineEvent { export interface TimelineEvent {
type: 'text' | 'highlight'; type: 'text' | 'highlight' | 'insert';
start: number; start: number;
end: number; end: number;
segmentIndex: number; segmentIndex: number;
content?: string[]; content?: HighlightConfig[] | InsertHtmlConfig[];
} }
export interface SegmentRef extends WalkthroughConfigs { export interface SegmentRef extends WalkthroughConfigs {

View File

@@ -1,5 +1,5 @@
import useStats from "@/hooks/useStats"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User, Type} from "@/interfaces/user"; import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User, Type, Stat, Gender} 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";
@@ -41,6 +41,7 @@ interface Props {
onViewStudents?: () => void; onViewStudents?: () => void;
onViewTeachers?: () => void; onViewTeachers?: () => void;
onViewCorporate?: () => void; onViewCorporate?: () => void;
maxUserAmount?: number;
disabled?: boolean; disabled?: boolean;
disabledFields?: { disabledFields?: {
countryManager?: boolean; countryManager?: boolean;
@@ -72,17 +73,34 @@ const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({
label, label,
})); }));
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false, disabledFields = {}}: Props) => { const UserCard = ({
user,
loggedInUser,
maxUserAmount,
onClose,
onViewStudents,
onViewTeachers,
onViewCorporate,
disabled = false,
disabledFields = {},
}: Props) => {
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate); const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
const [type, setType] = useState(user.type); const [type, setType] = useState(user.type);
const [status, setStatus] = useState(user.status); const [status, setStatus] = useState(user.status);
const [referralAgentLabel, setReferralAgentLabel] = useState<string>(); const [referralAgentLabel, setReferralAgentLabel] = useState<string>();
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined); const [position, setPosition] = useState<string | undefined>(
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined); user.type === "corporate" || user.type === "mastercorporate" ? user.demographicInformation?.position : undefined,
);
const [studentID, setStudentID] = useState<string | undefined>(user.type === "student" ? user.studentID : undefined);
const [name, setName] = useState<string>(user.name);
const [phone, setPhone] = useState<string | undefined>(user.demographicInformation?.phone);
const [gender, setGender] = useState<Gender | undefined>(user.demographicInformation?.gender);
const [referralAgent, setReferralAgent] = useState(user.type === "corporate" ? user.corporateInformation?.referralAgent : undefined); const [referralAgent, setReferralAgent] = useState(
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.referralAgent : undefined,
);
const [companyName, setCompanyName] = useState( const [companyName, setCompanyName] = useState(
user.type === "corporate" user.type === "corporate" || user.type === "mastercorporate"
? user.corporateInformation?.companyInformation.name ? user.corporateInformation?.companyInformation.name
: user.type === "agent" : user.type === "agent"
? user.agentInformation?.companyName ? user.agentInformation?.companyName
@@ -92,12 +110,22 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
const [commercialRegistration, setCommercialRegistration] = useState( const [commercialRegistration, setCommercialRegistration] = useState(
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined, user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
); );
const [userAmount, setUserAmount] = useState(user.type === "corporate" ? user.corporateInformation?.companyInformation.userAmount : undefined); const [userAmount, setUserAmount] = useState(
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined); user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.companyInformation.userAmount : undefined,
const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : "EUR"); );
const [monthlyDuration, setMonthlyDuration] = useState(user.type === "corporate" ? user.corporateInformation?.monthlyDuration : undefined); const [paymentValue, setPaymentValue] = useState(
const [commissionValue, setCommission] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.commission : undefined); user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.payment?.value : undefined,
const {stats} = useStats(user.id); );
const [paymentCurrency, setPaymentCurrency] = useState(
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.payment?.currency : "EUR",
);
const [monthlyDuration, setMonthlyDuration] = useState(
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.monthlyDuration : undefined,
);
const [commissionValue, setCommission] = useState(
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.payment?.commission : undefined,
);
const {data: stats} = useFilterRecordsByUser<Stat[]>(user.id);
const {users} = useUsers(); const {users} = useUsers();
const {codes} = useCodes(user.id); const {codes} = useCodes(user.id);
const {permissions} = usePermissions(loggedInUser.id); const {permissions} = usePermissions(loggedInUser.id);
@@ -115,16 +143,27 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
}, [users, referralAgent]); }, [users, referralAgent]);
const updateUser = () => { const updateUser = () => {
if (user.type === "corporate" && (!paymentValue || paymentValue < 0)) if (
(user.type === "corporate" || user.type === "mastercorporate") &&
(!paymentValue || paymentValue < 0) &&
["admin", "developer"].includes(loggedInUser.type)
)
return toast.error("Please set a price for the user's package before updating!"); return toast.error("Please set a price for the user's package before updating!");
if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return; if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
axios axios
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, { .post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
...user, ...user,
subscriptionExpirationDate: expiryDate, subscriptionExpirationDate: expiryDate,
studentID,
type, type,
status, status,
name,
demographicInformation: {
...(!!user.demographicInformation ? user.demographicInformation : {}),
phone,
},
agentInformation: agentInformation:
type === "agent" type === "agent"
? { ? {
@@ -134,7 +173,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
} }
: undefined, : undefined,
corporateInformation: corporateInformation:
type === "corporate" type === "corporate" || type === "mastercorporate"
? { ? {
referralAgent, referralAgent,
monthlyDuration, monthlyDuration,
@@ -178,7 +217,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
]; ];
const corporateProfileItems = const corporateProfileItems =
user.type === "corporate" user.type === "corporate" || user.type === "mastercorporate"
? [ ? [
{ {
icon: <BsPerson className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />, icon: <BsPerson className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
@@ -187,7 +226,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
}, },
{ {
icon: <BsPersonAdd className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />, icon: <BsPersonAdd className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: user.corporateInformation.companyInformation.userAmount, value: user.corporateInformation?.companyInformation?.userAmount,
label: "Number of Users", label: "Number of Users",
}, },
] ]
@@ -199,7 +238,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
}; };
return ( return (
<> <>
<ProfileSummary user={user} items={user.type === "corporate" ? corporateProfileItems : generalProfileItems} /> <ProfileSummary
user={user}
items={user.type === "corporate" || user.type === "mastercorporate" ? corporateProfileItems : generalProfileItems}
/>
{user.type === "agent" && ( {user.type === "agent" && (
<> <>
@@ -238,7 +280,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<Divider className="w-full !m-0" /> <Divider className="w-full !m-0" />
</> </>
)} )}
{user.type === "corporate" && ( {(user.type === "corporate" || user.type === "mastercorporate") && (
<> <>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
<Input <Input
@@ -248,16 +290,31 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
onChange={setCompanyName} onChange={setCompanyName}
placeholder="Enter corporate name" placeholder="Enter corporate name"
defaultValue={companyName} defaultValue={companyName}
disabled={disabled} disabled={
disabled ||
checkAccess(
loggedInUser,
getTypesOfUser(
user.type === "mastercorporate" ? ["developer", "admin"] : ["developer", "admin", "mastercorporate"],
),
)
}
/> />
<Input <Input
label="Number of Users" label="Number of Users"
type="number" type="number"
name="userAmount" name="userAmount"
max={maxUserAmount}
onChange={(e) => setUserAmount(e ? parseInt(e) : undefined)} onChange={(e) => setUserAmount(e ? parseInt(e) : undefined)}
placeholder="Enter number of users" placeholder="Enter number of users"
defaultValue={userAmount} defaultValue={userAmount}
disabled={disabled} disabled={
disabled ||
checkAccess(
loggedInUser,
getTypesOfUser(["developer", "admin", ...((user.type === "corporate" ? ["mastercorporate"] : []) as Type[])]),
)
}
/> />
<Input <Input
label="Monthly Duration" label="Monthly Duration"
@@ -266,7 +323,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
onChange={(e) => setMonthlyDuration(e ? parseInt(e) : undefined)} onChange={(e) => setMonthlyDuration(e ? parseInt(e) : undefined)}
placeholder="Enter monthly duration" placeholder="Enter monthly duration"
defaultValue={monthlyDuration} defaultValue={monthlyDuration}
disabled={disabled} disabled={disabled || checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"]))}
/> />
<div className="flex flex-col gap-3 w-full lg:col-span-3"> <div className="flex flex-col gap-3 w-full lg:col-span-3">
<label className="font-normal text-base text-mti-gray-dim">Pricing</label> <label className="font-normal text-base text-mti-gray-dim">Pricing</label>
@@ -277,7 +334,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
type="number" type="number"
defaultValue={paymentValue || 0} defaultValue={paymentValue || 0}
className="col-span-3" className="col-span-3"
disabled={disabled} disabled={disabled || checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"]))}
/> />
<Select <Select
className={clsx( className={clsx(
@@ -305,7 +362,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
color: state.isFocused ? "black" : styles.color, color: state.isFocused ? "black" : styles.color,
}), }),
}} }}
isDisabled={disabled} isDisabled={disabled || checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"]))}
/> />
</div> </div>
</div> </div>
@@ -384,10 +441,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
label="Name" label="Name"
type="text" type="text"
name="name" name="name"
onChange={() => null} onChange={setName}
placeholder="Enter your name" placeholder="Enter your name"
defaultValue={user.name} defaultValue={name}
disabled disabled={disabled}
/> />
<Input <Input
label="E-mail Address" label="E-mail Address"
@@ -409,24 +466,35 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
type="tel" type="tel"
name="phone" name="phone"
label="Phone number" label="Phone number"
onChange={() => null} onChange={setPhone}
placeholder="Enter phone number" placeholder="Enter phone number"
defaultValue={user.demographicInformation?.phone} defaultValue={phone}
disabled disabled={disabled}
/> />
</div> </div>
{user.type === "student" && ( {user.type === "student" && (
<Input <div className="flex flex-col md:flex-row gap-8 w-full">
type="text" <Input
name="passport_id" type="text"
label="Passport/National ID" name="passport_id"
onChange={() => null} label="Passport/National ID"
placeholder="Enter National ID or Passport number" onChange={() => null}
value={user.type === "student" ? user.demographicInformation?.passport_id : undefined} placeholder="Enter National ID or Passport number"
disabled value={user.type === "student" ? user.demographicInformation?.passport_id : undefined}
required disabled
/> required
/>
<Input
type="text"
name="studentID"
label="Student ID"
onChange={setStudentID}
placeholder="Enter Student ID"
disabled={!checkAccess(loggedInUser, getTypesOfUser(["teacher", "agent", "student"]), permissions, "editStudent")}
value={studentID}
/>
</div>
)} )}
<div className="flex flex-col md:flex-row gap-8 w-full"> <div className="flex flex-col md:flex-row gap-8 w-full">
@@ -456,12 +524,12 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
</RadioGroup> </RadioGroup>
</div> </div>
)} )}
{user.type === "corporate" && ( {(user.type === "corporate" || user.type === "mastercorporate") && (
<Input <Input
name="position" name="position"
onChange={setPosition} onChange={setPosition}
type="text" type="text"
label="Position" label="Department"
defaultValue={position} defaultValue={position}
placeholder="CEO, Head of Marketing..." placeholder="CEO, Head of Marketing..."
disabled disabled
@@ -472,7 +540,8 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<div className="relative flex flex-col gap-3 w-full"> <div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender</label> <label className="font-normal text-base text-mti-gray-dim">Gender</label>
<RadioGroup <RadioGroup
value={user.demographicInformation?.gender} value={gender}
onChange={(e) => setGender(e)}
className="flex flex-row gap-4 justify-between" className="flex flex-row gap-4 justify-between"
disabled={disabled}> disabled={disabled}>
<RadioGroup.Option value="male"> <RadioGroup.Option value="male">
@@ -526,7 +595,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
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 || (!["admin", "developer"].includes(loggedInUser.type) && !!loggedInUser.subscriptionExpirationDate) disabled ||
(!["admin", "developer", "mastercorporate", "corporate"].includes(loggedInUser.type) &&
!!loggedInUser.subscriptionExpirationDate)
}> }>
Enabled Enabled
</Checkbox> </Checkbox>

View File

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

View File

@@ -1,8 +1,8 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {User} from "@/interfaces/user"; import {Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils"; import {dateSorter} from "@/utils";
import moment from "moment"; import moment from "moment";
@@ -31,13 +31,40 @@ interface Props {
user: User; user: User;
} }
const studentHash = {
type: "student",
size: 25,
orderBy: "registrationDate",
};
const teacherHash = {
type: "teacher",
size: 25,
orderBy: "registrationDate",
};
const corporateHash = {
type: "corporate",
size: 25,
orderBy: "registrationDate",
};
const agentsHash = {
type: "agent",
size: 25,
orderBy: "registrationDate",
};
export default function AdminDashboard({user}: Props) { export default function AdminDashboard({user}: Props) {
const [page, setPage] = useState(""); const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const {stats} = useStats(user.id); const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
const {users, reload} = useUsers(); const {users: teachers, total: totalTeachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(teacherHash);
const {users: corporates, total: totalCorporate, reload: reloadCorporates, isLoading: isCorporatesLoading} = useUsers(corporateHash);
const {users: agents, total: totalAgents, reload: reloadAgents, isLoading: isAgentsLoading} = useUsers(agentsHash);
const {groups} = useGroups({}); const {groups} = useGroups({});
const {pending, done} = usePaymentStatusUsers(); const {pending, done} = usePaymentStatusUsers();
@@ -45,14 +72,10 @@ export default function AdminDashboard({user}: Props) {
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && router.asPath === "/#");
}, [selectedUser, page]); }, [selectedUser, router.asPath]);
// eslint-disable-next-line react-hooks/exhaustive-deps const inactiveCountryManagerFilter = (x: User) => x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate);
useEffect(reload, [page]);
const inactiveCountryManagerFilter = (x: User) =>
x.type === "agent" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
const UserDisplay = (displayUser: User) => ( const UserDisplay = (displayUser: User) => (
<div <div
@@ -72,22 +95,22 @@ export default function AdminDashboard({user}: Props) {
const StudentsList = () => { const StudentsList = () => {
const filter = (x: User) => const filter = (x: User) =>
x.type === "student" && !!selectedUser
(!!selectedUser
? groups ? groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id)) .filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id) .includes(x.id)
: true); : true;
return ( return (
<UserList <UserList
user={user} user={user}
type="student"
filters={[filter]} filters={[filter]}
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
@@ -101,22 +124,22 @@ export default function AdminDashboard({user}: Props) {
const TeachersList = () => { const TeachersList = () => {
const filter = (x: User) => const filter = (x: User) =>
x.type === "teacher" && !!selectedUser
(!!selectedUser
? groups ? groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id)) .filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id) || false .includes(x.id) || false
: true); : true;
return ( return (
<UserList <UserList
user={user} user={user}
type="teacher"
filters={[filter]} filters={[filter]}
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
@@ -129,16 +152,14 @@ export default function AdminDashboard({user}: Props) {
}; };
const AgentsList = () => { const AgentsList = () => {
const filter = (x: User) => x.type === "agent";
return ( return (
<UserList <UserList
user={user} user={user}
filters={[filter]} type="agent"
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
@@ -153,11 +174,11 @@ export default function AdminDashboard({user}: Props) {
const CorporateList = () => ( const CorporateList = () => (
<UserList <UserList
user={user} user={user}
filters={[(x) => x.type === "corporate"]} type="corporate"
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
@@ -170,16 +191,17 @@ export default function AdminDashboard({user}: Props) {
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) => list.includes(x.id);
return ( return (
<UserList <UserList
user={user} user={user}
type="corporate"
filters={[filter]} filters={[filter]}
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
@@ -197,11 +219,12 @@ export default function AdminDashboard({user}: Props) {
return ( return (
<UserList <UserList
user={user} user={user}
type="agent"
filters={[inactiveCountryManagerFilter]} filters={[inactiveCountryManagerFilter]}
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
@@ -214,16 +237,17 @@ export default function AdminDashboard({user}: Props) {
}; };
const InactiveStudentsList = () => { const InactiveStudentsList = () => {
const filter = (x: User) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)); const filter = (x: User) => x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate);
return ( return (
<UserList <UserList
user={user} user={user}
type="student"
filters={[filter]} filters={[filter]}
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
@@ -236,16 +260,17 @@ export default function AdminDashboard({user}: Props) {
}; };
const InactiveCorporateList = () => { const InactiveCorporateList = () => {
const filter = (x: User) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)); const filter = (x: User) => x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate);
return ( return (
<UserList <UserList
user={user} user={user}
filters={[filter]} filters={[filter]}
type="corporate"
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
@@ -262,7 +287,7 @@ export default function AdminDashboard({user}: Props) {
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
@@ -279,68 +304,83 @@ export default function AdminDashboard({user}: Props) {
<section className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 place-items-center items-center justify-between"> <section className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 place-items-center items-center justify-between">
<IconCard <IconCard
Icon={BsPersonFill} Icon={BsPersonFill}
isLoading={isStudentsLoading}
label="Students" label="Students"
value={users.filter((x) => x.type === "student").length} value={totalStudents}
onClick={() => setPage("students")} onClick={() => router.push("/#students")}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsPencilSquare} Icon={BsPencilSquare}
isLoading={isTeachersLoading}
label="Teachers" label="Teachers"
value={users.filter((x) => x.type === "teacher").length} value={totalTeachers}
onClick={() => setPage("teachers")} onClick={() => router.push("/#teachers")}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsBank} Icon={BsBank}
isLoading={isCorporatesLoading}
label="Corporate" label="Corporate"
value={users.filter((x) => x.type === "corporate").length} value={totalCorporate}
onClick={() => setPage("corporate")} onClick={() => router.push("/#corporate")}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsBriefcaseFill} Icon={BsBriefcaseFill}
isLoading={isAgentsLoading}
label="Country Managers" label="Country Managers"
value={users.filter((x) => x.type === "agent").length} value={totalAgents}
onClick={() => setPage("agents")} onClick={() => router.push("/#agents")}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsGlobeCentralSouthAsia} Icon={BsGlobeCentralSouthAsia}
isLoading={isAgentsLoading}
label="Countries" label="Countries"
value={[...new Set(users.filter((x) => x.demographicInformation).map((x) => x.demographicInformation?.country))].length} value={[...new Set(agents.filter((x) => x.demographicInformation).map((x) => x.demographicInformation?.country))].length}
color="purple" color="purple"
/> />
<IconCard <IconCard
onClick={() => setPage("inactiveStudents")} onClick={() => router.push("/#inactiveStudents")}
Icon={BsPersonFill} Icon={BsPersonFill}
isLoading={isStudentsLoading}
label="Inactive Students" label="Inactive Students"
value={ value={
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate))) students.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
.length .length
} }
color="rose" color="rose"
/> />
<IconCard <IconCard
onClick={() => setPage("inactiveCountryManagers")} onClick={() => router.push("/#inactiveCountryManagers")}
Icon={BsBriefcaseFill} Icon={BsBriefcaseFill}
isLoading={isAgentsLoading}
label="Inactive Country Managers" label="Inactive Country Managers"
value={users.filter(inactiveCountryManagerFilter).length} value={agents.filter(inactiveCountryManagerFilter).length}
color="rose" color="rose"
/> />
<IconCard <IconCard
onClick={() => setPage("inactiveCorporate")} onClick={() => router.push("/#inactiveCorporate")}
Icon={BsBank} Icon={BsBank}
isLoading={isCorporatesLoading}
label="Inactive Corporate" label="Inactive Corporate"
value={ value={
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate))) corporates.filter(
.length (x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)),
).length
} }
color="rose" color="rose"
/> />
<IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
<IconCard <IconCard
onClick={() => setPage("paymentpending")} onClick={() => router.push("/#paymentdone")}
Icon={BsCurrencyDollar}
label="Payment Done"
value={done.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/#paymentpending")}
Icon={BsCurrencyDollar} Icon={BsCurrencyDollar}
label="Pending Payment" label="Pending Payment"
value={pending.length} value={pending.length}
@@ -352,15 +392,20 @@ 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" /> <IconCard
onClick={() => router.push("/#corporatestudentslevels")}
Icon={BsPersonFill}
isLoading={isStudentsLoading}
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">
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span> <span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {students
.filter((x) => x.type === "student")
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) .sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
@@ -370,8 +415,7 @@ export default function AdminDashboard({user}: Props) {
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest teachers</span> <span className="p-4">Latest teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {teachers
.filter((x) => x.type === "teacher")
.sort((a, b) => { .sort((a, b) => {
return dateSorter(a, b, "desc", "registrationDate"); return dateSorter(a, b, "desc", "registrationDate");
}) })
@@ -383,8 +427,7 @@ export default function AdminDashboard({user}: Props) {
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest corporate</span> <span className="p-4">Latest corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {corporates
.filter((x) => x.type === "corporate")
.sort((a, b) => { .sort((a, b) => {
return dateSorter(a, b, "desc", "registrationDate"); return dateSorter(a, b, "desc", "registrationDate");
}) })
@@ -396,8 +439,8 @@ export default function AdminDashboard({user}: Props) {
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Unpaid Corporate</span> <span className="p-4">Unpaid Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {corporates
.filter((x) => x.type === "corporate" && x.status === "paymentDue") .filter((x) => x.status === "paymentDue")
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
))} ))}
@@ -406,10 +449,9 @@ export default function AdminDashboard({user}: Props) {
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Students expiring in 1 month</span> <span className="p-4">Students expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {students
.filter( .filter(
(x) => (x) =>
x.type === "student" &&
x.subscriptionExpirationDate && x.subscriptionExpirationDate &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) && moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)), moment().isBefore(moment(x.subscriptionExpirationDate)),
@@ -422,10 +464,9 @@ export default function AdminDashboard({user}: Props) {
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Teachers expiring in 1 month</span> <span className="p-4">Teachers expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {teachers
.filter( .filter(
(x) => (x) =>
x.type === "teacher" &&
x.subscriptionExpirationDate && x.subscriptionExpirationDate &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) && moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)), moment().isBefore(moment(x.subscriptionExpirationDate)),
@@ -438,10 +479,9 @@ export default function AdminDashboard({user}: Props) {
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Country Manager expiring in 1 month</span> <span className="p-4">Country Manager expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {agents
.filter( .filter(
(x) => (x) =>
x.type === "agent" &&
x.subscriptionExpirationDate && x.subscriptionExpirationDate &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) && moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)), moment().isBefore(moment(x.subscriptionExpirationDate)),
@@ -454,10 +494,9 @@ export default function AdminDashboard({user}: Props) {
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Corporate expiring in 1 month</span> <span className="p-4">Corporate expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {corporates
.filter( .filter(
(x) => (x) =>
x.type === "corporate" &&
x.subscriptionExpirationDate && x.subscriptionExpirationDate &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) && moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)), moment().isBefore(moment(x.subscriptionExpirationDate)),
@@ -470,10 +509,8 @@ export default function AdminDashboard({user}: Props) {
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Students</span> <span className="p-4">Expired Students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {students
.filter( .filter((x) => x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)))
(x) => x.type === "student" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
)
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
))} ))}
@@ -482,10 +519,8 @@ export default function AdminDashboard({user}: Props) {
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Teachers</span> <span className="p-4">Expired Teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {teachers
.filter( .filter((x) => x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)))
(x) => x.type === "teacher" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
)
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
))} ))}
@@ -494,10 +529,8 @@ export default function AdminDashboard({user}: Props) {
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Country Manager</span> <span className="p-4">Expired Country Manager</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {agents
.filter( .filter((x) => x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)))
(x) => x.type === "agent" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
)
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
))} ))}
@@ -506,11 +539,8 @@ export default function AdminDashboard({user}: Props) {
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Corporate</span> <span className="p-4">Expired Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {corporates
.filter( .filter((x) => x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)))
(x) =>
x.type === "corporate" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
)
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
))} ))}
@@ -530,7 +560,10 @@ export default function AdminDashboard({user}: Props) {
loggedInUser={user} loggedInUser={user}
onClose={(shouldReload) => { onClose={(shouldReload) => {
setSelectedUser(undefined); setSelectedUser(undefined);
if (shouldReload) reload(); if (shouldReload && selectedUser!.type === "student") reloadStudents();
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
if (shouldReload && selectedUser!.type === "corporate") reloadCorporates();
if (shouldReload && selectedUser!.type === "agent") reloadAgents();
}} }}
onViewStudents={ onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher" selectedUser.type === "corporate" || selectedUser.type === "teacher"
@@ -598,17 +631,17 @@ export default function AdminDashboard({user}: Props) {
)} )}
</> </>
</Modal> </Modal>
{page === "students" && <StudentsList />} {router.asPath === "/#students" && <StudentsList />}
{page === "teachers" && <TeachersList />} {router.asPath === "/#teachers" && <TeachersList />}
{page === "corporate" && <CorporateList />} {router.asPath === "/#corporate" && <CorporateList />}
{page === "agents" && <AgentsList />} {router.asPath === "/#agents" && <AgentsList />}
{page === "inactiveStudents" && <InactiveStudentsList />} {router.asPath === "/#inactiveStudents" && <InactiveStudentsList />}
{page === "inactiveCorporate" && <InactiveCorporateList />} {router.asPath === "/#inactiveCorporate" && <InactiveCorporateList />}
{page === "inactiveCountryManagers" && <InactiveCountryManagerList />} {router.asPath === "/#inactiveCountryManagers" && <InactiveCountryManagerList />}
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />} {router.asPath === "/#paymentdone" && <CorporatePaidStatusList paid={true} />}
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />} {router.asPath === "/#paymentpending" && <CorporatePaidStatusList paid={false} />}
{page === "corporatestudentslevels" && <CorporateStudentsLevelsHelper />} {router.asPath === "/#corporatestudentslevels" && <CorporateStudentsLevelsHelper />}
{page === "" && <DefaultDashboard />} {router.asPath === "/" && <DefaultDashboard />}
</> </>
); );
} }

View File

@@ -1,8 +1,8 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {User} from "@/interfaces/user"; import {Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils"; import {dateSorter} from "@/utils";
import moment from "moment"; import moment from "moment";
@@ -23,7 +23,7 @@ export default function AgentDashboard({user}: Props) {
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const {stats} = useStats(); const {data: stats} = useFilterRecordsByUser<Stat[]>();
const {users, reload} = useUsers(); const {users, reload} = useUsers();
const {pending, done} = usePaymentStatusUsers(); const {pending, done} = usePaymentStatusUsers();

View File

@@ -10,6 +10,7 @@ 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 {useAssignmentRelease} from "@/hooks/useAssignmentRelease";
import {getUserName} from "@/utils/users"; import {getUserName} from "@/utils/users";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
@@ -40,11 +41,14 @@ export default function AssignmentCard({
allowUnarchive, allowUnarchive,
allowExcelDownload, allowExcelDownload,
users, users,
released,
}: Assignment & Props) { }: Assignment & Props) {
const renderPdfIcon = usePDFDownload("assignments"); const renderPdfIcon = usePDFDownload("assignments");
const renderExcelIcon = usePDFDownload("assignments", "excel"); 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);
const renderReleaseIcon = useAssignmentRelease(id, reload);
const calculateAverageModuleScore = (module: Module) => { const calculateAverageModuleScore = (module: Module) => {
const resultModuleBandScores = results.map((r) => { const resultModuleBandScores = results.map((r) => {
@@ -58,6 +62,30 @@ export default function AssignmentCard({
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length; return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
}; };
const uniqModules = uniqBy(exams, (x) => x.module);
const shouldRenderPDF = () => {
if(released && allowDownload) {
// in order to be downloadable, the assignment has to be released
// the component should have the allowDownload prop
// and the assignment should not have the level module
return uniqModules.every(({ module }) => module !== 'level');
}
return false;
}
const shouldRenderExcel = () => {
if(released && allowExcelDownload) {
// in order to be downloadable, the assignment has to be released
// the component should have the allowExcelDownload prop
// and the assignment should have the level module
return uniqModules.some(({ module }) => module === 'level');
}
return false;
}
return ( return (
<div <div
onClick={onClick} onClick={onClick}
@@ -66,10 +94,11 @@ export default function AssignmentCard({
<div className="flex flex-row justify-between"> <div className="flex flex-row justify-between">
<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")} {shouldRenderPDF() && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{allowExcelDownload && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")} {shouldRenderExcel() && 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")}
{!released && renderReleaseIcon("text-mti-gray-dim", "text-mti-gray-dim")}
</div> </div>
</div> </div>
<ProgressBar <ProgressBar
@@ -89,7 +118,7 @@ export default function AssignmentCard({
<span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span> <span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span>
</div> </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}) => ( {uniqModules.map(({module}) => (
<div <div
key={module} key={module}
className={clsx( className={clsx(

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 {useEffect, useState} from "react"; import {useEffect, useMemo, 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";
@@ -21,22 +21,40 @@ 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"; import useExams from "@/hooks/useExams";
import {useListSearch} from "@/hooks/useListSearch";
interface Props { interface Props {
isCreating: boolean; isCreating: boolean;
assigner: string;
users: User[]; users: User[];
user: User;
groups: Group[]; groups: Group[];
assignment?: Assignment; assignment?: Assignment;
cancelCreation: () => void; cancelCreation: () => void;
} }
export default function AssignmentCreator({isCreating, assignment, assigner, groups, users, cancelCreation}: Props) { const SIZE = 12;
export default function AssignmentCreator({isCreating, assignment, user, groups, users, cancelCreation}: Props) {
const [studentsPage, setStudentsPage] = useState(0);
const [teachersPage, setTeachersPage] = useState(0);
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []); const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []);
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []); const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize})); const [teachers, setTeachers] = useState<string[]>(!!assignment ? assignment.teachers || [] : [...(user.type === "teacher" ? [user.id] : [])]);
const [name, setName] = useState(
assignment?.name ||
generate({
minLength: 6,
maxLength: 8,
min: 2,
max: 3,
join: " ",
formatter: capitalize,
}),
);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : new Date()); const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "hour").toDate());
const [endDate, setEndDate] = useState<Date | null>( const [endDate, setEndDate] = useState<Date | null>(
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(), assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
); );
@@ -44,11 +62,42 @@ 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 [released, setReleased] = useState<boolean>(assignment?.released || false);
const [autoStart, setAutostart] = useState<boolean>(assignment?.autoStart || false);
const [autoStartDate, setAutoStartDate] = useState<Date | null>(assignment ? moment(assignment.autoStartDate).toDate() : new Date());
const [useRandomExams, setUseRandomExams] = useState(true); const [useRandomExams, setUseRandomExams] = useState(true);
const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]); const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]);
const {exams} = useExams(); const {exams} = useExams();
const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]);
const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]);
const {rows: filteredStudentsRows, renderSearch: renderStudentSearch, text: studentText} = useListSearch([["name"], ["email"]], userStudents);
const {rows: filteredTeachersRows, renderSearch: renderTeacherSearch, text: teacherText} = useListSearch([["name"], ["email"]], userTeachers);
useEffect(() => setStudentsPage(0), [studentText]);
const studentRows = useMemo(
() =>
filteredStudentsRows.slice(
studentsPage * SIZE,
(studentsPage + 1) * SIZE > filteredStudentsRows.length ? filteredStudentsRows.length : (studentsPage + 1) * SIZE,
),
[filteredStudentsRows, studentsPage],
);
useEffect(() => setTeachersPage(0), [teacherText]);
const teacherRows = useMemo(
() =>
filteredTeachersRows.slice(
teachersPage * SIZE,
(teachersPage + 1) * SIZE > filteredTeachersRows.length ? filteredTeachersRows.length : (teachersPage + 1) * SIZE,
),
[filteredTeachersRows, teachersPage],
);
useEffect(() => { useEffect(() => {
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module))); setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
}, [selectedModules]); }, [selectedModules]);
@@ -62,6 +111,10 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id])); setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
}; };
const toggleTeacher = (user: User) => {
setTeachers((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
};
const createAssignment = () => { const createAssignment = () => {
setIsLoading(true); setIsLoading(true);
@@ -73,8 +126,12 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
endDate, endDate,
selectedModules, selectedModules,
generateMultiple, generateMultiple,
teachers,
variant, variant,
instructorGender, instructorGender,
released,
autoStart,
autoStartDate,
}) })
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`); toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
@@ -106,15 +163,32 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
} }
}; };
const startAssignment = () => {
if (assignment) {
setIsLoading(true);
axios
.post(`/api/assignments/${assignment.id}/start`)
.then(() => {
toast.success(`The assignment "${name}" has been started successfully!`);
cancelCreation();
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
})
.finally(() => setIsLoading(false));
}
};
return ( return (
<Modal isOpen={isCreating} onClose={cancelCreation} title="New Assignment"> <Modal isOpen={isCreating} onClose={cancelCreation} title="New Assignment">
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4">
<section className="w-full grid -md:grid-cols-1 md:grid-cols-2 place-items-center lg:grid-cols-6 -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8"> <section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined} onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}> )}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
@@ -131,7 +205,6 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined} onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}> )}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
@@ -144,11 +217,30 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} {selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div> </div>
<div
onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
? () => toggleModule("level")
: undefined
}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsClipboard className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Level</span>
{!selectedModules.includes("level") && selectedModules.length === 0 && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined} onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}> )}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
@@ -165,7 +257,6 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined} onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-3",
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}> )}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
@@ -178,34 +269,13 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} {selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div> </div>
<div
onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
? () => toggleModule("level")
: undefined
}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-3",
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsClipboard className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Level</span>
{!selectedModules.includes("level") && selectedModules.length === 0 && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
</section> </section>
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required /> <Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required />
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8"> <div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Start Date *</label> <label className="font-normal text-base text-mti-gray-dim">Limit Start Date *</label>
<ReactDatePicker <ReactDatePicker
className={clsx( className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
@@ -236,13 +306,34 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
onChange={(date) => setEndDate(date)} onChange={(date) => setEndDate(date)}
/> />
</div> </div>
{autoStart && (
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Automatic Start Date *</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
dateFormat="dd/MM/yyyy HH:mm"
selected={autoStartDate}
showTimeSelect
onChange={(date) => setAutoStartDate(date)}
/>
</div>
)}
</div> </div>
{selectedModules.includes("speaking") && ( {selectedModules.includes("speaking") && (
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label> <label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label>
<Select <Select
value={{value: instructorGender, label: capitalize(instructorGender)}} value={{
value: instructorGender,
label: capitalize(instructorGender),
}}
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)} onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
disabled={!selectedModules.includes("speaking") || !!assignment} disabled={!selectedModules.includes("speaking") || !!assignment}
options={[ options={[
@@ -285,9 +376,9 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
</div> </div>
)} )}
<section className="w-full flex flex-col gap-3"> <section className="w-full flex flex-col gap-4">
<span className="font-semibold">Assignees ({assignees.length} selected)</span> <span className="font-semibold">Assignees ({assignees.length} selected)</span>
<div className="flex gap-4 overflow-x-scroll scrollbar-hide"> <div className="grid grid-cols-5 gap-4">
{groups.map((g) => ( {groups.map((g) => (
<button <button
key={g.id} key={g.id}
@@ -309,8 +400,11 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
</button> </button>
))} ))}
</div> </div>
{renderStudentSearch()}
<div className="flex flex-wrap -md:justify-center gap-4"> <div className="flex flex-wrap -md:justify-center gap-4">
{users.map((user) => ( {studentRows.map((user) => (
<div <div
onClick={() => toggleAssignee(user)} onClick={() => toggleAssignee(user)}
className={clsx( className={clsx(
@@ -340,29 +434,145 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
</div> </div>
))} ))}
</div> </div>
<div className="w-full flex gap-2 justify-between items-center">
<div className="flex items-center gap-4 w-fit">
<Button className="w-[200px] h-fit" disabled={studentsPage === 0} onClick={() => setStudentsPage((prev) => prev - 1)}>
Previous Page
</Button>
</div>
<div className="flex items-center gap-4 w-fit">
<span className="opacity-80">
{studentsPage * SIZE + 1} -{" "}
{(studentsPage + 1) * SIZE > filteredStudentsRows.length ? filteredStudentsRows.length : (studentsPage + 1) * SIZE} /{" "}
{filteredStudentsRows.length}
</span>
<Button
className="w-[200px]"
disabled={(studentsPage + 1) * SIZE >= filteredStudentsRows.length}
onClick={() => setStudentsPage((prev) => prev + 1)}>
Next Page
</Button>
</div>
</div>
</section> </section>
<div className="flex flex-col gap-4 w-full items-end">
{user.type !== "teacher" && (
<section className="w-full flex flex-col gap-3">
<span className="font-semibold">Teachers ({teachers.length} selected)</span>
<div className="grid grid-cols-5 gap-4">
{groups.map((g) => (
<button
key={g.id}
onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
if (groupStudentIds.every((u) => teachers.includes(u))) {
setTeachers((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
} else {
setTeachers((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
}
}}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => teachers.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
{g.name}
</button>
))}
</div>
{renderTeacherSearch()}
<div className="flex flex-wrap -md:justify-center gap-4">
{teacherRows.map((user) => (
<div
onClick={() => toggleTeacher(user)}
className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300",
teachers.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
)}
key={user.id}>
<span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span>
</span>
<span className="text-mti-black/80 text-sm whitespace-pre-wrap mt-2">
Groups:{" "}
{groups
.filter((g) => g.participants.includes(user.id))
.map((g) => g.name)
.join(", ")}
</span>
</div>
))}
</div>
<div className="w-full flex gap-2 justify-between items-center">
<div className="flex items-center gap-4 w-fit">
<Button className="w-[200px] h-fit" disabled={teachersPage === 0} onClick={() => setTeachersPage((prev) => prev - 1)}>
Previous Page
</Button>
</div>
<div className="flex items-center gap-4 w-fit">
<span className="opacity-80">
{teachersPage * SIZE + 1} -{" "}
{(teachersPage + 1) * SIZE > filteredTeachersRows.length
? filteredTeachersRows.length
: (teachersPage + 1) * SIZE}{" "}
/ {filteredTeachersRows.length}
</span>
<Button
className="w-[200px]"
disabled={(teachersPage + 1) * SIZE >= filteredTeachersRows.length}
onClick={() => setTeachersPage((prev) => prev + 1)}>
Next Page
</Button>
</div>
</div>
</section>
)}
<div className="flex gap-4 w-full items-end">
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}> <Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
Full length exams Full length exams
</Checkbox> </Checkbox>
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}> <Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
Generate different exams Generate different exams
</Checkbox> </Checkbox>
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}>
Auto release results
</Checkbox>
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}>
Auto start exam
</Checkbox>
</div> </div>
<div className="flex gap-4 w-full justify-end"> <div className="flex gap-4 w-full justify-end">
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}> <Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
Cancel Cancel
</Button> </Button>
{assignment && ( {assignment && (
<Button <>
className="w-full max-w-[200px]" <Button
color="red" className="w-full max-w-[200px]"
variant="outline" color="green"
onClick={deleteAssignment} variant="outline"
disabled={isLoading} onClick={startAssignment}
isLoading={isLoading}> disabled={isLoading || moment().isAfter(startDate)}
Delete isLoading={isLoading}>
</Button> Start
</Button>
<Button
className="w-full max-w-[200px]"
color="red"
variant="outline"
onClick={deleteAssignment}
disabled={isLoading}
isLoading={isLoading}>
Delete
</Button>
</>
)} )}
<Button <Button
disabled={ disabled={

View File

@@ -2,310 +2,433 @@ import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {Assignment} from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import {Stat, User} from "@/interfaces/user"; import { Stat, 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 {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 { 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";
import moment from "moment"; import moment from "moment";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; import {
import {toast} from "react-toastify"; BsBook,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
} from "react-icons/bs";
import { toast } from "react-toastify";
import { futureAssignmentFilter } from "@/utils/assignments";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
assignment?: Assignment; assignment?: Assignment;
onClose: () => void; onClose: () => void;
} }
export default function AssignmentView({isOpen, assignment, onClose}: Props) { export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
const {users} = useUsers(); const { users } = useUsers();
const router = useRouter(); const router = useRouter();
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions); const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions); const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules); const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const deleteAssignment = async () => { const deleteAssignment = async () => {
if (!confirm("Are you sure you want to delete this assignment?")) return; if (!confirm("Are you sure you want to delete this assignment?")) return;
axios axios
.delete(`/api/assignments/${assignment?.id}`) .delete(`/api/assignments/${assignment?.id}`)
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`)) .then(() =>
.catch(() => toast.error("Something went wrong, please try again later.")) toast.success(
.finally(onClose); `Successfully deleted the assignment "${assignment?.name}".`
}; )
)
.catch(() => toast.error("Something went wrong, please try again later."))
.finally(onClose);
};
const formatTimestamp = (timestamp: string) => { const startAssignment = () => {
const date = moment(parseInt(timestamp)); if (assignment) {
const formatter = "YYYY/MM/DD - HH:mm"; axios
.post(`/api/assignments/${assignment.id}/start`)
.then(() => {
toast.success(
`The assignment "${assignment.name}" has been started successfully!`
);
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
});
}
};
return date.format(formatter); const formatTimestamp = (timestamp: string) => {
}; const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm";
const calculateAverageModuleScore = (module: Module) => { return date.format(formatter);
if (!assignment) return -1; };
const resultModuleBandScores = assignment.results.map((r) => { const calculateAverageModuleScore = (module: Module) => {
const moduleStats = r.stats.filter((s) => s.module === module); if (!assignment) return -1;
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0); const resultModuleBandScores = assignment.results.map((r) => {
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0); const moduleStats = r.stats.filter((s) => s.module === module);
return calculateBandScore(correct, total, module, r.type);
});
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length; const correct = moduleStats.reduce(
}; (acc, curr) => acc + curr.score.correct,
0
);
const total = moduleStats.reduce(
(acc, curr) => acc + curr.score.total,
0
);
return calculateBandScore(correct, total, module, r.type);
});
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => { return resultModuleBandScores.length === 0
const scores: { ? -1
[key in Module]: {total: number; missing: number; correct: number}; : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
} = { assignment.results.length;
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) => { const aggregateScoresByModule = (
scores[x.module!] = { stats: Stat[]
total: scores[x.module!].total + x.score.total, ): { module: Module; total: number; missing: number; correct: number }[] => {
correct: scores[x.module!].correct + x.score.correct, const scores: {
missing: scores[x.module!].missing + x.score.missing, [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,
},
};
return Object.keys(scores) stats.forEach((x) => {
.filter((x) => scores[x as Module].total > 0) scores[x.module!] = {
.map((x) => ({module: x as Module, ...scores[x as 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,
};
});
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => { return Object.keys(scores)
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0); .filter((x) => scores[x as Module].total > 0)
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0); .map((x) => ({ module: x as Module, ...scores[x as Module] }));
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0); };
const aggregatedLevels = aggregatedScores.map((x) => ({ const customContent = (
module: x.module, stats: Stat[],
level: calculateBandScore(x.correct, x.total, x.module, focus), user: string,
})); focus: "academic" | "general"
) => {
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 timeSpent = stats[0].timeSpent; const aggregatedLevels = aggregatedScores.map((x) => ({
module: x.module,
level: calculateBandScore(x.correct, x.total, x.module, focus),
}));
const selectExam = () => { const timeSpent = stats[0].timeSpent;
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
Promise.all(examPromises).then((exams) => { const selectExam = () => {
if (exams.every((x) => !!x)) { const examPromises = uniqBy(stats, "exam").map((stat) =>
setUserSolutions(convertToUserSolutions(stats)); getExamById(stat.module, stat.exam)
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 = ( Promise.all(examPromises).then((exams) => {
<> if (exams.every((x) => !!x)) {
<div className="-md:items-center flex w-full justify-between 2xl:items-center"> setUserSolutions(convertToUserSolutions(stats));
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2"> setShowSolutions(true);
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span> setExams(exams.map((x) => x!).sort(sortByModule));
{timeSpent && ( setSelectedModules(
<> exams
<span className="md:hidden 2xl:flex"> </span> .map((x) => x!)
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span> .sort(sortByModule)
</> .map((x) => x!.module)
)} );
</div> router.push("/exercises");
<span }
className={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",
)}>
Level{" "}
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
</span>
</div>
<div className="flex w-full flex-col gap-1"> const content = (
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2"> <>
{aggregatedLevels.map(({module, level}) => ( <div className="-md:items-center flex w-full justify-between 2xl:items-center">
<div <div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
key={module} <span className="font-medium">
className={clsx( {formatTimestamp(stats[0].date.toString())}
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4", </span>
module === "reading" && "bg-ielts-reading", {timeSpent && (
module === "listening" && "bg-ielts-listening", <>
module === "writing" && "bg-ielts-writing", <span className="md:hidden 2xl:flex"> </span>
module === "speaking" && "bg-ielts-speaking", <span className="text-sm">
module === "level" && "bg-ielts-level", {Math.floor(timeSpent / 60)} minutes
)}> </span>
{module === "reading" && <BsBook className="h-4 w-4" />} </>
{module === "listening" && <BsHeadphones className="h-4 w-4" />} )}
{module === "writing" && <BsPen className="h-4 w-4" />} </div>
{module === "speaking" && <BsMegaphone className="h-4 w-4" />} <span
{module === "level" && <BsClipboard className="h-4 w-4" />} className={clsx(
<span className="text-sm">{level.toFixed(1)}</span> correct / total >= 0.7 && "text-mti-purple",
</div> correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
))} correct / total < 0.3 && "text-mti-rose"
</div> )}
</div> >
</> Level{" "}
); {(
aggregatedLevels.reduce(
(accumulator, current) => accumulator + current.level,
0
) / aggregatedLevels.length
).toFixed(1)}
</span>
</div>
return ( <div className="flex w-full flex-col gap-1">
<div className="flex flex-col gap-2"> <div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
<span> {aggregatedLevels.map(({ module, level }) => (
{(() => { <div
const student = users.find((u) => u.id === user); key={module}
return `${student?.name} (${student?.email})`; className={clsx(
})()} "-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
</span> module === "reading" && "bg-ielts-reading",
<div module === "listening" && "bg-ielts-listening",
key={user} module === "writing" && "bg-ielts-writing",
className={clsx( module === "speaking" && "bg-ielts-speaking",
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out", module === "level" && "bg-ielts-level"
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", {module === "reading" && <BsBook className="h-4 w-4" />}
)} {module === "listening" && <BsHeadphones className="h-4 w-4" />}
onClick={selectExam} {module === "writing" && <BsPen className="h-4 w-4" />}
role="button"> {module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{content} {module === "level" && <BsClipboard className="h-4 w-4" />}
</div> <span className="text-sm">{level.toFixed(1)}</span>
<div </div>
key={user} ))}
className={clsx( </div>
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden", </div>
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."
role="button">
{content}
</div>
</div>
);
};
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}> <div className="flex flex-col gap-2">
<div className="mt-4 flex w-full flex-col gap-4"> <span>
<ProgressBar {(() => {
color="purple" const student = users.find((u) => u.id === user);
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`} return `${student?.name} (${student?.email})`;
className="h-6" })()}
textClassName={ </span>
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white" <div
} key={user}
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100} className={clsx(
/> "border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
<div className="flex items-start gap-8"> correct / total >= 0.7 && "hover:border-mti-purple",
<div className="flex flex-col gap-2"> correct / total >= 0.3 &&
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span> correct / total < 0.7 &&
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span> "hover:border-mti-red",
</div> correct / total < 0.3 && "hover:border-mti-rose"
<div className="flex flex-col gap-2"> )}
<span> onClick={selectExam}
Assignees:{" "} role="button"
{users >
.filter((u) => assignment?.assignees.includes(u.id)) {content}
.map((u) => `${u.name} (${u.email})`) </div>
.join(", ")} <div
</span> key={user}
<span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span> className={clsx(
</div> "border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
</div> correct / total >= 0.7 && "hover:border-mti-purple",
<div className="flex flex-col gap-2"> correct / total >= 0.3 &&
<span className="text-xl font-bold">Average Scores</span> correct / total < 0.7 &&
<div className="-md:mt-2 flex w-full items-center gap-4"> "hover:border-mti-red",
{assignment && correct / total < 0.3 && "hover:border-mti-rose"
uniqBy(assignment.exams, (x) => x.module).map(({module}) => ( )}
<div data-tip="Your screen size is too small to view previous exams."
data-tip={capitalize(module)} role="button"
key={module} >
className={clsx( {content}
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4", </div>
module === "reading" && "bg-ielts-reading", </div>
module === "listening" && "bg-ielts-listening", );
module === "writing" && "bg-ielts-writing", };
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />}
{calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
)}
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">
Results ({assignment?.results.length}/{assignment?.assignees.length})
</span>
<div>
{assignment && assignment?.results.length > 0 && (
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
</div>
)}
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
</div>
</div>
<div className="flex gap-4 w-full items-center justify-end"> const shouldRenderStart = () => {
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && ( if (assignment) {
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}> if (futureAssignmentFilter(assignment)) {
Delete return true;
</Button> }
)} }
<Button onClick={onClose} className="w-full max-w-[200px]">
Close return false;
</Button> };
</div>
</div> return (
</Modal> <Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
); <div className="mt-4 flex w-full flex-col gap-4">
<ProgressBar
color="purple"
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
className="h-6"
textClassName={
(assignment?.results.length || 0) /
(assignment?.assignees.length || 1) <
0.5
? "!text-mti-gray-dim font-light"
: "text-white"
}
percentage={
((assignment?.results.length || 0) /
(assignment?.assignees.length || 1)) *
100
}
/>
<div className="flex items-start gap-8">
<div className="flex flex-col gap-2">
<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>
</div>
<div className="flex flex-col gap-2">
<span>
Assignees:{" "}
{users
.filter((u) => assignment?.assignees.includes(u.id))
.map((u) => `${u.name} (${u.email})`)
.join(", ")}
</span>
<span>
Assigner:{" "}
{getUserName(users.find((x) => x.id === assignment?.assigner))}
</span>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">Average Scores</span>
<div className="-md:mt-2 flex w-full items-center gap-4">
{assignment &&
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
<div
data-tip={capitalize(module)}
key={module}
className={clsx(
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
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="h-4 w-4" />}
{module === "listening" && (
<BsHeadphones className="h-4 w-4" />
)}
{module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />}
{calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">
{calculateAverageModuleScore(module).toFixed(1)}
</span>
)}
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">
Results ({assignment?.results.length}/{assignment?.assignees.length}
)
</span>
<div>
{assignment && assignment?.results.length > 0 && (
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{assignment.results.map((r) =>
customContent(r.stats, r.user, r.type)
)}
</div>
)}
{assignment && assignment?.results.length === 0 && (
<span className="ml-1 font-semibold">No results yet...</span>
)}
</div>
</div>
<div className="flex gap-4 w-full items-center justify-end">
{assignment &&
(assignment.results.length === assignment.assignees.length ||
moment().isAfter(moment(assignment.endDate))) && (
<Button
variant="outline"
color="red"
className="w-full max-w-[200px]"
onClick={deleteAssignment}
>
Delete
</Button>
)}
{/** if the assignment is not deemed as active yet, display start */}
{shouldRenderStart() && (
<Button
variant="outline"
color="green"
className="w-full max-w-[200px]"
onClick={startAssignment}
>
Start
</Button>
)}
<Button onClick={onClose} className="w-full max-w-[200px]">
Close
</Button>
</div>
</div>
</Modal>
);
} }

View File

@@ -1,647 +0,0 @@
/* 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, 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,
BsClipboard2DataFill,
BsClock,
BsGlobeCentralSouthAsia,
BsPaperclip,
BsPerson,
BsPersonAdd,
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPencilSquare,
BsPersonBadge,
BsPersonCheck,
BsPeople,
BsArrowRepeat,
BsPlus,
BsEnvelopePaper,
} 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 {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 {
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) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
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 {users, reload, isLoading} = useUsers();
const {codes} = useCodes(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 router = useRouter();
useEffect(() => {
setShowModal(!!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 teacherFilter = (user: User) => user.type === "teacher" && groups.flatMap((g) => g.participants).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
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).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
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).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 GroupsList = () => {
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).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());
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 formattedStats = studentStats
.map((s) => ({
focus: users.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
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,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
const DefaultDashboard = () => (
<>
{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
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(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={`${userBalance}/${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={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 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 === "assignments" && <AssignmentsPage />}
{page === "studentsPerformance" && <StudentPerformancePage />}
{page === "" && <DefaultDashboard />}
</>
);
}

View File

@@ -0,0 +1,45 @@
import useUsers, {userHashStudent, userHashTeacher} from "@/hooks/useUsers";
import {CorporateUser, User} from "@/interfaces/user";
import {useRouter} from "next/router";
import {useMemo} from "react";
import {BsArrowLeft} from "react-icons/bs";
import MasterStatistical from "../MasterCorporate/MasterStatistical";
interface Props {
user: CorporateUser;
}
const MasterStatisticalPage = ({user}: Props) => {
const {users: students} = useUsers(userHashStudent);
const {users: teachers} = useUsers(userHashTeacher);
// this workaround will allow us toreuse the master statistical due to master corporate restraints
// while still being able to use the corporate user
const groupedByNameCorporateIds = useMemo(
() => ({
[user.corporateInformation?.companyInformation?.name || user.name]: [user.id],
}),
[user],
);
const teachersAndStudents = useMemo(() => [...students, ...teachers], [students, teachers]);
const router = useRouter();
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
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={teachersAndStudents} corporateUsers={groupedByNameCorporateIds} displaySelection={false} />
</>
);
};
export default MasterStatisticalPage;

View File

@@ -0,0 +1,154 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers, {userHashStudent, userHashTeacher, userHashCorporate} 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, useMemo, useState} from "react";
import {
BsArrowLeft,
BsClipboard2Data,
BsClipboard2DataFill,
BsClock,
BsGlobeCentralSouthAsia,
BsPaperclip,
BsPerson,
BsPersonAdd,
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPencilSquare,
BsPersonBadge,
BsPersonCheck,
BsPeople,
BsArrowRepeat,
BsPlus,
BsEnvelopePaper,
} 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 {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";
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
import useUserBalance from "@/hooks/useUserBalance";
import AssignmentsPage from "../views/AssignmentsPage";
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 StudentPerformanceList;

View File

@@ -0,0 +1,49 @@
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useGroups from "@/hooks/useGroups";
import useUsers, {userHashStudent} from "@/hooks/useUsers";
import {Stat, User} from "@/interfaces/user";
import {getUserCompanyName} from "@/resources/user";
import clsx from "clsx";
import {useRouter} from "next/router";
import {BsArrowLeft, BsArrowRepeat} from "react-icons/bs";
import StudentPerformanceList from "./StudentPerformanceList";
interface Props {
user: User;
}
const StudentPerformancePage = ({user}: Props) => {
const {groups} = useGroups({admin: user.id});
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent);
const {data: stats} = useFilterRecordsByUser<Stat[]>();
const router = useRouter();
const performanceStudents = students.map((u) => ({
...u,
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
corporateName: getUserCompanyName(user, [], groups),
}));
return (
<>
<div className="w-full flex justify-between items-center">
<div
onClick={() => router.push("/")}
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={reloadStudents}
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", isStudentsLoading && "animate-spin")} />
</div>
</div>
<StudentPerformanceList items={performanceStudents} stats={stats} users={students} />
</>
);
};
export default StudentPerformancePage;

View File

@@ -0,0 +1,405 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers, {userHashStudent, userHashTeacher, userHashCorporate} 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, useMemo, useState} from "react";
import {
BsArrowLeft,
BsClipboard2Data,
BsClipboard2DataFill,
BsClock,
BsGlobeCentralSouthAsia,
BsPaperclip,
BsPerson,
BsPersonAdd,
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPencilSquare,
BsPersonBadge,
BsPersonCheck,
BsPeople,
BsArrowRepeat,
BsPlus,
BsEnvelopePaper,
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 {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";
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
import useUserBalance from "@/hooks/useUserBalance";
import AssignmentsPage from "../views/AssignmentsPage";
import StudentPerformancePage from "./StudentPerformancePage";
import MasterStatistical from "../MasterCorporate/MasterStatistical";
import MasterStatisticalPage from "./MasterStatisticalPage";
interface Props {
user: CorporateUser;
linkedCorporate?: CorporateUser | MasterCorporateUser;
}
const studentHash = {
type: "student",
orderBy: "registrationDate",
size: 25,
};
const teacherHash = {
type: "teacher",
orderBy: "registrationDate",
size: 25,
};
export default function CorporateDashboard({user, linkedCorporate}: Props) {
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const {data: stats} = useFilterRecordsByUser<Stat[]>();
const {groups} = useGroups({admin: user.id});
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
const {balance} = useUserBalance();
const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
const {users: teachers, total: totalTeachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(teacherHash);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
const assignmentsUsers = useMemo(
() =>
[...teachers, ...students].filter((x) =>
!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id),
),
[groups, teachers, students, selectedUser],
);
useEffect(() => {
setShowModal(!!selectedUser && router.asPath === "/#");
}, [selectedUser, router.asPath]);
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 GroupsList = () => {
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
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.filter(filter).length})</h2>
</div>
<GroupList user={user} />
</>
);
};
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
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,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
if (router.asPath === "/#students")
return (
<UserList
user={user}
type="student"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
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>
)}
/>
);
if (router.asPath === "/#teachers")
return (
<UserList
user={user}
type="teacher"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
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>
)}
/>
);
if (router.asPath === "/#groups") return <GroupsList />;
if (router.asPath === "/#studentsPerformance") return <StudentPerformancePage user={user} />;
if (router.asPath === "/#assignments")
return (
<AssignmentsPage
assignments={assignments}
user={user}
groups={assignmentsGroups}
reloadAssignments={reloadAssignments}
isLoading={isAssignmentsLoading}
onBack={() => router.push("/")}
/>
);
if (router.asPath === "/#statistical") return <MasterStatisticalPage user={user} />;
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 && selectedUser!.type === "student") reloadStudents();
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
}}
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>
<>
{!!linkedCorporate && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
Linked to: <b>{linkedCorporate?.corporateInformation?.companyInformation.name || linkedCorporate.name}</b>
</div>
)}
<section className="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center">
<IconCard
onClick={() => router.push("/#students")}
isLoading={isStudentsLoading}
Icon={BsPersonFill}
label="Students"
value={totalStudents}
color="purple"
/>
<IconCard
onClick={() => router.push("/#teachers")}
isLoading={isTeachersLoading}
Icon={BsPencilSquare}
label="Teachers"
value={totalTeachers}
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}
isLoading={isStudentsLoading}
label="Average Level"
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
color="purple"
/>
<IconCard onClick={() => router.push("/#groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
<IconCard
Icon={BsPersonCheck}
label="User Balance"
value={`${balance}/${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={BsPersonFillGear}
isLoading={isStudentsLoading}
label="Student Performance"
value={totalStudents}
color="purple"
onClick={() => router.push("/#studentsPerformance")}
/>
<IconCard Icon={BsDatabase} label="Master Statistical" color="purple" onClick={() => router.push("/#statistical")} />
<button
disabled={isAssignmentsLoading}
onClick={() => router.push("/#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">
{students
.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">
{teachers
.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">
{students
.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">
{students
.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>
</>
</>
);
}

View File

@@ -1,5 +1,5 @@
import React from "react"; import React, {useMemo} from "react";
import useUsers from "@/hooks/useUsers"; import useUsers, { userHashStudent, userHashTeacher, userHashCorporate } from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
@@ -61,29 +61,17 @@ const Card = ({user}: {user: User}) => {
}; };
const CorporateStudentsLevels = () => { 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 [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 {users: students} = useUsers(userHashStudent);
const {users: corporates} = useUsers(userHashCorporate);
const groupsParticipants = groupsFromCorporate const corporate = useMemo(() => corporates.find((u) => u.id === corporateId) || corporates[0], [corporates, corporateId]);
.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 ( return (
<> <>
<Select <Select
options={corporateUsers.map((x: User) => ({ options={corporates.map((x: User) => ({
value: x.id, value: x.id,
label: `${x.name} - ${x.email}`, label: `${x.name} - ${x.email}`,
}))} }))}
@@ -98,7 +86,7 @@ const CorporateStudentsLevels = () => {
}), }),
}} }}
/> />
{groupsParticipants.map((u) => ( {students.map((u) => (
<Card user={u} key={u.id} /> <Card user={u} key={u.id} />
))} ))}
</> </>

View File

@@ -8,14 +8,17 @@ interface Props {
color: "purple" | "rose" | "red" | "green"; color: "purple" | "rose" | "red" | "green";
tooltip?: string; tooltip?: string;
onClick?: () => void; onClick?: () => void;
isSelected?: boolean;
isLoading?: boolean;
className?: string;
} }
export default function IconCard({Icon, label, value, color, tooltip, onClick}: Props) { export default function IconCard({Icon, label, value, color, tooltip, onClick, className, isLoading, isSelected}: Props) {
const colorClasses: {[key in typeof color]: string} = { const colorClasses: {[key in typeof color]: string} = {
purple: "text-mti-purple-light", purple: "mti-purple-light",
red: "text-mti-red-light", red: "mti-red-light",
rose: "text-mti-rose-light", rose: "mti-rose-light",
green: "text-mti-green-light", green: "mti-green-light",
}; };
return ( return (
@@ -24,12 +27,16 @@ export default function IconCard({Icon, label, value, color, tooltip, onClick}:
className={clsx( className={clsx(
"bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300", "bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300",
tooltip && "tooltip tooltip-bottom", tooltip && "tooltip tooltip-bottom",
isSelected && `border border-solid border-${colorClasses[color]}`,
className,
)} )}
data-tip={tooltip}> data-tip={tooltip}>
<Icon className={clsx("text-6xl", colorClasses[color])} /> <Icon className={clsx("text-6xl", `text-${colorClasses[color]}`)} />
<span className="flex flex-col gap-1 items-center text-xl"> <span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">{label}</span> <span className="text-lg">{label}</span>
<span className={clsx("font-semibold", colorClasses[color])}>{value}</span> <span className={clsx("font-semibold", `text-${colorClasses[color]}`, isLoading && "animate-pulse")}>
{isLoading ? "..." : value}
</span>
</span> </span>
</div> </div>
); );

View File

@@ -1,838 +0,0 @@
/* 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,413 @@
import React, {useEffect, useMemo, useState} from "react";
import {CorporateUser, StudentUser, User} from "@/interfaces/user";
import {BsFileExcel, BsBank, BsPersonFill} from "react-icons/bs";
import IconCard from "../IconCard";
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
import ReactDatePicker from "react-datepicker";
import moment from "moment";
import {AssignmentWithCorporateId} from "@/interfaces/results";
import {flexRender, createColumnHelper, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import Checkbox from "@/components/Low/Checkbox";
import {useListSearch} from "@/hooks/useListSearch";
import axios from "axios";
import {toast} from "react-toastify";
import Button from "@/components/Low/Button";
import {getUserName} from "@/utils/users";
interface GroupedCorporateUsers {
// list of user Ids
[key: string]: string[];
}
interface Props {
corporateUsers: GroupedCorporateUsers;
users: User[];
displaySelection?: boolean;
}
interface TableData {
user: User | undefined;
email: string;
correct: number;
corporate: string;
submitted: boolean;
date: moment.Moment;
assignment: string;
corporateId: string;
}
interface UserCount {
userCount: number;
maxUserCount: number;
}
const searchFilters = [["email"], ["user"], ["userId"], ["exams"], ["assignment"]];
const SIZE = 16;
const MasterStatistical = (props: Props) => {
const {users, corporateUsers, displaySelection = true} = props;
const [page, setPage] = useState(0);
// const corporateRelevantUsers = React.useMemo(
// () => corporateUsers.filter((x) => x.type !== "student") as CorporateUser[],
// [corporateUsers]
// );
const corporates = React.useMemo(() => Object.values(corporateUsers).flat(), [corporateUsers]);
const [selectedCorporates, setSelectedCorporates] = React.useState<string[]>(corporates);
const [startDate, setStartDate] = React.useState<Date | null>(moment("01/01/2023").toDate());
const [endDate, setEndDate] = React.useState<Date | null>(moment().endOf("year").toDate());
const {assignments, isLoading} = useAssignmentsCorporates({
corporates: selectedCorporates,
startDate,
endDate,
});
const [downloading, setDownloading] = React.useState<boolean>(false);
const tableResults = React.useMemo(
() =>
assignments.reduce((accmA: TableData[], a: AssignmentWithCorporateId) => {
const userResults = a.assignees.map((assignee) => {
const userData = users.find((u) => u.id === assignee);
const userStats = a.results.find((r) => r.user === assignee)?.stats || [];
const corporate = getUserName(users.find((u) => u.id === a.assigner));
const commonData = {
user: userData,
email: userData?.email || "N/A",
userId: assignee,
corporateId: a.corporateId,
exams: a.exams.map((x) => x.id).join(", "),
corporate,
assignment: a.name,
};
if (userStats.length === 0) {
return {
...commonData,
correct: 0,
submitted: false,
date: null,
};
}
return {
...commonData,
correct: userStats.reduce((n, e) => n + e.score.correct, 0),
submitted: true,
date: moment.max(userStats.map((e) => moment(e.date))),
};
}) as TableData[];
return [...accmA, ...userResults];
}, []),
[assignments, users],
);
const getCorporateScores = (corporateId: string): UserCount => {
const corporateAssignmentsUsers = assignments.filter((a) => a.corporateId === corporateId).reduce((acc, a) => acc + a.assignees.length, 0);
const corporateResults = tableResults.filter((r) => r.corporateId === corporateId).length;
return {
maxUserCount: corporateAssignmentsUsers,
userCount: corporateResults,
};
};
const getCorporatesScoresHash = (data: string[]) =>
data.reduce(
(accm, id) => ({
...accm,
[id]: getCorporateScores(id),
}),
{},
) as Record<string, UserCount>;
const getConsolidateScore = (data: Record<string, UserCount>) =>
Object.values(data).reduce(
(acc: UserCount, {userCount, maxUserCount}: UserCount) => ({
userCount: acc.userCount + userCount,
maxUserCount: acc.maxUserCount + maxUserCount,
}),
{userCount: 0, maxUserCount: 0},
);
const corporateScores = getCorporatesScoresHash(corporates);
const consolidateScore = getConsolidateScore(corporateScores);
const getConsolidateScoreStr = (data: UserCount) => `${data.userCount}/${data.maxUserCount}`;
const columnHelper = createColumnHelper<TableData>();
const defaultColumns = [
columnHelper.accessor("user", {
header: "User",
id: "user",
cell: (info) => {
return <span>{info.getValue()?.name || "N/A"}</span>;
},
}),
columnHelper.accessor("email", {
header: "Email",
id: "email",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("user", {
header: "Student ID",
id: "studentID",
cell: (info) => {
return <span>{(info.getValue() as StudentUser)?.studentID || "N/A"}</span>;
},
}),
...(displaySelection
? [
columnHelper.accessor("corporate", {
header: "Corporate",
id: "corporate",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
]
: []),
columnHelper.accessor("assignment", {
header: "Assignment",
id: "assignment",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("submitted", {
header: "Submitted",
id: "submitted",
cell: (info) => {
return (
<Checkbox isChecked={info.getValue()} disabled onChange={() => {}}>
<span></span>
</Checkbox>
);
},
}),
columnHelper.accessor("correct", {
header: "Score",
id: "correct",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("date", {
header: "Date",
id: "date",
cell: (info) => {
const date = info.getValue();
if (date) {
return <span>{!!date ? date.format("DD/MM/YYYY") : "N/A"}</span>;
}
return <span>{""}</span>;
},
}),
];
const {rows: filteredRows, renderSearch, text: searchText} = useListSearch(searchFilters, tableResults);
useEffect(() => setPage(0), [searchText]);
const rows = useMemo(
() => filteredRows.slice(page * SIZE, (page + 1) * SIZE > filteredRows.length ? filteredRows.length : (page + 1) * SIZE),
[filteredRows, page],
);
const table = useReactTable({
data: rows,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const areAllSelected = selectedCorporates.length === corporates.length;
const getStudentsConsolidateScore = () => {
if (tableResults.length === 0) {
return {highest: null, lowest: null};
}
// Find the student with the highest and lowest score
return tableResults.reduce(
(acc, curr) => {
if (curr.correct > acc.highest.correct) {
acc.highest = curr;
}
if (curr.correct < acc.lowest.correct) {
acc.lowest = curr;
}
return acc;
},
{highest: tableResults[0], lowest: tableResults[0]},
);
};
const triggerDownload = async () => {
try {
setDownloading(true);
const res = await axios.post("/api/assignments/statistical/excel", {
ids: selectedCorporates,
...(startDate ? {startDate: startDate.toISOString()} : {}),
...(endDate ? {endDate: endDate.toISOString()} : {}),
searchText,
});
toast.success("Report ready!");
const link = document.createElement("a");
link.href = res.data;
// download should have worked but there are some CORS issues
// https://firebase.google.com/docs/storage/web/download-files#cors_configuration
// link.download="report.pdf";
link.target = "_blank";
link.rel = "noreferrer";
link.click();
setDownloading(false);
} catch (err) {
toast.error("Failed to display the report!");
console.error(err);
setDownloading(false);
}
};
const consolidateResults = getStudentsConsolidateScore();
return (
<>
{displaySelection && (
<div className="flex flex-wrap gap-2 items-center text-center">
<IconCard
Icon={BsBank}
label="Consolidate"
isLoading={isLoading}
value={getConsolidateScoreStr(consolidateScore)}
color="purple"
onClick={() => {
if (areAllSelected) {
setSelectedCorporates([]);
return;
}
setSelectedCorporates(corporates);
}}
isSelected={areAllSelected}
/>
{Object.keys(corporateUsers).map((corporateName) => {
const group = corporateUsers[corporateName];
const isSelected = group.every((id) => selectedCorporates.includes(id));
const valueHash = getCorporatesScoresHash(group);
const value = getConsolidateScoreStr(getConsolidateScore(valueHash));
return (
<IconCard
key={corporateName}
Icon={BsBank}
isLoading={isLoading}
label={corporateName}
value={value}
color="purple"
onClick={() => {
if (isSelected) {
setSelectedCorporates((prev) => prev.filter((x) => !group.includes(x)));
return;
}
setSelectedCorporates((prev) => [...new Set([...prev, ...group])]);
}}
isSelected={isSelected}
/>
);
})}
</div>
)}
<div className="flex gap-3 w-full">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Date</label>
<ReactDatePicker
dateFormat="dd/MM/yyyy"
className="px-4 py-6 w-52 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
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>
{renderSearch()}
<div className="flex flex-col gap-3 justify-end">
<Button className="w-[200px] h-[70px]" variant="outline" isLoading={downloading} onClick={triggerDownload}>
Download
</Button>
</div>
</div>
<div className="w-full h-full flex flex-col gap-4">
<div className="w-full flex gap-2 justify-between">
<Button className="w-full max-w-[200px]" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}>
Previous Page
</Button>
<div className="flex items-center gap-4 w-fit">
<span className="opacity-80">
{page * SIZE + 1} - {(page + 1) * SIZE > filteredRows.length ? filteredRows.length : (page + 1) * SIZE} /{" "}
{filteredRows.length}
</span>
<Button className="w-[200px]" disabled={(page + 1) * SIZE >= filteredRows.length} onClick={() => setPage((prev) => prev + 1)}>
Next Page
</Button>
</div>
</div>
<table className="rounded-xl h-full 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>
</div>
<div className="flex flex-wrap gap-2 items-center text-center">
{consolidateResults.highest && (
<IconCard onClick={() => {}} Icon={BsPersonFill} label={`Highest result: ${consolidateResults.highest.user}`} color="purple" />
)}
{consolidateResults.lowest && (
<IconCard onClick={() => {}} Icon={BsPersonFill} label={`Lowest result: ${consolidateResults.lowest.user}`} color="purple" />
)}
</div>
</>
);
};
export default MasterStatistical;

View File

@@ -0,0 +1,43 @@
import useUsers from "@/hooks/useUsers";
import {CorporateUser, User} from "@/interfaces/user";
import {groupBy} from "lodash";
import {useRouter} from "next/router";
import {useMemo} from "react";
import {BsArrowLeft} from "react-icons/bs";
import MasterStatistical from "./MasterStatistical";
interface Props {
groupedByNameCorporates: Record<string, CorporateUser[]>;
}
const MasterStatisticalPage = ({ groupedByNameCorporates }: Props) => {
const {users} = useUsers();
const router = useRouter();
const groupedByNameCorporateIds = useMemo(
() =>
Object.keys(groupedByNameCorporates).reduce((accm, x) => {
const corporateUserIds = (groupedByNameCorporates[x] as CorporateUser[]).map((y) => y.id);
return {...accm, [x]: corporateUserIds};
}, {}),
[groupedByNameCorporates],
);
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
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={users} corporateUsers={groupedByNameCorporateIds} />
</>
);
};
export default MasterStatisticalPage;

View File

@@ -0,0 +1,252 @@
/* eslint-disable @next/next/no-img-element */
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
import {useState} from "react";
import {BsFilter} from "react-icons/bs";
import {averageLevelCalculator, calculateBandScore} from "@/utils/score";
import {groupByExam} from "@/utils/stats";
import {createColumnHelper} from "@tanstack/react-table";
import List from "@/components/List";
import {getUserCompanyName} from "@/resources/user";
import Checkbox from "@/components/Low/Checkbox";
import {uniqBy} from "lodash";
import Select from "@/components/Low/Select";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
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[]) => {
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 StudentPerformanceList;

View File

@@ -0,0 +1,46 @@
import useAssignments from "@/hooks/useAssignments";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useGroups from "@/hooks/useGroups";
import useUsers, {userHashCorporate, userHashStudent} from "@/hooks/useUsers";
import {Stat, User} from "@/interfaces/user";
import clsx from "clsx";
import {useRouter} from "next/router";
import {BsArrowLeft, BsArrowRepeat} from "react-icons/bs";
import StudentPerformanceList from "./StudentPerformanceList";
interface Props {
user: User;
}
const StudentPerformancePage = ({user}: Props) => {
const {users: students} = useUsers(userHashStudent);
const {users: corporates} = useUsers(userHashCorporate);
const {groups} = useGroups({admin: user.id, userType: user.type});
const {data: stats} = useFilterRecordsByUser<Stat[]>();
const {reload: reloadAssignments, isLoading: isAssignmentsLoading} = useAssignments({corporate: user.id});
const router = useRouter();
return (
<>
<div className="w-full flex justify-between items-center">
<div
onClick={() => router.push("/")}
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={corporates} groups={groups} />
</>
);
};
export default StudentPerformancePage;

View File

@@ -0,0 +1,448 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers from "@/hooks/useUsers";
import {CorporateUser, MasterCorporateUser, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
import {useEffect, useState, useMemo} from "react";
import {
BsArrowLeft,
BsClipboard2Data,
BsClock,
BsPaperclip,
BsPersonFill,
BsPencilSquare,
BsPersonCheck,
BsPeople,
BsBank,
BsEnvelopePaper,
BsArrowRepeat,
BsPersonFillGear,
BsDatabase,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {averageLevelCalculator, calculateAverageLevel} from "@/utils/score";
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 useAssignments from "@/hooks/useAssignments";
import {Assignment} from "@/interfaces/results";
import clsx from "clsx";
import {getCorporateUser} from "@/resources/user";
import {groupBy, uniqBy} from "lodash";
import MasterStatistical from "./MasterStatistical";
import {activeAssignmentFilter} from "@/utils/assignments";
import useUserBalance from "@/hooks/useUserBalance";
import AssignmentsPage from "../views/AssignmentsPage";
import StudentPerformanceList from "./StudentPerformanceList";
import StudentPerformancePage from "./StudentPerformancePage";
import MasterStatisticalPage from "./MasterStatisticalPage";
interface Props {
user: MasterCorporateUser;
}
const studentHash = {
type: "student",
size: 25,
orderBy: "registrationDate",
};
const teacherHash = {
type: "teacher",
size: 25,
orderBy: "registrationDate",
};
const corporateHash = {
type: "corporate",
size: 25,
orderBy: "registrationDate",
};
export default function MasterCorporateDashboard({user}: Props) {
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]);
const {data: stats} = useFilterRecordsByUser<Stat[]>();
const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
const {users: teachers, total: totalTeachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(teacherHash);
const {users: corporates, total: totalCorporate, reload: reloadCorporates, isLoading: isCorporatesLoading} = useUsers(corporateHash);
const {groups} = useGroups({admin: user.id, userType: user.type});
const {balance} = useUserBalance();
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
useEffect(() => {
setShowModal(!!selectedUser && router.asPath === "/");
}, [selectedUser, router.asPath]);
useEffect(() => {
setCorporateAssignments(
assignments.filter(activeAssignmentFilter).map((a) => {
const assigner = [...teachers, ...corporates].find((x) => x.id === a.assigner);
return {
...a,
corporate: assigner ? getCorporateUser(assigner, [...teachers, ...corporates], groups) : undefined,
};
}),
);
}, [assignments, groups, teachers, corporates]);
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 {users} = useUsers();
const groupedByNameCorporates = useMemo(
() =>
groupBy(
users.filter((x) => x.type === "corporate"),
(x: CorporateUser) => x.corporateInformation?.companyInformation?.name || "N/A",
) as Record<string, CorporateUser[]>,
[users],
);
const groupedByNameCorporatesKeys = Object.keys(groupedByNameCorporates);
const GroupsList = () => {
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
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} />
</>
);
};
if (router.asPath === "/#studentsPerformance") return <StudentPerformancePage user={user} />;
if (router.asPath === "/#statistical") return <MasterStatisticalPage groupedByNameCorporates={groupedByNameCorporates} />;
if (router.asPath === "/#groups") return <GroupsList />;
if (router.asPath === "/#students")
return (
<UserList
user={user}
type="student"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
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>
)}
/>
);
if (router.asPath === "/#assignments")
return (
<AssignmentsPage
assignments={assignments}
corporateAssignments={corporateAssignments}
groups={assignmentsGroups}
user={user}
reloadAssignments={reloadAssignments}
isLoading={isAssignmentsLoading}
onBack={() => router.push("/")}
/>
);
if (router.asPath === "/#corporate")
return (
<UserList
user={user}
type="corporate"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
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>
)}
/>
);
if (router.asPath === "/#students")
return (
<UserList
user={user}
type="student"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
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>
)}
/>
);
if (router.asPath === "/#teachers")
return (
<UserList
user={user}
type="teacher"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => router.push("/")}
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>
)}
/>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
maxUserAmount={
user.type === "mastercorporate"
? (user.corporateInformation?.companyInformation?.userAmount || 0) - balance
: undefined
}
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload && selectedUser!.type === "student") reloadStudents();
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
if (shouldReload && selectedUser!.type === "corporate") reloadCorporates();
}}
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>
<>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
<IconCard
onClick={() => router.push("/#students")}
Icon={BsPersonFill}
isLoading={isStudentsLoading}
label="Students"
value={totalStudents}
color="purple"
/>
<IconCard
onClick={() => router.push("/#teachers")}
Icon={BsPencilSquare}
isLoading={isTeachersLoading}
label="Teachers"
value={totalTeachers}
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(
students,
stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)),
).toFixed(1)}
color="purple"
/>
<IconCard onClick={() => router.push("/#groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
<IconCard
Icon={BsPersonCheck}
label="User Balance"
value={`${balance}/${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 Accounts"
value={totalCorporate}
isLoading={isCorporatesLoading}
color="purple"
onClick={() => router.push("/#corporate")}
/>
<IconCard
Icon={BsBank}
label="Corporate"
value={groupedByNameCorporatesKeys.length}
isLoading={isCorporatesLoading}
color="purple"
/>
<IconCard
Icon={BsPersonFillGear}
isLoading={isStudentsLoading}
label="Student Performance"
value={totalStudents}
color="purple"
onClick={() => router.push("/#studentsPerformance")}
/>
<IconCard
Icon={BsDatabase}
label="Master Statistical"
// value={masterCorporateUserGroups.length}
color="purple"
onClick={() => router.push("/#statistical")}
/>
<button
disabled={isAssignmentsLoading}
onClick={() => router.push("/#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">
{students
.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">
{teachers
.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">
{students
.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">
{students
.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>
</>
</>
);
}

View File

@@ -1,34 +0,0 @@
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

@@ -3,17 +3,18 @@ import ProgressBar from "@/components/Low/ProgressBar";
import InviteCard from "@/components/Medium/InviteCard"; import InviteCard from "@/components/Medium/InviteCard";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import useAssignments from "@/hooks/useAssignments"; import useAssignments from "@/hooks/useAssignments";
import useGradingSystem from "@/hooks/useGrading";
import useInvites from "@/hooks/useInvites"; import useInvites from "@/hooks/useInvites";
import useStats from "@/hooks/useStats"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers from "@/hooks/useUsers"; import useUsers, { userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
import {Invite} from "@/interfaces/invite"; import {Invite} from "@/interfaces/invite";
import {Assignment} from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import {CorporateUser, User} from "@/interfaces/user"; import {CorporateUser, MasterCorporateUser, Stat, User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {getUserCorporate} from "@/utils/groups"; import {getUserCorporate} from "@/utils/groups";
import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils"; import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
import {getLevelLabel, getLevelScore} from "@/utils/score"; import {getGradingLabel, getLevelLabel, getLevelScore} from "@/utils/score";
import {averageScore, groupBySession} from "@/utils/stats"; import {averageScore, groupBySession} from "@/utils/stats";
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js"; import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
import {PayPalButtons} from "@paypal/react-paypal-js"; import {PayPalButtons} from "@paypal/react-paypal-js";
@@ -23,22 +24,29 @@ import {capitalize} from "lodash";
import moment from "moment"; import moment from "moment";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import {useEffect, useMemo, useState} from "react";
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs"; import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {activeAssignmentFilter} from "@/utils/assignments";
import ModuleBadge from "@/components/ModuleBadge";
import useSessions from "@/hooks/useSessions";
interface Props { interface Props {
user: User; user: User;
linkedCorporate?: CorporateUser | MasterCorporateUser;
} }
export default function StudentDashboard({user}: Props) { export default function StudentDashboard({user, linkedCorporate}: Props) {
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>(); const {gradingSystem} = useGradingSystem();
const {sessions} = useSessions(user.id);
const {stats} = useStats(user.id, !user?.id); const {data: stats} = useFilterRecordsByUser<Stat[]>(user.id, !user?.id);
const {users} = useUsers();
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id}); const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id}); const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
const {users: teachers} = useUsers(userHashTeacher);
const {users: corporates} = useUsers(userHashCorporate);
const users = useMemo(() => [...teachers, ...corporates], [teachers, corporates]);
const router = useRouter(); const router = useRouter();
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
@@ -47,10 +55,6 @@ export default function StudentDashboard({user}: Props) {
const setSelectedModules = useExamStore((state) => state.setSelectedModules); const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setAssignment = useExamStore((state) => state.setAssignment); const setAssignment = useExamStore((state) => state.setAssignment);
useEffect(() => {
getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]);
const startAssignment = (assignment: Assignment) => { const startAssignment = (assignment: Assignment) => {
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id)); const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
@@ -72,11 +76,13 @@ export default function StudentDashboard({user}: Props) {
}); });
}; };
const studentAssignments = assignments.filter(activeAssignmentFilter);
return ( return (
<> <>
{corporateUserToShow && ( {linkedCorporate && (
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1"> <div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b> Linked to: <b>{linkedCorporate?.corporateInformation?.companyInformation.name || linkedCorporate.name}</b>
</div> </div>
)} )}
<ProfileSummary <ProfileSummary
@@ -122,50 +128,32 @@ export default function StudentDashboard({user}: Props) {
</div> </div>
</div> </div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 && {studentAssignments.length === 0 && "Assignments will appear here. It seems that for now there are no assignments for you."}
"Assignments will appear here. It seems that for now there are no assignments for you."} {studentAssignments
{assignments
.filter((a) => moment(a.endDate).isSameOrAfter(moment()))
.sort((a, b) => moment(a.startDate).diff(b.startDate)) .sort((a, b) => moment(a.startDate).diff(b.startDate))
.map((assignment) => ( .map((assignment) => (
<div <div
className={clsx( className={clsx(
"border-mti-gray-anti-flash flex min-w-[300px] flex-col gap-6 rounded-xl border p-4", "border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light", assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
)} )}
key={assignment.id}> key={assignment.id}>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3> <h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
<span className="flex justify-between gap-1"> <span className="flex justify-between gap-1 text-lg">
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span> <span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span> <span>-</span>
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span> <span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
</span> </span>
</div> </div>
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="-md:mt-2 grid w-fit min-w-[104px] grid-cols-2 place-items-center justify-center gap-2"> <div className="-md:mt-2 grid w-fit min-w-[140px] grid-cols-2 grid-rows-2 place-items-center justify-between gap-4">
{assignment.exams {assignment.exams
.filter((e) => e.assignee === user.id) .filter((e) => e.assignee === user.id)
.map((e) => e.module) .map((e) => e.module)
.sort(sortByModuleName) .sort(sortByModuleName)
.map((module) => ( .map((module) => (
<div <ModuleBadge className="scale-110 w-full" key={module} module={module} />
key={module}
data-tip={capitalize(module)}
className={clsx(
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
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="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />}
</div>
))} ))}
</div> </div>
{!assignment.results.map((r) => r.user).includes(user.id) && ( {!assignment.results.map((r) => r.user).includes(user.id) && (
@@ -173,20 +161,24 @@ export default function StudentDashboard({user}: Props) {
<div <div
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden" className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
data-tip="Your screen size is too small to perform an assignment"> data-tip="Your screen size is too small to perform an assignment">
<Button <Button className="h-full w-full !rounded-xl" variant="outline">
disabled={moment(assignment.startDate).isAfter(moment())} Start
className="h-full w-full !rounded-xl" </Button>
variant="outline"> </div>
<div
data-tip="You have already started this assignment!"
className={clsx(
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
sessions.filter((x) => x.assignment?.id === assignment.id).length > 0 && "tooltip",
)}>
<Button
className={clsx("w-full h-full !rounded-xl")}
onClick={() => startAssignment(assignment)}
variant="outline"
disabled={sessions.filter((x) => x.assignment?.id === assignment.id).length > 0}>
Start Start
</Button> </Button>
</div> </div>
<Button
disabled={moment(assignment.startDate).isAfter(moment())}
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
onClick={() => startAssignment(assignment)}
variant="outline">
Start
</Button>
</> </>
)} )}
{assignment.results.map((r) => r.user).includes(user.id) && ( {assignment.results.map((r) => r.user).includes(user.id) && (
@@ -243,7 +235,7 @@ export default function StudentDashboard({user}: Props) {
<div className="flex w-full justify-between"> <div className="flex w-full justify-between">
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span> <span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
<span className="text-mti-gray-dim text-sm font-normal"> <span className="text-mti-gray-dim text-sm font-normal">
{module === "level" && `English Level: ${getLevelLabel(level).join(" / ")}`} {module === "level" && !!gradingSystem && `English Level: ${getGradingLabel(level, gradingSystem.steps)}`}
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`} {module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
</span> </span>
</div> </div>
@@ -252,9 +244,9 @@ export default function StudentDashboard({user}: Props) {
<ProgressBar <ProgressBar
color={module} color={module}
label="" label=""
mark={Math.round((desiredLevel * 100) / 9)} mark={module === "level" ? undefined : Math.round((desiredLevel * 100) / 9)}
markLabel={`Desired Level: ${desiredLevel}`} markLabel={`Desired Level: ${desiredLevel}`}
percentage={Math.round((level * 100) / 9)} percentage={module === "level" ? level : Math.round((level * 100) / 9)}
className="h-2 w-full" className="h-2 w-full"
/> />
</div> </div>

View File

@@ -1,12 +1,12 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers from "@/hooks/useUsers"; import useUsers, {userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
import {CorporateUser, Group, Stat, User} from "@/interfaces/user"; import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils"; import {dateSorter} from "@/utils";
import moment from "moment"; import moment from "moment";
import {useEffect, useState} from "react"; import {useEffect, useMemo, useState} from "react";
import { import {
BsArrowLeft, BsArrowLeft,
BsArrowRepeat, BsArrowRepeat,
@@ -48,34 +48,41 @@ import AssignmentView from "./AssignmentView";
import {getUserCorporate} from "@/utils/groups"; import {getUserCorporate} from "@/utils/groups";
import {checkAccess} from "@/utils/permissions"; import {checkAccess} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
import AssignmentsPage from "./views/AssignmentsPage";
import {useRouter} from "next/router";
import useFilterStore from "@/stores/listFilterStore";
interface Props { interface Props {
user: User; user: User;
linkedCorporate?: CorporateUser | MasterCorporateUser;
} }
export default function TeacherDashboard({user}: Props) { const studentHash = {
const [page, setPage] = useState(""); type: "student",
orderBy: "registrationDate",
size: 25,
};
export default function TeacherDashboard({user, linkedCorporate}: Props) {
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
const {stats} = useStats(); const {data: stats} = useFilterRecordsByUser<Stat[]>();
const {users, reload} = useUsers();
const {groups} = useGroups({adminAdmins: user.id}); const {groups} = useGroups({adminAdmins: user.id});
const {permissions} = usePermissions(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(() => { const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
useEffect(() => { useEffect(() => {
getUserCorporate(user.id).then(setCorporateUserToShow); setShowModal(!!selectedUser && router.asPath === "/#");
}, [user]); }, [selectedUser, router.asPath]);
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id); const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
@@ -91,35 +98,6 @@ export default function TeacherDashboard({user}: Props) {
</div> </div>
); );
const StudentsList = () => {
const filter = (x: User) =>
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));
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 GroupsList = () => { const GroupsList = () => {
const filter = (x: Group) => x.admin === user.id; const filter = (x: Group) => x.admin === user.id;
@@ -127,7 +105,7 @@ export default function TeacherDashboard({user}: Props) {
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => router.push("/")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
@@ -143,7 +121,7 @@ export default function TeacherDashboard({user}: Props) {
const averageLevelCalculator = (studentStats: Stat[]) => { const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats const formattedStats = studentStats
.map((s) => ({ .map((s) => ({
focus: users.find((u) => u.id === s.user)?.focus, focus: students.find((u) => u.id === s.user)?.focus,
score: s.score, score: s.score,
module: s.module, module: s.module,
})) }))
@@ -165,218 +143,36 @@ export default function TeacherDashboard({user}: Props) {
return calculateAverageLevel(levels); return calculateAverageLevel(levels);
}; };
const AssignmentsPage = () => { if (router.asPath === "/#students")
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 ( return (
<> <UserList
<AssignmentView user={user}
isOpen={!!selectedAssignment && !isCreatingAssignment} type="student"
onClose={() => { renderHeader={(total) => (
setSelectedAssignment(undefined); <div className="flex flex-col gap-4">
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)
: 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 <div
onClick={() => setIsCreatingAssignment(true)} onClick={() => router.push("/")}
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"> className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsPlus className="text-6xl" /> <BsArrowLeft className="text-xl" />
<span className="text-lg">New Assignment</span> <span>Back</span>
</div> </div>
{assignments.filter(futureFilter).map((a) => ( <h2 className="text-2xl font-semibold">Students ({total})</h2>
<AssignmentCard
{...a}
users={users}
onClick={() => {
setSelectedAssignment(a);
setIsCreatingAssignment(true);
}}
key={a.id}
/>
))}
</div> </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 DefaultDashboard = () => (
<>
{corporateUserToShow && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
</div>
)}
<section
className={clsx(
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
!!corporateUserToShow && "mt-12 xl:mt-6",
)}>
<IconCard
onClick={() => setPage("students")}
Icon={BsPersonFill}
label="Students"
value={users.filter(studentFilter).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(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
color="purple"
/>
{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 />
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"> if (router.asPath === "/#assignments")
<BsEnvelopePaper className="text-6xl text-mti-purple-light" /> return (
<span className="flex flex-col gap-1 items-center text-xl"> <AssignmentsPage
<span className="text-lg">Assignments</span> assignments={assignments}
<span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span> groups={assignmentsGroups}
</span> user={user}
</div> reloadAssignments={reloadAssignments}
</section> isLoading={isAssignmentsLoading}
onBack={() => router.push("/")}
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 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> if (router.asPath === "/#groups") return <GroupsList />;
<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">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 ( return (
<> <>
@@ -388,22 +184,143 @@ export default function TeacherDashboard({user}: Props) {
loggedInUser={user} loggedInUser={user}
onClose={(shouldReload) => { onClose={(shouldReload) => {
setSelectedUser(undefined); setSelectedUser(undefined);
if (shouldReload) reload(); if (shouldReload && selectedUser!.type === "student") reloadStudents();
}} }}
onViewStudents={ onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined selectedUser.type === "corporate" || selectedUser.type === "teacher"
? () => {
appendUserFilters({
id: "view-students",
filter: (x: User) => x.type === "student",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
}
onViewTeachers={
selectedUser.type === "corporate" || selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-teachers",
filter: (x: User) => x.type === "teacher",
});
appendUserFilters({
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id),
});
router.push("/list/users");
}
: undefined
} }
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
user={selectedUser} user={selectedUser}
/> />
</div> </div>
)} )}
</> </>
</Modal> </Modal>
{page === "students" && <StudentsList />}
{page === "groups" && <GroupsList />} <>
{page === "assignments" && <AssignmentsPage />} {linkedCorporate && (
{page === "" && <DefaultDashboard />} <div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
Linked to: <b>{linkedCorporate?.corporateInformation?.companyInformation.name || linkedCorporate.name}</b>
</div>
)}
<section
className={clsx(
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
!!linkedCorporate && "mt-12 xl:mt-6",
)}>
<IconCard
onClick={() => router.push("/#students")}
isLoading={isStudentsLoading}
Icon={BsPersonFill}
label="Students"
value={totalStudents}
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"
isLoading={isStudentsLoading}
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
color="purple"
/>
{checkAccess(user, ["teacher", "developer"], permissions, "viewGroup") && (
<IconCard
Icon={BsPeople}
label="Groups"
value={groups.filter((x) => x.admin === user.id).length}
color="purple"
onClick={() => router.push("/#groups")}
/>
)}
<div
onClick={() => router.push("/#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">
<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">{assignments.filter((a) => !a.archived).length}</span>
</span>
</div>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 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">
{students
.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">
{students
.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">
{students
.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>
</>
</> </>
); );
} }

View File

@@ -0,0 +1,183 @@
import useUsers from "@/hooks/useUsers";
import {Assignment} from "@/interfaces/results";
import {CorporateUser, Group, User} from "@/interfaces/user";
import {getUserCompanyName} from "@/resources/user";
import {
activeAssignmentFilter,
archivedAssignmentFilter,
futureAssignmentFilter,
pastAssignmentFilter,
startHasExpiredAssignmentFilter,
} from "@/utils/assignments";
import clsx from "clsx";
import {groupBy} from "lodash";
import {useState} from "react";
import {BsArrowLeft, BsArrowRepeat, BsPlus} from "react-icons/bs";
import AssignmentCard from "../AssignmentCard";
import AssignmentCreator from "../AssignmentCreator";
import AssignmentView from "../AssignmentView";
interface Props {
assignments: Assignment[];
corporateAssignments?: ({corporate?: CorporateUser} & Assignment)[];
groups: Group[];
isLoading: boolean;
user: User;
onBack: () => void;
reloadAssignments: () => void;
}
export default function AssignmentsPage({assignments, corporateAssignments, user, groups, isLoading, onBack, reloadAssignments}: Props) {
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const {users} = useUsers();
const displayAssignmentView = !!selectedAssignment && !isCreatingAssignment;
const assignmentsPastExpiredStart = assignments.filter(startHasExpiredAssignmentFilter);
return (
<>
{displayAssignmentView && (
<AssignmentView
isOpen={displayAssignmentView}
onClose={() => {
setSelectedAssignment(undefined);
setIsCreatingAssignment(false);
reloadAssignments();
}}
assignment={selectedAssignment}
/>
)}
{/** I'll be using this is creating assingment as a workaround for a key to trigger a new rendering */}
{isCreatingAssignment && (
<AssignmentCreator
assignment={selectedAssignment}
groups={groups}
users={[...users, user]}
user={user}
isCreating={isCreatingAssignment}
cancelCreation={() => {
setIsCreatingAssignment(false);
setSelectedAssignment(undefined);
reloadAssignments();
}}
/>
)}
<div className="w-full flex justify-between items-center">
<div
onClick={onBack}
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", isLoading && "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(activeAssignmentFilter).reduce((acc, curr) => acc + curr.results.length, 0)}/
{assignments.filter(activeAssignmentFilter).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(activeAssignmentFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(activeAssignmentFilter).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(futureAssignmentFilter).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(futureAssignmentFilter).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(pastAssignmentFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(pastAssignmentFilter).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">Assignments start expired ({assignmentsPastExpiredStart.length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(startHasExpiredAssignmentFilter).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(archivedAssignmentFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(archivedAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowUnarchive
allowExcelDownload
/>
))}
</div>
</section>
</>
);
}

View File

@@ -4,13 +4,14 @@ import {moduleResultText} from "@/constants/ielts";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {calculateBandScore} from "@/utils/score"; import {calculateBandScore, getGradingLabel} from "@/utils/score";
import clsx from "clsx"; 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 { import {
BsArrowCounterclockwise, BsArrowCounterclockwise,
BsBan,
BsBook, BsBook,
BsClipboard, BsClipboard,
BsClipboardFill, BsClipboardFill,
@@ -24,8 +25,10 @@ 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 Modal from "@/components/Modal";
import { UserSolution } from "@/interfaces/exam"; import {UserSolution} from "@/interfaces/exam";
import ai_usage from "@/utils/ai.detection"; import ai_usage from "@/utils/ai.detection";
import useGradingSystem from "@/hooks/useGrading";
import {Assignment} from "@/interfaces/results";
interface Score { interface Score {
module: Module; module: Module;
@@ -44,17 +47,18 @@ interface Props {
}; };
solutions: UserSolution[]; solutions: UserSolution[];
isLoading: boolean; isLoading: boolean;
assignment?: Assignment;
onViewResults: (moduleIndex?: number) => void; onViewResults: (moduleIndex?: number) => void;
} }
export default function Finish({user, scores, modules, information, solutions, isLoading, onViewResults}: Props) { export default function Finish({user, scores, modules, information, solutions, isLoading, assignment, 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 [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
const aiUsage = Math.round(ai_usage(solutions) * 100); const aiUsage = Math.round(ai_usage(solutions) * 100);
const exams = useExamStore((state) => state.exams); const exams = useExamStore((state) => state.exams);
const {gradingSystem} = useGradingSystem();
useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]); useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]);
@@ -94,10 +98,10 @@ export default function Finish({user, scores, modules, information, solutions, i
const showLevel = (level: number) => { const showLevel = (level: number) => {
if (selectedModule === "level") { if (selectedModule === "level") {
const [levelStr, grade] = getLevelScore(level); const label = getGradingLabel(level, gradingSystem?.steps || []);
return ( return (
<div className="flex flex-col items-center justify-center gap-1"> <div className="flex flex-col items-center justify-center gap-1">
<span className="text-xl font-bold">{levelStr}</span> <span className="text-xl font-bold">{label}</span>
</div> </div>
); );
} }
@@ -155,26 +159,24 @@ export default function Finish({user, scores, modules, information, solutions, i
)} )}
{modules.includes("writing") && ( {modules.includes("writing") && (
<div className="flex w-full justify-between items-center"> <div className="flex w-full justify-between items-center">
<div <div
onClick={() => setSelectedModule("writing")} onClick={() => setSelectedModule("writing")}
className={clsx( className={clsx(
"hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg", "hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing", selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
)}> )}>
<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>
{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>
)} {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> </div>
)} )}
{modules.includes("speaking") && ( {modules.includes("speaking") && (
@@ -210,7 +212,18 @@ export default function Finish({user, scores, modules, information, solutions, i
</span> </span>
</div> </div>
)} )}
{!isLoading && ( {assignment && !assignment.released && !isLoading && (
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-12">
{/* <span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} /> */}
<BsBan size={64} className={clsx(moduleColors[selectedModule].progress)} />
<span className={clsx("text-center text-2xl font-bold", moduleColors[selectedModule].progress)}>
This exam has not yet been released by its assigner.
<br />
You can check it later on your records page when it is released!
</span>
</div>
)}
{!isLoading && !(assignment && !assignment.released) && (
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9"> <div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span> <span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
<div className="flex gap-9 px-16"> <div className="flex gap-9 px-16">
@@ -283,6 +296,7 @@ export default function Finish({user, scores, modules, information, solutions, i
<div className="flex w-fit cursor-pointer flex-col items-center gap-1"> <div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button <button
onClick={() => onViewResults()} onClick={() => onViewResults()}
disabled={assignment && !assignment.released}
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"> 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">
<BsEyeFill className="h-7 w-7 text-white" /> <BsEyeFill className="h-7 w-7 text-white" />
</button> </button>
@@ -290,6 +304,7 @@ export default function Finish({user, scores, modules, information, solutions, i
</div> </div>
<div className="flex w-fit cursor-pointer flex-col items-center gap-1"> <div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button <button
disabled={assignment && !assignment.released}
onClick={() => onViewResults(modules.findIndex((x) => x === selectedModule))} onClick={() => onViewResults(modules.findIndex((x) => x === selectedModule))}
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"> 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">
<BsEyeFill className="h-7 w-7 text-white" /> <BsEyeFill className="h-7 w-7 text-white" />

View File

@@ -1,6 +1,7 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { LevelPart, UserSolution } from "@/interfaces/exam"; import { LevelPart, UserSolution } from "@/interfaces/exam";
import clsx from "clsx";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs"; import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
@@ -21,10 +22,10 @@ const PartDivider: React.FC<Props> = ({ partIndex, part, onNext }) => {
}; };
return ( return (
<div className="flex flex-col w-3/6 h-fit border bg-white rounded-3xl p-12 gap-8"> <div className={clsx("flex flex-col h-fit border bg-white rounded-3xl p-12 gap-8", part.intro ? "w-3/6" : "items-center my-auto")}>
{/** only level for now */} {/** 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> <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.intro ? `Part ${partIndex + 1}` : "Placement Test"}</p></div>
{part.intro!.split('\\n\\n').map((x, index) => <p key={`line-${index}`} className="text-2xl text-clip">{x}</p>)} {part.intro && part.intro.split('\\n\\n').map((x, index) => <p key={`line-${index}`} className="text-2xl text-clip" dangerouslySetInnerHTML={{__html: x.replace('that is not correct', 'that is <span class="font-bold"><u>not correct</u></span>')}}></p>)}
<div className="flex items-center justify-center mt-4"> <div className="flex items-center justify-center mt-4">
<Button color="purple" onClick={() => onNext()} className="max-w-[200px] self-end w-full text-2xl"> <Button color="purple" onClick={() => onNext()} className="max-w-[200px] self-end w-full text-2xl">
{partIndex === 0 ? `Start now`: `Start Part ${partIndex + 1}`} {partIndex === 0 ? `Start now`: `Start Part ${partIndex + 1}`}

155
src/exams/Level/Shuffle.ts Normal file
View File

@@ -0,0 +1,155 @@
import { Exercise, FillBlanksExercise, FillBlanksMCOption, MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap, Shuffles, UserSolution } from "@/interfaces/exam";
export default function shuffleExamExercise(
shuffle: boolean | undefined,
exercise: Exercise,
showSolutions: boolean,
userSolutions: UserSolution[],
shuffles: Shuffles[],
setShuffles: (maps: Shuffles[]) => void
): Exercise {
if (!shuffle) {
return exercise;
}
const userSolution = userSolutions.find((x) => x.exercise === exercise.id)!;
if (exercise.type === "multipleChoice") {
return shuffleMultipleChoice(exercise, userSolution, shuffles, setShuffles, showSolutions);
} else if (exercise.type === "fillBlanks") {
return shuffleFillBlanks(exercise, userSolution, shuffles, setShuffles, showSolutions);
}
return exercise;
}
function shuffleMultipleChoice(
exercise: MultipleChoiceExercise,
userSolution: UserSolution,
shuffles: Shuffles[],
setShuffles: (maps: Shuffles[]) => void,
showSolutions: boolean,
): MultipleChoiceExercise {
if (typeof userSolution.shuffleMaps === "undefined" || (userSolution.shuffleMaps && userSolution.shuffleMaps.length === 0) && !showSolutions) {
const newShuffleMaps: ShuffleMap[] = [];
exercise.questions = exercise.questions.map(shuffleQuestion(newShuffleMaps));
userSolution!.shuffleMaps = newShuffleMaps;
setShuffles([...shuffles.filter((x) => x.exerciseID !== exercise.id), {exerciseID: exercise.id, shuffles: newShuffleMaps}]);
} else {
exercise.questions = exercise.questions.map(retrieveShuffledQuestion(userSolution.shuffleMaps));
}
return exercise;
}
function shuffleQuestion(newShuffleMaps: ShuffleMap[]) {
return (question: MultipleChoiceQuestion): MultipleChoiceQuestion => {
const options = [...question.options];
const shuffledOptions = fisherYatesShuffle(options);
const optionMapping: Record<string, string> = {};
const newOptions = shuffledOptions.map((option, index) => {
const newId = String.fromCharCode(65 + index);
optionMapping[option.id] = newId;
return { ...option, id: newId };
});
newShuffleMaps.push({ questionID: question.id, map: optionMapping });
return { ...question, options: newOptions, shuffleMap: optionMapping };
};
}
function retrieveShuffledQuestion(shuffleMaps: ShuffleMap[]) {
return (question: MultipleChoiceQuestion): MultipleChoiceQuestion => {
const questionShuffleMap = shuffleMaps.find(map => map.questionID === question.id);
if (questionShuffleMap) {
const shuffledOptions = Object.entries(questionShuffleMap.map)
.sort(([, a], [, b]) => a.localeCompare(b))
.map(([originalId, newId]) => {
const originalOption = question.options.find(opt => opt.id === originalId);
return { ...originalOption, id: newId };
});
return { ...question, options: shuffledOptions, shuffleMap: questionShuffleMap.map };
}
return question;
};
}
function shuffleFillBlanks(
exercise: FillBlanksExercise,
userSolution: UserSolution,
shuffles: Shuffles[],
setShuffles: (maps: Shuffles[]) => void,
showSolutions: boolean
): FillBlanksExercise {
if (typeof userSolution.shuffleMaps === "undefined" || (userSolution.shuffleMaps && userSolution.shuffleMaps.length === 0) && !showSolutions) {
const newShuffleMaps: ShuffleMap[] = [];
exercise.words = exercise.words.map(shuffleWord(newShuffleMaps));
userSolution.shuffleMaps = newShuffleMaps;
setShuffles([...shuffles.filter((x) => x.exerciseID !== exercise.id), {exerciseID: exercise.id, shuffles: newShuffleMaps}]);
} else {
exercise.words = exercise.words.map(retrieveShuffledWord(userSolution.shuffleMaps!));
}
return exercise;
}
function shuffleWord(newShuffleMaps: ShuffleMap[]) {
return (word: string | { letter: string; word: string } | FillBlanksMCOption): typeof word => {
if (typeof word === 'object' && 'options' in word) {
const options = word.options;
const originalKeys = Object.keys(options);
const shuffledKeys = fisherYatesShuffle(originalKeys);
const newOptions = shuffledKeys.reduce<typeof options>((acc, key, index) => {
acc[key as keyof typeof options] = options[originalKeys[index] as keyof typeof options];
return acc;
}, {} as typeof options);
const optionMapping = originalKeys.reduce<Record<string, string>>((acc, key, index) => {
acc[key] = shuffledKeys[index];
return acc;
}, {});
newShuffleMaps.push({ questionID: word.id, map: optionMapping });
return { ...word, options: newOptions };
}
return word;
};
}
function retrieveShuffledWord(shuffleMaps: ShuffleMap[]) {
return (word: string | { letter: string; word: string } | FillBlanksMCOption): typeof word => {
if (typeof word === 'object' && 'options' in word) {
const shuffleMap = shuffleMaps.find(map => map.questionID === word.id);
if (shuffleMap) {
const options = word.options;
const shuffledOptions = Object.keys(options).reduce<typeof options>((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 typeof options);
return { ...word, options: shuffledOptions };
}
}
return word;
};
}
function fisherYatesShuffle<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every(
word => word && typeof word === 'object' && 'id' in word && 'options' in word
);
}

View File

@@ -1,88 +1,145 @@
import { LevelPart } from "@/interfaces/exam"; import { LevelPart } from "@/interfaces/exam";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
interface Props { interface Props {
part: LevelPart, part: LevelPart,
contextWord: string | undefined, contextWords: { match: string, originalLine: string }[] | undefined,
setContextWordLine: React.Dispatch<React.SetStateAction<number | undefined>> setContextWordLines: React.Dispatch<React.SetStateAction<number[] | undefined>>
setTotalLines: React.Dispatch<React.SetStateAction<number>>
} }
const TextComponent: React.FC<Props> = ({part, contextWord, setContextWordLine}) => { const TextComponent: React.FC<Props> = ({ part, contextWords, setContextWordLines, setTotalLines }) => {
const textRef = useRef<HTMLDivElement>(null); const textRef = useRef<HTMLDivElement>(null);
const [lineNumbers, setLineNumbers] = useState<number[]>([]);
const [lineHeight, setLineHeight] = useState<number>(0);
const [addBreaksTo, setAddBreaksTo] = useState<number[]>([]);
const getBoldTag = (context: string) => {
const regex = /<b\s+class=['"]([^'"]+)['"]>(\d+)<\/b>/;
const match = context.match(regex);
if (match) {
return {
className: match[1],
number: match[2],
fullTag: match[0]
};
}
return null;
};
const bTag = getBoldTag(part.context!);
const calculateLineNumbers = () => { const calculateLineNumbers = () => {
if (textRef.current) { if (textRef.current) {
const computedStyle = window.getComputedStyle(textRef.current); const computedStyle = window.getComputedStyle(textRef.current);
const lineHeightValue = parseFloat(computedStyle.lineHeight);
const containerWidth = textRef.current.clientWidth; const containerWidth = textRef.current.clientWidth;
setLineHeight(lineHeightValue);
const offscreenElement = document.createElement('div'); const offscreenElement = document.createElement('div');
offscreenElement.style.position = 'absolute'; offscreenElement.style.position = 'absolute';
offscreenElement.style.top = '-9999px'; offscreenElement.style.top = '-9999px';
offscreenElement.style.left = '-9999px'; offscreenElement.style.left = '-9999px';
offscreenElement.style.whiteSpace = 'pre-wrap';
offscreenElement.style.width = `${containerWidth}px`; offscreenElement.style.width = `${containerWidth}px`;
offscreenElement.style.font = computedStyle.font; offscreenElement.style.font = computedStyle.font;
offscreenElement.style.lineHeight = computedStyle.lineHeight; offscreenElement.style.lineHeight = computedStyle.lineHeight;
offscreenElement.style.whiteSpace = 'pre-wrap';
offscreenElement.style.wordWrap = 'break-word'; offscreenElement.style.wordWrap = 'break-word';
offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign; offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign;
const paragraphs = part.context!.split('\n\n'); const textContent = textRef.current.textContent || '';
let currentLine = 1;
let contextWordLine: number | null = null;
const paragraphLineStarts: number[] = [];
paragraphs.forEach((paragraph, pIndex) => { const paragraphs = textContent.split(/\n\n/);
const p = document.createElement('p'); const betweenParagraphs: string[][] = Array.from({ length: paragraphs.length }, () => []);
p.style.margin = '0';
p.style.padding = '0';
paragraph.split(/(\s+)/).forEach((word: string) => { const lines = paragraphs.map((line, lineIndex) => {
const paragraphWords = line.split(/(\s+)/);
return paragraphWords.map((word, wordIndex) => {
if (lineIndex !== 0 && wordIndex == 0 && lineIndex < paragraphs.length) {
betweenParagraphs[lineIndex - 1][1] = word;
}
if (wordIndex == paragraphWords.length - 1 && lineIndex < paragraphs.length) {
betweenParagraphs[lineIndex][0] = word;
}
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = word; if (wordIndex === 0 && bTag) {
p.appendChild(span); const b = document.createElement('b');
b.classList.add(bTag.className);
b.textContent = `${lineIndex + 1}`;
span.appendChild(b);
span.appendChild(document.createTextNode(word.substring(1)));
}else {
span.appendChild(document.createTextNode(word));
}
return span;
})
}
);
lines.forEach(line => {
line.forEach((span, index) => {
offscreenElement.appendChild(span);
}); });
offscreenElement.appendChild(document.createElement('br'));
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); document.body.appendChild(offscreenElement);
const processedLines: string[][] = [[]];
let currentLine = 1;
let currentLineTop: number | undefined; let currentLineTop: number | undefined;
const elements = offscreenElement.querySelectorAll('p, div');
elements.forEach((element) => { let contextWordLines: number[] = [];
if (element.tagName === 'P') { if (contextWords) {
const spans = element.querySelectorAll<HTMLSpanElement>('span'); contextWordLines = Array(contextWords.length).fill(-1);
paragraphLineStarts.push(currentLine); }
const firstChild = offscreenElement.firstChild as HTMLElement;
if (firstChild) {
currentLineTop = firstChild.getBoundingClientRect().top;
}
spans.forEach(span => { const spans = offscreenElement.querySelectorAll<HTMLSpanElement>('span');
const rect = span.getBoundingClientRect();
const top = rect.top;
if (currentLineTop === undefined || top > currentLineTop) { let betweenIndex = 0;
if (currentLineTop !== undefined) { const addBreaksTo: number[] = [];
currentLine++; spans.forEach((span, index) => {
} const rect = span.getBoundingClientRect();
currentLineTop = top; const top = rect.top;
}
if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) { if (
contextWordLine = currentLine; betweenIndex < paragraphs.length - 1 &&
} span.textContent === betweenParagraphs[betweenIndex][1] &&
}); spans[index - 1].textContent === betweenParagraphs[betweenIndex][0]
} else if (element.tagName === 'DIV') { // Gap ) {
addBreaksTo.push(currentLine);
betweenIndex = betweenIndex + 1;
}
if (currentLineTop !== undefined && top > currentLineTop) {
currentLine++; currentLine++;
currentLineTop = undefined; currentLineTop = top;
processedLines.push([]);
}
processedLines[processedLines.length - 1].push(span.textContent?.trim() || '');
if (contextWords && contextWordLines.some(element => element === -1)) {
contextWords.forEach((w, index) => {
if (span.textContent?.includes(w.match) && contextWordLines[index] == -1) {
contextWordLines[index] = currentLine;
}
})
} }
}); });
if (contextWordLine) {
setContextWordLine(contextWordLine); setAddBreaksTo(addBreaksTo);
setLineNumbers(processedLines.map((_, index) => index + 1));
setTotalLines(currentLine);
if (contextWordLines.length > 0) {
setContextWordLines(contextWordLines);
} }
document.body.removeChild(offscreenElement); document.body.removeChild(offscreenElement);
@@ -90,7 +147,6 @@ const TextComponent: React.FC<Props> = ({part, contextWord, setContextWordLine})
}; };
useEffect(() => { useEffect(() => {
calculateLineNumbers(); calculateLineNumbers();
@@ -110,34 +166,23 @@ const TextComponent: React.FC<Props> = ({part, contextWord, setContextWordLine})
} }
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [part.context, contextWord]); }, [part.context, contextWords]);
/*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 ( return (
<div className="flex flex-col gap-2 w-full"> <div className="flex mt-2">
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" /> <div className="flex-shrink-0 w-8 pr-2">
<div className="flex mt-2"> {lineNumbers.map(num => (
<div ref={textRef} className="h-fit ml-2 flex flex-col gap-4"> <>
{part.context!.split('\n\n').map((line, index) => { <div key={num} className="text-gray-400 flex justify-end" style={{ lineHeight: `${lineHeight}px` }}>
return <p key={`line-${index}`}><span className="mr-6">{index + 1}</span>{line}</p> {num}
})} </div>
</div> {/* Do not delete the space between the span or else the lines get messed up */}
{addBreaksTo.includes(num) && <span className={`h-[${lineHeight}px] whitespace-pre-wrap`}> </span>}
</>
))}
</div>
<div ref={textRef} className="h-fit whitespace-pre-wrap ml-2">
<div dangerouslySetInnerHTML={{ __html: part.context! }} />
</div> </div>
</div> </div>
); );

View File

@@ -4,15 +4,17 @@ import Button from "@/components/Low/Button";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import { renderSolution } from "@/components/Solutions"; import { renderSolution } from "@/components/Solutions";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap, UserSolution } from "@/interfaces/exam"; import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, UserSolution } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import { countExercises } from "@/utils/moduleUtils"; import { countExercises } from "@/utils/moduleUtils";
import clsx from "clsx"; import clsx from "clsx";
import { use, useEffect, useState } from "react"; import { use, useEffect, useMemo, useState } from "react";
import TextComponent from "./TextComponent"; import TextComponent from "./TextComponent";
import PartDivider from "./PartDivider"; import PartDivider from "./PartDivider";
import Timer from "@/components/Medium/Timer"; import Timer from "@/components/Medium/Timer";
import { Stat } from "@/interfaces/user"; import shuffleExamExercise from "./Shuffle";
import { Tab } from "@headlessui/react";
import Modal from "@/components/Modal";
interface Props { interface Props {
exam: LevelExam; exam: LevelExam;
@@ -31,236 +33,198 @@ const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) { export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) {
const levelBgColor = "bg-ielts-level-light"; 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 {
const { userSolutions, setUserSolutions } = useExamStore((state) => state); userSolutions,
const { hasExamEnded, setHasExamEnded } = useExamStore((state) => state); hasExamEnded,
const { partIndex, setPartIndex } = useExamStore((state) => state); partIndex,
const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state); exerciseIndex,
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]); questionIndex,
const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps]) shuffles,
const [currentExercise, setCurrentExercise] = useState<Exercise>(); currentSolution,
setBgColor,
setUserSolutions,
setHasExamEnded,
setPartIndex,
setExerciseIndex,
setQuestionIndex,
setShuffles,
setCurrentSolution
} = useExamStore((state) => state);
// In case client want to switch back
const textRenderDisabled = true;
const [showSubmissionModal, setShowSubmissionModal] = useState(false);
const [showQuestionsModal, setShowQuestionsModal] = useState(false);
const [continueAnyways, setContinueAnyways] = useState(false);
const [textRender, setTextRender] = useState(false);
const [changedPrompt, setChangedPrompt] = useState(false);
const [nextExerciseCalled, setNextExerciseCalled] = useState(false);
const [currentSolutionSet, setCurrentSolutionSet] = useState(false);
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : [0]));
const [questionModalKwargs, setQuestionModalKwargs] = useState<{
type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined;
}>({
type: "blankQuestions",
onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
});
const [currentExercise, setCurrentExercise] = useState<Exercise | undefined>(undefined);
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions); const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
const [startNow, setStartNow] = useState<boolean>(true && !showSolutions);
useEffect(() => {
if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) {
setCurrentExercise(exam.parts[0].exercises[0]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentExercise, partIndex, exerciseIndex]);
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));
const [contextWord, setContextWord] = useState<string | undefined>(undefined); const [contextWords, setContextWords] = useState<{ match: string, originalLine: string }[] | undefined>(undefined);
const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined); const [contextWordLines, setContextWordLines] = useState<number[] | undefined>(undefined);
const [totalLines, setTotalLines] = useState<number>(0);
const [showSolutionsSave, setShowSolutionsSave] = useState(showSolutions ? userSolutions.filter((x) => x.module === "level") : undefined)
useEffect(() => { useEffect(() => {
if (showSolutions && exerciseIndex && exam.shuffle && userSolutions[exerciseIndex].shuffleMaps) { if (typeof currentSolution !== "undefined") {
setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[]) setUserSolutions([...userSolutions.filter((x) => x.exercise !== currentSolution.exercise), { ...currentSolution, module: "level" as Module, exam: exam.id, shuffleMaps: exam.shuffle ? [...shuffles.find((x) => x.exerciseID == currentExercise?.id)?.shuffles!] : [] }]);
setCurrentSolutionSet(true);
} }
}, [showSolutions, exerciseIndex, setShuffleMaps, userSolutions, exam.shuffle]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSolution, exam.id, exam.shuffle, shuffles, currentExercise])
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (typeof currentSolution !== "undefined") {
setExerciseIndex(exerciseIndex + 1); setCurrentSolution(undefined);
} }
}, [hasExamEnded, exerciseIndex, setExerciseIndex]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSolution]);
useEffect(() => {
if (showSolutions) {
const solutionShuffles = userSolutions.map(solution => ({
exerciseID: solution.exercise,
shuffles: solution.shuffleMaps || []
}));
setShuffles(solutionShuffles);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getExercise = () => { const getExercise = () => {
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex]; let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
if (!exercise) return undefined;
exercise = { exercise = {
...exercise, ...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], userSolutions: userSolutions.find((x) => x.exercise == exercise.id)?.solutions || [],
}; };
exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles);
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; return exercise;
}; };
useEffect(() => { useEffect(() => {
if (exerciseIndex !== -1) { setCurrentExercise(getExercise());
setCurrentExercise(getExercise());
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex, exerciseIndex, shuffleMaps, exam.parts[partIndex].context]); }, [partIndex, exerciseIndex, questionIndex]);
const next = () => {
setNextExerciseCalled(true);
}
useEffect(() => { const nextExercise = () => {
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(); 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) { if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex(exerciseIndex + 1); setExerciseIndex(exerciseIndex + 1);
setCurrentSolutionSet(false);
return; return;
} }
if (partIndex + 1 < exam.parts.length && !hasExamEnded && (showQuestionsModal || showSolutions)) { if (partIndex + 1 === exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions && !continueAnyways) {
if (!showSolutions && exam.parts[0].intro) { modalKwargs();
setShowQuestionsModal(true);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
if (!answeredEveryQuestion(partIndex) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) {
modalKwargs();
setShowQuestionsModal(true);
return;
}
if (!showSolutions && exam.parts[0].intro && !seenParts.has(partIndex + 1)) {
setShowPartDivider(true); setShowPartDivider(true);
setBgColor(levelBgColor); setBgColor(levelBgColor);
} }
setSeenParts(prev => new Set(prev).add(partIndex + 1));
if (partIndex < exam.parts.length - 1 && exam.parts[partIndex + 1].context && !textRenderDisabled) {
setTextRender(true);
}
setPartIndex(partIndex + 1); setPartIndex(partIndex + 1);
setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0); setExerciseIndex(0);
setStoreQuestionIndex(0); setQuestionIndex(0);
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]); setCurrentSolutionSet(false);
return; return;
} }
if (partIndex + 1 < exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions) { if (partIndex + 1 === exam.parts.length && exerciseIndex === exam.parts[partIndex].exercises.length - 1 && !continueAnyways) {
modalKwargs();
setShowQuestionsModal(true); 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); setHasExamEnded(false);
setCurrentSolutionSet(false);
if (solution) { if (typeof showSolutionsSave !== "undefined") {
let stat = { ...solution, module: "level" as Module, exam: exam.id } onFinish(showSolutionsSave);
if (exam.shuffle) {
stat.shuffleMaps = shuffleMaps
}
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...stat }]);
} else { } else {
onFinish(userSolutions); onFinish(userSolutions);
} }
}; }
useEffect(() => {
if (nextExerciseCalled && currentSolutionSet) {
nextExercise();
setNextExerciseCalled(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nextExerciseCalled, currentSolutionSet])
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]); if (exam.parts[partIndex].context && questionIndex === 0 && !textRender && !textRenderDisabled) {
setTextRender(true);
return;
}
if (questionIndex == 0) {
setPartIndex(partIndex - 1);
if (!seenParts.has(partIndex - 1)) {
setBgColor(levelBgColor);
setShowPartDivider(true);
setQuestionIndex(0);
setSeenParts(prev => new Set(prev).add(partIndex - 1));
return;
}
const lastExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
const lastExercise = exam.parts[partIndex - 1].exercises[lastExerciseIndex];
setExerciseIndex(lastExerciseIndex);
if (lastExercise.type === "multipleChoice") {
setQuestionIndex(lastExercise.questions.length - 1)
} else {
setQuestionIndex(0)
}
return;
} }
setExerciseIndex(exerciseIndex - 1); setExerciseIndex(exerciseIndex - 1);
@@ -269,171 +233,294 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1; const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex]; const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex];
if (previousExercise.type === "multipleChoice") { if (previousExercise.type === "multipleChoice") {
setStoreQuestionIndex(previousExercise.questions.length - 1) setQuestionIndex(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 = () => { const calculateExerciseIndex = () => {
if (partIndex === 0) { return exam.parts.reduce((acc, curr, index) => {
return ( if (index < partIndex) {
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex //+ multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0) return acc + countExercises(curr.exercises)
); }
} return acc;
const exercisesPerPart = exam.parts.map((x) => x.exercises.length); }, 0) + (questionIndex + 1);
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 = () => ( 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={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"> <div className="flex flex-col w-full gap-2">
Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read. {textRender && !textRenderDisabled ? (
</h4> <>
<span className="text-base">You will be allowed to read the text while doing the exercises</span> <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>
</>
) : (
<h4 className="text-xl font-semibold">
Answer the questions on the right based on what you&apos;ve read.
</h4>
)}
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{exam.parts[partIndex].context &&
<TextComponent
part={exam.parts[partIndex]}
contextWords={contextWords}
setContextWordLines={setContextWordLines}
setTotalLines={setTotalLines}
/>}
</div>
</>
</div>
{textRender && !textRenderDisabled && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
className="max-w-[200px] w-full"
onClick={() => { setTextRender(false); previousExercise(); }}
>
Back
</Button>
<Button color="purple" onClick={() => setTextRender(false)} className="max-w-[200px] self-end w-full">
Next
</Button>
</div> </div>
<TextComponent )}
part={exam.parts[partIndex]} </>
contextWord={contextWord}
setContextWordLine={setContextWordLine}
/>
</>
</div>
); );
const partLabel = () => { const partLabel = () => {
const partCategory = exam.parts[partIndex].category ? ` (${exam.parts[partIndex].category})` : '';
if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words)) 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}` return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${currentExercise.words[currentExercise.words.length - 1].id})${partCategory}\n\n${currentExercise.prompt}`
if (currentExercise?.type === "multipleChoice") { if (currentExercise?.type === "multipleChoice") {
return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})\n\n${currentExercise.prompt}` return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})${partCategory}\n\n${currentExercise.prompt}`
} }
if (typeof exam.parts[partIndex].context === "string") { if (typeof exam.parts[partIndex].context === "string") {
const nextExercise = exam.parts[partIndex].exercises[0] as MultipleChoiceExercise; 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}` return `Part ${partIndex + 1} (Questions ${nextExercise.questions[0].id} - ${nextExercise.questions[nextExercise.questions.length - 1].id})${partCategory}\n\n${nextExercise.prompt}`
} }
} }
const modalKwargs = () => { const answeredEveryQuestion = (partIndex: number) => {
const allSolutionsCorrectLength = exam.parts[partIndex].exercises.every((exercise) => { return exam.parts[partIndex].exercises.every((exercise) => {
const userSolution = userSolutions.find(x => x.exercise === exercise.id); const userSolution = userSolutions.find(x => x.exercise === exercise.id);
if (exercise.type === "multipleChoice") { switch (exercise.type) {
return userSolution?.solutions.length === exercise.questions.length; case 'multipleChoice':
} return userSolution?.solutions.length === exercise.questions.length;
if (exercise.type === "fillBlanks") { case 'fillBlanks':
return userSolution?.solutions.length === exercise.words.length; return userSolution?.solutions.length === exercise.words.length;
case 'writeBlanks':
return userSolution?.solutions.length === exercise.solutions.length;
case 'matchSentences':
return userSolution?.solutions.length === exercise.sentences.length;
case 'trueFalse':
return userSolution?.solutions.length === exercise.questions.length;
} }
return false; return false;
}); });
}
return { useEffect(() => {
blankQuestions: !allSolutionsCorrectLength, const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
finishingWhat: "part",
onClose: partIndex !== exam.parts.length - 1 ? ( const findMatch = (index: number) => {
function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } } if (currentExercise && currentExercise.type === "multipleChoice" && currentExercise!.questions[index]) {
) : function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); onFinish(userSolutions); } else { setShowQuestionsModal(false) } } const match = currentExercise!.questions[index].prompt.match(regex);
if (match) {
return { match: match[1], originalLine: match[2] }
}
}
return;
} }
// if the client for some whatever random reason decides
// to add more questions update this
const numberOfQuestions = 2;
if (exam.parts[partIndex].context) {
const hits = Array.from({ length: numberOfQuestions }).reduce<{ match: string, originalLine: string }[]>((acc, _, i) => {
const result = findMatch(questionIndex + i);
if (!!result) {
acc.push(result);
}
return acc;
}, []);
if (hits.length > 0) {
setContextWords(hits)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentExercise, questionIndex, totalLines]);
useEffect(() => {
if (
exerciseIndex !== -1 && currentExercise &&
currentExercise.type === "multipleChoice" &&
exam.parts[partIndex].context && contextWordLines
) {
if (contextWordLines.length > 0) {
contextWordLines.forEach((n, i) => {
if (contextWords && contextWords[i] && n !== -1) {
const updatedPrompt = currentExercise!.questions[questionIndex + i].prompt.replace(
`in line ${contextWords[i].originalLine}`,
`in line ${n}`
);
currentExercise!.questions[questionIndex + i].prompt = updatedPrompt;
}
})
setChangedPrompt(true);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contextWordLines]);
useEffect(() => {
if (continueAnyways) {
setContinueAnyways(false);
nextExercise();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [continueAnyways]);
const modalKwargs = () => {
const kwargs: { type: "module" | "blankQuestions" | "submit", unanswered: boolean, onClose: (next?: boolean) => void; } = {
type: "blankQuestions",
unanswered: false,
onClose: function (x: boolean | undefined) { if (x) { setContinueAnyways(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } }
};
if (partIndex === exam.parts.length - 1) {
kwargs.type = "submit"
kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestion(partIndex));
kwargs.onClose = function (x: boolean | undefined) { if (x) { setShowSubmissionModal(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } };
}
setQuestionModalKwargs(kwargs);
} }
const mcNavKwargs = {
userSolutions: userSolutions,
exam: exam,
partIndex: partIndex,
showSolutions: showSolutions,
"setExerciseIndex": setExerciseIndex,
"setPartIndex": setPartIndex,
"runOnClick": setQuestionIndex
}
const memoizedRender = useMemo(() => {
setChangedPrompt(false);
return (
<>
{textRender && !textRenderDisabled ?
renderText() :
<>
{exam.parts[partIndex].context && renderText()}
{(showSolutions || editing) ?
currentExercise && renderSolution(currentExercise, nextExercise, previousExercise) :
currentExercise && renderExercise(currentExercise, exam.id, next, previousExercise)
}
</>
}
</>)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [textRender, currentExercise, changedPrompt]);
return ( return (
<> <>
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}> <div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
<QuestionsModal isOpen={showQuestionsModal} {...modalKwargs()} /> <Modal
className={"!w-2/6 !p-8"}
titleClassName={"font-bold text-3xl text-mti-rose-light"}
isOpen={showSubmissionModal}
onClose={() => { }}
title={"Confirm Submission"}
>
<>
<p className="text-xl mt-8 mb-12">Are you sure you want to proceed with the submission?</p>
<div className="w-full flex justify-between">
<Button color="purple" onClick={() => setShowSubmissionModal(false)} variant="outline" className="max-w-[200px] self-end w-full !text-xl">
Cancel
</Button>
<Button color="rose" onClick={() => { setShowSubmissionModal(false); setContinueAnyways(true) }} className="max-w-[200px] self-end w-full !text-xl">
Confirm
</Button>
</div>
</>
</Modal>
<QuestionsModal isOpen={showQuestionsModal} {...questionModalKwargs} />
{ {
!(partIndex === 0 && storeQuestionIndex === 0 && showPartDivider) && !(partIndex === 0 && questionIndex === 0 && (showPartDivider || startNow)) &&
<Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} /> <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") }} /> : ( {(showPartDivider || startNow) ? <PartDivider part={exam.parts[partIndex]} partIndex={partIndex} onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); }} /> : (
<> <>
{exam.parts[0].intro && (
<div className="w-full">
<Tab.Group className="w-[90%]" selectedIndex={partIndex} onChange={setPartIndex}>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
{exam.parts.map((_, index) =>
<Tab key={index} onClick={(e) => {
/*
// If client wants to revert uncomment and remove the added if statement
if (!seenParts.has(index)) {
e.preventDefault();
} else {
*/
setExerciseIndex(0);
setQuestionIndex(0);
if (!seenParts.has(index)) {
setShowPartDivider(true);
setBgColor(levelBgColor);
setSeenParts(prev => new Set(prev).add(index));
}
}}
className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/80",
"ring-white ring-opacity-60 focus:outline-none",
"transition duration-300 ease-in-out hover:bg-white/70",
selected && "bg-white shadow",
// seenParts.includes(index) ? "hover:bg-white/70" : "cursor-not-allowed"
)
}
>{`Part ${index + 1}`}</Tab>
)
}
</Tab.List>
</Tab.Group>
</div>
)}
<ModuleTitle <ModuleTitle
examLabel={exam.label}
partLabel={partLabel()} partLabel={partLabel()}
minTimer={exam.minTimer} minTimer={exam.minTimer}
exerciseIndex={calculateExerciseIndex()} exerciseIndex={calculateExerciseIndex()}
module="level" module="level"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions || editing} disableTimer={showSolutions || editing}
showTimer={typeof exam.parts[0].intro === "undefined"} showTimer={false}
{...mcNavKwargs}
/> />
<div <div
className={clsx( className={clsx(
"mb-20 w-full", "mb-20 w-full",
partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4", !!exam.parts[partIndex].context && !textRender && "grid grid-cols-2 gap-4",
)}> )}>
{partIndex > -1 && !!exam.parts[partIndex].context && renderText()} {memoizedRender}
{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> </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> </div>

View File

@@ -2,17 +2,17 @@
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 {Stat, User} from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs"; import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {totalExamsByModule} from "@/utils/stats"; import {totalExamsByModule} from "@/utils/stats";
import useStats from "@/hooks/useStats"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
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 {ShuffleMap, Shuffles, 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";
@@ -30,7 +30,7 @@ export default function Selection({user, page, onStart, disableSelection = false
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true); const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const {stats} = useStats(user?.id); const {data: stats} = useFilterRecordsByUser<Stat[]>(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);
@@ -41,6 +41,7 @@ export default function Selection({user, page, onStart, disableSelection = false
}; };
const loadSession = async (session: Session) => { const loadSession = async (session: Session) => {
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
state.setSelectedModules(session.selectedModules); state.setSelectedModules(session.selectedModules);
state.setExam(session.exam); state.setExam(session.exam);
state.setExams(session.exams); state.setExams(session.exams);

View File

@@ -1,6 +1,7 @@
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";
import { base64 } from "@firebase/util";
const stagingServiceAccount = require("@/constants/staging.json"); const stagingServiceAccount = require("@/constants/staging.json");
const platformServiceAccount = require("@/constants/platform.json"); const platformServiceAccount = require("@/constants/platform.json");
@@ -22,3 +23,10 @@ export const adminApp = admin.initializeApp(
Math.random().toString(), Math.random().toString(),
); );
export const storage = getStorage(app); export const storage = getStorage(app);
export const firebaseAuthScryptParams = {
memCost: Number(process.env.FIREBASE_SCRYPT_MEM_COST),
rounds: Number(process.env.FIREBASE_SCRYPT_ROUNDS),
saltSeparator: process.env.FIREBASE_SCRYPT_B64_SALT_SEPARATOR!,
signerKey: process.env.FIREBASE_SCRYPT_B64_SIGNER_KEY!,
}

View File

@@ -1,13 +1,17 @@
import { Assignment } from "@/interfaces/results"; import { AssignmentWithCorporateId } from "@/interfaces/results";
import axios from "axios"; import axios from "axios";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export default function useAssignmentsCorporates({ export default function useAssignmentsCorporates({
corporates, corporates,
startDate,
endDate,
}: { }: {
corporates: string[]; corporates: string[];
startDate: Date | null;
endDate: Date | null;
}) { }) {
const [assignments, setAssignments] = useState<Assignment[]>([]); const [assignments, setAssignments] = useState<AssignmentWithCorporateId[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
@@ -18,9 +22,15 @@ export default function useAssignmentsCorporates({
} }
setIsLoading(true); setIsLoading(true);
const urlSearchParams = new URLSearchParams({
ids: corporates.join(","),
...(startDate ? { startDate: startDate.toISOString() } : {}),
...(endDate ? { endDate: endDate.toISOString() } : {}),
});
axios axios
.get<Assignment[]>( .get<AssignmentWithCorporateId[]>(
`/api/assignments/corporate?ids=${corporates.join(",")}` `/api/assignments/corporate?${urlSearchParams.toString()}`
) )
.then(async (response) => { .then(async (response) => {
setAssignments(response.data); setAssignments(response.data);
@@ -28,7 +38,7 @@ export default function useAssignmentsCorporates({
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
useEffect(getData, [corporates]); useEffect(getData, [corporates, startDate, endDate]);
return { assignments, isLoading, isError, reload: getData }; return { assignments, isLoading, isError, reload: getData };
} }

View File

@@ -0,0 +1,42 @@
import React from "react";
import axios from "axios";
import {toast} from "react-toastify";
import {BsDoorOpen} from "react-icons/bs";
export const useAssignmentRelease = (assignmentId: string, reload?: Function) => {
const [loading, setLoading] = React.useState(false);
const archive = () => {
// archive assignment
setLoading(true);
axios
.post(`/api/assignments/${assignmentId}/release`)
.then((res) => {
toast.success("Assignment released!");
if (reload) reload();
setLoading(false);
})
.catch((err) => {
toast.error("Failed to release the assignment!");
setLoading(false);
});
};
const renderIcon = (downloadClasses: string, loadingClasses: string) => {
if (loading) {
return <span className={`${loadingClasses} loading loading-infinity w-6`} />;
}
return (
<div
className="tooltip flex items-center justify-center w-fit h-fit"
data-tip="Release assignment"
onClick={(e) => {
e.stopPropagation();
archive();
}}>
<BsDoorOpen className={`${downloadClasses} text-2xl cursor-pointer tooltip`} />
</div>
);
};
return renderIcon;
};

View File

@@ -1,7 +1,11 @@
import {Assignment} from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import axios from "axios"; import Axios from "axios";
import {setupCache} from "axios-cache-interceptor";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
const instance = Axios.create();
const axios = setupCache(instance);
export default function useAssignments({assigner, assignees, corporate}: {assigner?: string; assignees?: string; corporate?: 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);
@@ -13,7 +17,7 @@ export default function useAssignments({assigner, assignees, corporate}: {assign
.get<Assignment[]>(!corporate ? "/api/assignments" : `/api/assignments/corporate/${corporate}`) .get<Assignment[]>(!corporate ? "/api/assignments" : `/api/assignments/corporate/${corporate}`)
.then(async (response) => { .then(async (response) => {
if (assigner) { if (assigner) {
setAssignments(response.data.filter((a) => a.assigner === assigner)); setAssignments(response.data.filter((a) => a.assigner === assigner || (!a.teachers ? false : a.teachers.includes(assigner))));
return; return;
} }

View File

@@ -0,0 +1,51 @@
import axios from "axios";
import { useEffect, useState } from "react";
const endpoints: Record<string, string> = {
stats: "/api/stats",
training: "/api/training"
};
export default function useFilterRecordsByUser<T extends any[]>(
id?: string,
shouldNotQuery?: boolean,
recordType: string = 'stats'
) {
type ElementType = T extends (infer U)[] ? U : never;
const [data, setData] = useState<T>([] as unknown as T);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const endpointURL = endpoints[recordType] || endpoints.stats;
// CAUTION: This makes the assumption that the record enpoint has a /user/${id} endpoint
const endpoint = !id ? endpointURL: `${endpointURL}/user/${id}`;
const getData = () => {
if (shouldNotQuery) return;
setIsLoading(true);
setIsError(false);
axios
.get<T>(endpoint)
.then((response) => {
// CAUTION: This makes the assumption ElementType has a "user" field that contains the user id
setData(response.data.filter((x: ElementType) => (id ? (x as any).user === id : true)) as T);
})
.catch(() => setIsError(true))
.finally(() => setIsLoading(false));
};
useEffect(() => {
getData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, shouldNotQuery, recordType, endpoint]);
return {
data,
reload: getData,
isLoading,
isError
};
}

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

@@ -0,0 +1,22 @@
import {Grading} from "@/interfaces";
import {Code, Group, User} from "@/interfaces/user";
import axios from "axios";
import {useEffect, useState} from "react";
export default function useGradingSystem() {
const [gradingSystem, setGradingSystem] = useState<Grading>();
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Grading>(`/api/grading`)
.then((response) => setGradingSystem(response.data))
.finally(() => setIsLoading(false));
};
useEffect(getData, []);
return {gradingSystem, isLoading, isError, reload: getData, mutate: setGradingSystem};
}

View File

@@ -1,7 +1,11 @@
import {Group, User} from "@/interfaces/user"; import {Group, User} from "@/interfaces/user";
import axios from "axios"; import Axios from "axios";
import {setupCache} from "axios-cache-interceptor";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
const instance = Axios.create();
const axios = setupCache(instance);
interface Props { interface Props {
admin?: string; admin?: string;
userType?: string; userType?: string;

View File

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

View File

@@ -0,0 +1,28 @@
import Button from "@/components/Low/Button";
import {useMemo, useState} from "react";
export default function usePagination<T>(list: T[], size = 25) {
const [page, setPage] = useState(0);
const items = useMemo(() => list.slice(page * size, (page + 1) * size), [page, size, list]);
const render = () => (
<div className="w-full flex gap-2 justify-between items-center">
<div className="flex items-center gap-4 w-fit">
<Button className="w-[200px] h-fit" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}>
Previous Page
</Button>
</div>
<div className="flex items-center gap-4 w-fit">
<span className="opacity-80">
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length}
</span>
<Button className="w-[200px]" disabled={(page + 1) * size >= list.length} onClick={() => setPage((prev) => prev + 1)}>
Next Page
</Button>
</div>
</div>
);
return {page, items, setPage, render};
}

View File

@@ -1,9 +1,13 @@
import {Exam} from "@/interfaces/exam"; import {Exam} from "@/interfaces/exam";
import {Permission, PermissionType} from "@/interfaces/permissions"; import {Permission, PermissionType} from "@/interfaces/permissions";
import {ExamState} from "@/stores/examStore"; import {ExamState} from "@/stores/examStore";
import axios from "axios"; import Axios from "axios";
import {setupCache} from "axios-cache-interceptor";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
const instance = Axios.create();
const axios = setupCache(instance);
export default function usePermissions(user: string) { export default function usePermissions(user: string) {
const [permissions, setPermissions] = useState<PermissionType[]>([]); const [permissions, setPermissions] = useState<PermissionType[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);

View File

@@ -1,6 +1,7 @@
import {Exam} from "@/interfaces/exam"; import {Exam} from "@/interfaces/exam";
import {ExamState} from "@/stores/examStore"; import {ExamState} from "@/stores/examStore";
import axios from "axios"; import axios from "axios";
import {setupCache} from "axios-cache-interceptor";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
export type Session = ExamState & {user: string; id: string; date: string}; export type Session = ExamState & {user: string; id: string; date: string};

View File

@@ -1,23 +0,0 @@
import {Stat, User} from "@/interfaces/user";
import axios from "axios";
import {useEffect, useState} from "react";
export default function useStats(id?: string, shouldNotQuery?: boolean) {
const [stats, setStats] = useState<Stat[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
if (shouldNotQuery) return;
setIsLoading(true);
axios
.get<Stat[]>(!id ? "/api/stats" : `/api/stats/user/${id}`)
.then((response) => setStats(response.data.filter((x) => (id ? x.user === id : true))))
.finally(() => setIsLoading(false));
};
useEffect(getData, [id, shouldNotQuery]);
return {stats, reload: getData, isLoading, isError};
}

View File

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

View File

@@ -0,0 +1,21 @@
import {Code, Group, User} from "@/interfaces/user";
import axios from "axios";
import {useEffect, useState} from "react";
export default function useUserBalance() {
const [balance, setBalance] = useState<number>(0);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<{balance: number}>(`/api/users/balance`)
.then((response) => setBalance(response.data.balance))
.finally(() => setIsLoading(false));
};
useEffect(getData, []);
return {balance, isLoading, isError, reload: getData};
}

View File

@@ -1,21 +1,39 @@
import {User} from "@/interfaces/user"; import {Type, User} from "@/interfaces/user";
import axios from "axios"; import Axios from "axios";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {setupCache} from "axios-cache-interceptor";
const instance = Axios.create();
const axios = setupCache(instance);
export default function useUsers() { export const userHashStudent = {type: "student"} as {type: Type};
export const userHashTeacher = {type: "teacher"} as {type: Type};
export const userHashCorporate = {type: "corporate"} as {type: Type};
export default function useUsers(props?: {type?: string; page?: number; size?: number; orderBy?: string; direction?: "asc" | "desc"}) {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [total, setTotal] = useState(0);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const getData = () => { const getData = () => {
const params = new URLSearchParams();
if (!!props)
Object.keys(props).forEach((key) => {
if (props[key as keyof typeof props] !== undefined) params.append(key, props[key as keyof typeof props]!.toString());
});
setIsLoading(true); setIsLoading(true);
axios axios
.get<User[]>("/api/users/list", {headers: {page: "register"}}) .get<{users: User[]; total: number}>(`/api/users/list?${params.toString()}`, {headers: {page: "register"}})
.then((response) => setUsers(response.data)) .then((response) => {
setUsers(response.data.users);
setTotal(response.data.total);
})
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(getData, [props?.page, props?.size, props?.type, props?.orderBy, props?.direction]);
useEffect(getData, []); return {users, total, isLoading, isError, reload: getData};
return {users, isLoading, isError, reload: getData};
} }

View File

@@ -1,4 +1,4 @@
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";
@@ -12,9 +12,12 @@ interface ExamBase {
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty; difficulty?: Difficulty;
owners?: string[];
shuffle?: boolean; shuffle?: boolean;
createdBy?: string; // option as it has been added later createdBy?: string; // option as it has been added later
createdAt?: string; // option as it has been added later createdAt?: string; // option as it has been added later
private?: boolean;
label?: string;
} }
export interface ReadingExam extends ExamBase { export interface ReadingExam extends ExamBase {
module: "reading"; module: "reading";
@@ -38,6 +41,7 @@ export interface LevelExam extends ExamBase {
export interface LevelPart { export interface LevelPart {
context?: string; context?: string;
intro?: string; intro?: string;
category?: string;
exercises: Exercise[]; exercises: Exercise[];
} }
@@ -67,7 +71,7 @@ export interface UserSolution {
}; };
exercise: string; exercise: string;
isDisabled?: boolean; isDisabled?: boolean;
shuffleMaps?: ShuffleMap[] shuffleMaps?: ShuffleMap[];
} }
export interface WritingExam extends ExamBase { export interface WritingExam extends ExamBase {
@@ -99,24 +103,19 @@ export type Exercise =
export interface Evaluation { export interface Evaluation {
comment: string; comment: string;
overall: number; overall: number;
task_response: { [key: string]: number | { grade: number; comment: string } }; task_response: {[key: string]: number | {grade: number; comment: string}};
misspelled_pairs?: { correction: string | null; misspelled: string }[]; misspelled_pairs?: {correction: string | null; misspelled: string}[];
} }
type InteractivePerfectAnswerKey = `perfect_answer_${number}`; type InteractivePerfectAnswerKey = `perfect_answer_${number}`;
type InteractiveTranscriptKey = `transcript_${number}`; type InteractiveTranscriptKey = `transcript_${number}`;
type InteractiveFixedTextKey = `fixed_text_${number}`; type InteractiveFixedTextKey = `fixed_text_${number}`;
type InteractivePerfectAnswerType = { [key in InteractivePerfectAnswerKey]: { answer: string } }; type InteractivePerfectAnswerType = {[key in InteractivePerfectAnswerKey]: {answer: string}};
type InteractiveTranscriptType = { [key in InteractiveTranscriptKey]?: string }; type InteractiveTranscriptType = {[key in InteractiveTranscriptKey]?: string};
type InteractiveFixedTextType = { [key in InteractiveFixedTextKey]?: string }; type InteractiveFixedTextType = {[key in InteractiveFixedTextKey]?: string};
interface InteractiveSpeakingEvaluation extends Evaluation,
InteractivePerfectAnswerType,
InteractiveTranscriptType,
InteractiveFixedTextType { }
interface InteractiveSpeakingEvaluation extends Evaluation, InteractivePerfectAnswerType, InteractiveTranscriptType, InteractiveFixedTextType {}
interface SpeakingEvaluation extends CommonEvaluation { interface SpeakingEvaluation extends CommonEvaluation {
perfect_answer_1?: string; perfect_answer_1?: string;
@@ -189,10 +188,10 @@ export interface InteractiveSpeakingExercise {
first_title?: string; first_title?: string;
second_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;
@@ -208,14 +207,14 @@ export interface FillBlanksMCOption {
B: string; B: string;
C: string; C: string;
D: 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 | { letter: string; word: string } | FillBlanksMCOption)[]; // *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: {
@@ -234,7 +233,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 {
@@ -263,7 +262,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[];
@@ -286,7 +285,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 {
@@ -303,8 +302,13 @@ export interface MultipleChoiceQuestion {
} }
export interface ShuffleMap { export interface ShuffleMap {
id: string; questionID: string;
map: { map: {
[key: string]: string; [key: string]: string;
} };
}
export interface Shuffles {
exerciseID: string;
shuffles: ShuffleMap[];
} }

View File

@@ -1 +1,12 @@
export type Module = "reading" | "listening" | "writing" | "speaking" | "level"; export type Module = "reading" | "listening" | "writing" | "speaking" | "level";
export interface Step {
min: number;
max: number;
label: string;
}
export interface Grading {
user: string;
steps: Step[];
}

View File

@@ -10,19 +10,30 @@ interface ModuleResult {
total: number; total: number;
} }
export interface AssignmentResult {
user: string;
type: "academic" | "general";
stats: Stat[];
}
export interface Assignment { export interface Assignment {
id: string; id: string;
name: string; name: string;
assigner: string; assigner: string;
assignees: string[]; assignees: string[];
results: { results: AssignmentResult[];
user: string;
type: "academic" | "general";
stats: Stat[];
}[];
exams: {id: string; module: Module; assignee: string}[]; exams: {id: string; module: Module; assignee: string}[];
instructorGender?: InstructorGender; instructorGender?: InstructorGender;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
teachers?: string[];
archived?: boolean; archived?: boolean;
released?: boolean;
// unless start is active, the assignment is not visible to the assignees
// start date now works as a limit time to start the exam
start?: boolean;
autoStartDate?: Date;
autoStart?: boolean;
} }
export type AssignmentWithCorporateId = Assignment & {corporateId: string};

View File

@@ -1,195 +1,168 @@
import { Module } from "."; import {Module} from ".";
import { InstructorGender, ShuffleMap } from "./exam"; import {InstructorGender, ShuffleMap} from "./exam";
import { PermissionType } from "./permissions"; import {PermissionType} from "./permissions";
export type User = export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser | MasterCorporateUser;
| StudentUser
| TeacherUser
| CorporateUser
| AgentUser
| AdminUser
| DeveloperUser
| MasterCorporateUser;
export type UserStatus = "active" | "disabled" | "paymentDue"; export type UserStatus = "active" | "disabled" | "paymentDue";
export interface BasicUser { export interface BasicUser {
email: string; email: string;
name: string; name: string;
profilePicture: string; profilePicture: string;
id: string; id: string;
isFirstLogin: boolean; isFirstLogin: boolean;
focus: "academic" | "general"; focus: "academic" | "general";
levels: { [key in Module]: number }; levels: {[key in Module]: number};
desiredLevels: { [key in Module]: number }; desiredLevels: {[key in Module]: number};
type: Type; type: Type;
bio: string; bio: string;
isVerified: boolean; isVerified: boolean;
subscriptionExpirationDate?: null | Date; subscriptionExpirationDate?: null | Date;
registrationDate?: Date; registrationDate?: Date;
status: UserStatus; status: UserStatus;
permissions: PermissionType[]; permissions: PermissionType[];
lastLogin?: Date; lastLogin?: Date;
} }
export interface StudentUser extends BasicUser { export interface StudentUser extends BasicUser {
type: "student"; type: "student";
preferredGender?: InstructorGender; studentID?: string;
demographicInformation?: DemographicInformation; preferredGender?: InstructorGender;
preferredTopics?: string[]; demographicInformation?: DemographicInformation;
preferredTopics?: string[];
} }
export interface TeacherUser extends BasicUser { export interface TeacherUser extends BasicUser {
type: "teacher"; type: "teacher";
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface CorporateUser extends BasicUser { export interface CorporateUser extends BasicUser {
type: "corporate"; type: "corporate";
corporateInformation: CorporateInformation; corporateInformation: CorporateInformation;
demographicInformation?: DemographicCorporateInformation; demographicInformation?: DemographicCorporateInformation;
} }
export interface MasterCorporateUser extends BasicUser { export interface MasterCorporateUser extends BasicUser {
type: "mastercorporate"; type: "mastercorporate";
corporateInformation: CorporateInformation; corporateInformation: CorporateInformation;
demographicInformation?: DemographicCorporateInformation; demographicInformation?: DemographicCorporateInformation;
} }
export interface AgentUser extends BasicUser { export interface AgentUser extends BasicUser {
type: "agent"; type: "agent";
agentInformation: AgentInformation; agentInformation: AgentInformation;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface AdminUser extends BasicUser { export interface AdminUser extends BasicUser {
type: "admin"; type: "admin";
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface DeveloperUser extends BasicUser { export interface DeveloperUser extends BasicUser {
type: "developer"; type: "developer";
preferredGender?: InstructorGender; preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[]; preferredTopics?: string[];
} }
export interface CorporateInformation { export interface CorporateInformation {
companyInformation: CompanyInformation; companyInformation: CompanyInformation;
monthlyDuration: number; monthlyDuration: number;
payment?: { payment?: {
value: number; value: number;
currency: string; currency: string;
commission: number; commission: number;
}; };
referralAgent?: string; referralAgent?: string;
} }
export interface AgentInformation { export interface AgentInformation {
companyName: string; companyName: string;
commercialRegistration: string; commercialRegistration: string;
companyArabName?: string; companyArabName?: string;
} }
export interface CompanyInformation { export interface CompanyInformation {
name: string; name: string;
userAmount: number; userAmount: number;
} }
export interface DemographicInformation { export interface DemographicInformation {
country: string; country: string;
phone: string; phone: string;
gender: Gender; gender: Gender;
employment: EmploymentStatus; employment: EmploymentStatus;
passport_id?: string; passport_id?: string;
timezone?: string; timezone?: string;
} }
export interface DemographicCorporateInformation { export interface DemographicCorporateInformation {
country: string; country: string;
phone: string; phone: string;
gender: Gender; gender: Gender;
position: string; position: string;
timezone?: string; timezone?: string;
} }
export type Gender = "male" | "female" | "other"; export type Gender = "male" | "female" | "other";
export type EmploymentStatus = export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other";
| "employed" export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
| "student" {status: "student", label: "Student"},
| "self-employed" {status: "employed", label: "Employed"},
| "unemployed" {status: "unemployed", label: "Unemployed"},
| "retired" {status: "self-employed", label: "Self-employed"},
| "other"; {status: "retired", label: "Retired"},
export const EMPLOYMENT_STATUS: { status: EmploymentStatus; label: string }[] = {status: "other", label: "Other"},
[ ];
{ status: "student", label: "Student" },
{ status: "employed", label: "Employed" },
{ status: "unemployed", label: "Unemployed" },
{ status: "self-employed", label: "Self-employed" },
{ status: "retired", label: "Retired" },
{ status: "other", label: "Other" },
];
export interface Stat { export interface Stat {
id: string; id: string;
user: string; user: string;
exam: string; exam: string;
exercise: string; exercise: string;
session: string; session: string;
date: number; date: number;
module: Module; module: Module;
solutions: any[]; solutions: any[];
type: string; type: string;
timeSpent?: number; timeSpent?: number;
inactivity?: number; inactivity?: number;
assignment?: string; assignment?: string;
score: { score: {
correct: number; correct: number;
total: number; total: number;
missing: number; missing: number;
}; };
isDisabled?: boolean; isDisabled?: boolean;
shuffleMaps?: ShuffleMap[]; shuffleMaps?: ShuffleMap[];
pdf?: { pdf?: {
path: string; path: string;
version: string; version: string;
}; };
} }
export interface Group { export interface Group {
admin: string; admin: string;
name: string; name: string;
participants: string[]; participants: string[];
id: string; id: string;
disableEditing?: boolean; disableEditing?: boolean;
} }
export interface Code { export interface Code {
code: string; id: string;
creator: string; code: string;
expiryDate: Date; creator: string;
type: Type; expiryDate: Date;
creationDate?: string; type: Type;
userId?: string; creationDate?: string;
email?: string; userId?: string;
name?: string; email?: string;
passport_id?: string; name?: string;
passport_id?: string;
} }
export type Type = export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate";
| "student" export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"];
| "teacher"
| "corporate"
| "admin"
| "developer"
| "agent"
| "mastercorporate";
export const userTypes: Type[] = [
"student",
"teacher",
"corporate",
"admin",
"developer",
"agent",
"mastercorporate",
];

30
src/lib/mongodb.ts Normal file
View File

@@ -0,0 +1,30 @@
import {MongoClient} from "mongodb";
if (!process.env.MONGODB_URI) {
throw new Error('Invalid/Missing environment variable: "MONGODB_URI"');
}
const uri = process.env.MONGODB_URI || "";
const options = {};
let client: MongoClient;
if (process.env.NODE_ENV === "development") {
// In development mode, use a global variable so that the value
// is preserved across module reloads caused by HMR (Hot Module Replacement).
let globalWithMongo = global as typeof globalThis & {
_mongoClient?: MongoClient;
};
if (!globalWithMongo._mongoClient) {
globalWithMongo._mongoClient = new MongoClient(uri, options);
}
client = globalWithMongo._mongoClient;
} else {
// In production mode, it's best to not use a global variable.
client = new MongoClient(uri, options);
}
// Export a module-scoped MongoClient. By doing this in a
// separate module, the client can be shared across functions.
export default client;

5
src/mongodb.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
import {MongoClient} from "mongodb";
declare global {
var _mongoClientPromise: Promise<MongoClient>;
}

View File

@@ -19,6 +19,7 @@ import {BsFileEarmarkEaselFill, BsQuestionCircleFill} from "react-icons/bs";
import {checkAccess, getTypesOfUser} from "@/utils/permissions"; import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import {PermissionType} from "@/interfaces/permissions"; import {PermissionType} from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions"; 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: { const USER_TYPE_PERMISSIONS: {
@@ -34,7 +35,7 @@ const USER_TYPE_PERMISSIONS: {
}, },
agent: { agent: {
perm: "createCodeCountryManager", perm: "createCodeCountryManager",
list: [], list: ["student", "teacher", "corporate", "mastercorporate"],
}, },
corporate: { corporate: {
perm: "createCodeCorporate", perm: "createCodeCorporate",
@@ -54,7 +55,14 @@ const USER_TYPE_PERMISSIONS: {
}, },
}; };
export default function BatchCodeGenerator({user}: {user: User}) { interface Props {
user: User;
users: User[];
permissions: PermissionType[];
onFinish: () => void;
}
export default function BatchCodeGenerator({user, users, permissions, onFinish}: Props) {
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]); const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [expiryDate, setExpiryDate] = useState<Date | null>( const [expiryDate, setExpiryDate] = useState<Date | null>(
@@ -64,9 +72,6 @@ export default function BatchCodeGenerator({user}: {user: User}) {
const [type, setType] = useState<Type>("student"); const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const {users} = useUsers();
const {permissions} = usePermissions(user?.id || "");
const {openFilePicker, filesContent, clear} = useFilePicker({ const {openFilePicker, filesContent, clear} = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
multiple: false, multiple: false,
@@ -85,7 +90,7 @@ export default function BatchCodeGenerator({user}: {user: User}) {
const information = uniqBy( const information = uniqBy(
rows rows
.map((row) => { .map((row) => {
const [firstName, lastName, country, passport_id, email, ...phone] = row as string[]; const [firstName, lastName, country, passport_id, email, phone] = row as string[];
return EMAIL_REGEX.test(email.toString().trim()) return EMAIL_REGEX.test(email.toString().trim())
? { ? {
email: email.toString().trim().toLowerCase(), email: email.toString().trim().toLowerCase(),
@@ -164,6 +169,8 @@ export default function BatchCodeGenerator({user}: {user: User}) {
)} codes and they have been notified by e-mail!`, )} codes and they have been notified by e-mail!`,
{toastId: "success"}, {toastId: "success"},
); );
onFinish();
return; return;
} }

View File

@@ -11,10 +11,13 @@ import Modal from "@/components/Modal";
import {BsQuestionCircleFill} from "react-icons/bs"; import {BsQuestionCircleFill} from "react-icons/bs";
import {PermissionType} from "@/interfaces/permissions"; import {PermissionType} from "@/interfaces/permissions";
import moment from "moment"; import moment from "moment";
import {checkAccess} from "@/utils/permissions"; import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import clsx from "clsx"; import clsx from "clsx";
import usePermissions from "@/hooks/usePermissions";
import countryCodes from "country-codes-list";
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]+)*$/);
type Type = Exclude<UserType, "admin" | "developer" | "agent" | "mastercorporate">; type Type = Exclude<UserType, "admin" | "developer" | "agent" | "mastercorporate">;
@@ -26,7 +29,7 @@ const USER_TYPE_LABELS: {[key in Type]: string} = {
}; };
const USER_TYPE_PERMISSIONS: { const USER_TYPE_PERMISSIONS: {
[key in Type]: {perm: PermissionType | undefined; list: Type[]}; [key in UserType]: {perm: PermissionType | undefined; list: UserType[]};
} = { } = {
student: { student: {
perm: "createCodeStudent", perm: "createCodeStudent",
@@ -36,13 +39,36 @@ const USER_TYPE_PERMISSIONS: {
perm: "createCodeTeacher", perm: "createCodeTeacher",
list: [], list: [],
}, },
agent: {
perm: "createCodeCountryManager",
list: ["student", "teacher", "corporate", "mastercorporate"],
},
corporate: { corporate: {
perm: "createCodeCorporate", perm: "createCodeCorporate",
list: ["student", "teacher"], list: ["student", "teacher"],
}, },
mastercorporate: {
perm: undefined,
list: ["student", "teacher", "corporate"],
},
admin: {
perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "mastercorporate"],
},
developer: {
perm: undefined,
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
},
}; };
export default function BatchCreateUser({user}: {user: User}) { interface Props {
user: User;
users: User[];
permissions: PermissionType[];
onFinish: () => void;
}
export default function BatchCreateUser({user, users, permissions, onFinish}: Props) {
const [infos, setInfos] = useState< const [infos, setInfos] = useState<
{ {
email: string; email: string;
@@ -64,8 +90,6 @@ export default function BatchCreateUser({user}: {user: User}) {
const [type, setType] = useState<Type>("student"); const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const {users} = useUsers();
const {openFilePicker, filesContent, clear} = useFilePicker({ const {openFilePicker, filesContent, clear} = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
multiple: false, multiple: false,
@@ -84,7 +108,11 @@ export default function BatchCreateUser({user}: {user: User}) {
const information = uniqBy( const information = uniqBy(
rows rows
.map((row) => { .map((row) => {
const [firstName, lastName, country, passport_id, email, phone, group] = row as string[]; const [firstName, lastName, studentID, passport_id, email, phone, corporate, group, country] = row as string[];
const countryItem =
countryCodes.findOne("countryCode" as any, country.toUpperCase()) ||
countryCodes.all().find((x) => x.countryNameEn.toLowerCase() === country.toLowerCase());
return EMAIL_REGEX.test(email.toString().trim()) return EMAIL_REGEX.test(email.toString().trim())
? { ? {
email: email.toString().trim().toLowerCase(), email: email.toString().trim().toLowerCase(),
@@ -92,10 +120,12 @@ export default function BatchCreateUser({user}: {user: User}) {
type: type, type: type,
passport_id: passport_id?.toString().trim() || undefined, passport_id: passport_id?.toString().trim() || undefined,
groupName: group, groupName: group,
corporate,
studentID,
demographicInformation: { demographicInformation: {
country: country, country: countryItem?.countryCode,
passport_id: passport_id?.toString().trim() || undefined, passport_id: passport_id?.toString().trim() || undefined,
phone, phone: phone.toString(),
}, },
} }
: undefined; : undefined;
@@ -131,8 +161,9 @@ export default function BatchCreateUser({user}: {user: User}) {
setIsLoading(true); setIsLoading(true);
try { try {
for (const newUser of newUsers) await axios.post("/api/make_user", {...newUser, type, expiryDate}); await axios.post("/api/batch_users", {users: newUsers.map((user) => ({...user, type, expiryDate}))});
toast.success(`Successfully added ${newUsers.length} user(s)!`); toast.success(`Successfully added ${newUsers.length} user(s)!`);
onFinish();
} catch { } catch {
toast.error("Something went wrong, please try again later!"); toast.error("Something went wrong, please try again later!");
} finally { } finally {
@@ -153,11 +184,13 @@ export default function BatchCreateUser({user}: {user: User}) {
<tr> <tr>
<th className="border border-neutral-200 px-2 py-1">First Name</th> <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">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">Student ID</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">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">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">Phone Number</th>
{user?.type !== "corporate" && <th className="border border-neutral-200 px-2 py-1">Corporate (e-mail)</th>}
<th className="border border-neutral-200 px-2 py-1">Group Name</th> <th className="border border-neutral-200 px-2 py-1">Group Name</th>
<th className="border border-neutral-200 px-2 py-1">Country</th>
</tr> </tr>
</thead> </thead>
</table> </table>
@@ -214,11 +247,17 @@ export default function BatchCreateUser({user}: {user: User}) {
defaultValue="student" defaultValue="student"
onChange={(e) => setType(e.target.value as Type)} 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"> 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) => ( {Object.keys(USER_TYPE_LABELS)
<option key={type} value={type}> .filter((x) => {
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
</option> // if (x === "corporate") console.log(list, perm, checkAccess(user, list, permissions, perm));
))} return checkAccess(user, getTypesOfUser(list), permissions, perm);
})
.map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select> </select>
)} )}
<Button className="my-auto" onClick={makeUsers} disabled={infos.length === 0}> <Button className="my-auto" onClick={makeUsers} disabled={infos.length === 0}>

View File

@@ -11,7 +11,7 @@ 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 {checkAccess, getTypesOfUser} from "@/utils/permissions";
import {PermissionType} from "@/interfaces/permissions"; import {PermissionType} from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
@@ -28,7 +28,7 @@ const USER_TYPE_PERMISSIONS: {
}, },
agent: { agent: {
perm: "createCodeCountryManager", perm: "createCodeCountryManager",
list: [], list: ["student", "teacher", "corporate", "mastercorporate"],
}, },
corporate: { corporate: {
perm: "createCodeCorporate", perm: "createCodeCorporate",
@@ -48,14 +48,19 @@ const USER_TYPE_PERMISSIONS: {
}, },
}; };
export default function CodeGenerator({user}: {user: User}) { interface Props {
user: User;
permissions: PermissionType[];
onFinish: () => void;
}
export default function CodeGenerator({user, permissions, onFinish}: Props) {
const [generatedCode, setGeneratedCode] = useState<string>(); const [generatedCode, setGeneratedCode] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>( const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null, user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
); );
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);
@@ -103,7 +108,7 @@ export default function CodeGenerator({user}: {user: User}) {
{Object.keys(USER_TYPE_LABELS) {Object.keys(USER_TYPE_LABELS)
.filter((x) => { .filter((x) => {
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type]; const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, list, permissions, perm); return checkAccess(user, getTypesOfUser(list), permissions, perm);
}) })
.map((type) => ( .map((type) => (
<option key={type} value={type}> <option key={type} value={type}>

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