Compare commits

...

699 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Approved-by: Tiago Ribeiro
2024-06-25 11:47:24 +00:00
Joao Ramos
ec8c06ca94 Filtered out the fields from the UserCard in the list wouldn't have anything to display 2024-06-24 11:12:16 +01:00
Joao Ramos
77a22b3ab3 Added a screen to display the desired levels for the students of a specific corporate 2024-06-22 00:16:30 +01:00
Joao Ramos
e79139174b Added an initial filter for corporate on records 2024-06-21 23:22:49 +01:00
Joao Ramos
61a86394ed Added persistance to the selected user record 2024-06-20 23:20:52 +01:00
Joao Ramos
f6741dd80e Added date filter to code list 2024-06-20 22:57:34 +01:00
Joao Ramos
ce6708be6e Minor fixing on a duplicated key on table 2024-06-20 22:34:02 +01:00
Joao Ramos
b62cae2e3a Filtered done ticket out of the view 2024-06-19 23:14:05 +01:00
Joao Ramos
d73b6d9d12 Added badges for students 2024-06-19 23:00:43 +01:00
Joao Ramos
c11906a395 Standardized the access to the list of users 2024-06-19 21:59:35 +01:00
Joao Ramos
a29b0b56d9 Changed to the correct format for CSV 2024-06-19 21:39:27 +01:00
Tiago Ribeiro
53dbf99fba Fixed some more issues with the Speaking 2024-06-18 22:16:31 +01:00
Tiago Ribeiro
cb49e15cb0 Updated the speaking and interactive speaking to the new format 2024-06-18 10:02:03 +01:00
Tiago Ribeiro
0eddded560 Updated part of the speaking accordingly 2024-06-13 18:17:07 +01:00
Tiago Ribeiro
11c6f70576 Updated a simple bug 2024-06-11 22:44:00 +01:00
Tiago Ribeiro
6712e89c47 Updated the format of the interactive speaking 2024-06-11 11:20:54 +01:00
Tiago Ribeiro
9959cf4294 Removed the persistence for the Speaking exam for now 2024-06-08 10:39:26 +01:00
Tiago Ribeiro
daec246835 Updated the Level Exam to work based on Parts 2024-06-07 13:25:18 +01:00
Tiago Ribeiro
8ea97ee944 Added a new feature to check for and register inactivity during an exam 2024-06-04 22:18:45 +01:00
Tiago Ribeiro
975f4c8285 Updated Firebase to use a service account depending on the environment 2024-05-29 09:06:46 +01:00
Tiago Ribeiro
f0b85409c9 Merge branch 'main' into develop 2024-05-27 16:37:09 +01:00
Tiago Ribeiro
bdd862c633 Updated the state to be active on payment 2024-05-27 13:08:11 +01:00
Tiago Ribeiro
4166781f7e Improved some issues with the payment 2024-05-27 13:05:38 +01:00
Tiago Ribeiro
1f8e9106de Updated the code to set the expiry date to the end of the day 2024-05-27 12:19:25 +01:00
Tiago Ribeiro
9e651358d5 Updated the code to return a 400 when it is not a success 2024-05-27 12:16:08 +01:00
Tiago Ribeiro
5aed336c96 Added a log 2024-05-27 11:02:11 +01:00
Tiago Ribeiro
85b94512e9 Merge branch 'develop' into ENCOA-38/add-validity-date-for-discounts 2024-05-23 19:22:31 +01:00
Tiago Ribeiro
906646ebce Created the validity dates for discounts 2024-05-23 19:21:52 +01:00
Tiago Ribeiro
96108a4958 Reverted to have checks 2024-05-23 17:22:57 +01:00
Tiago Ribeiro
fb449f2054 Updated the status when the transaction is not successful 2024-05-21 15:40:18 +01:00
Tiago Ribeiro
d5ee3d9519 Added a log for debugging 2024-05-21 15:35:57 +01:00
Tiago Ribeiro
4e20ec6575 Removed a check from the webhook 2024-05-21 12:04:31 +01:00
Tiago Ribeiro
836b674076 Added some changes to the propagate corporate changes 2024-05-21 11:21:14 +01:00
Tiago Ribeiro
5086c6fb09 Solved a visual bug 2024-05-21 11:09:36 +01:00
Tiago Ribeiro
489c9c3b7e Possibly solved part of the issue with speaking 2024-05-20 21:28:45 +01:00
Tiago Ribeiro
e3ded29e77 Merge branch 'develop' 2024-05-20 21:09:43 +01:00
Tiago Ribeiro
16419a5584 Fixed a bug introduced on the last one 2024-05-20 11:23:52 +01:00
Tiago Ribeiro
3e3b24cc30 Solved a bug for level test 2024-05-20 11:18:46 +01:00
Tiago Ribeiro
841698ba10 Updated the profile to also have the focus in it 2024-05-20 11:13:09 +01:00
Tiago Ribeiro
d50904611c Added a missing space 2024-05-16 15:42:13 +01:00
Tiago Ribeiro
e77fd16d26 Added a space to it 2024-05-16 15:03:31 +01:00
Tiago Ribeiro
649f24e4ae Updated the showcase 2024-05-16 14:51:19 +01:00
Tiago Ribeiro
2f0cbfe74e Removed the billing details modal 2024-05-16 14:30:44 +01:00
Tiago Ribeiro
d022bd078a Updated the currencies to have OMR as well 2024-05-16 13:44:27 +01:00
Tiago Ribeiro
c18afee9ad Updated the packages 2024-05-16 13:34:18 +01:00
Tiago Ribeiro
a65b72adad Updated the payment integration to be dynamic 2024-05-16 13:30:38 +01:00
Tiago Ribeiro
e13aea9f7d Updated the table 2024-05-15 23:41:45 +01:00
Tiago Ribeiro
2920fa7f3a Updated the payment to work with Paymob 2024-05-15 22:59:51 +01:00
Tiago Ribeiro
7af96ecccc Created a webhook to allow the transaction to be completed 2024-05-15 00:25:44 +01:00
Tiago Ribeiro
70716b3483 Merge branch 'develop' into feature/ENCOA-42/update-payment-system-paymob 2024-05-13 11:02:35 +01:00
Tiago Ribeiro
d7bb64e7e0 Merge branch 'main' into develop 2024-05-13 11:02:16 +01:00
Tiago Ribeiro
dd19b5746c Updated the times listened to not be global 2024-05-13 11:01:37 +01:00
Tiago Ribeiro
f967282f71 Started implementing the Paymob integration 2024-05-13 10:38:05 +01:00
Tiago Ribeiro
8b2459c304 ENCOA-37: Added the ability for users to download a list of the shown users 2024-05-08 15:46:24 +01:00
Tiago Ribeiro
72fb934d4f Updated the propagated changes to also affect expiry date changes for corporates 2024-05-07 23:53:15 +01:00
Tiago Ribeiro
ed0b8bcb99 ENCOA-36: Allow Corporate Users to select invitation expiry date lower than theirs 2024-05-07 11:42:05 +01:00
Tiago Ribeiro
6f211d8435 Added the corporate user balance to the User Card 2024-05-07 09:48:49 +01:00
Tiago Ribeiro
b59589b855 ENCOA-26: Student profile count stats was invalid 2024-05-07 09:07:17 +01:00
Tiago Ribeiro
db20feaa00 Added the ID of the multiple choice question 2024-05-07 08:48:16 +01:00
Tiago Ribeiro
8fc2cf571e Disabled the Play Again for admins 2024-05-07 08:37:09 +01:00
Tiago Ribeiro
3128fea8c9 Merge branch 'develop' 2024-05-05 12:03:36 +01:00
Tiago Ribeiro
0e53b4a454 Added the ability to view archived assignments and unarchive them 2024-05-05 12:02:53 +01:00
Tiago Ribeiro
cbb61d18fe Made sure to only send the e-mail for previously invited users instead of also creating a new code 2024-04-30 14:59:55 +01:00
Tiago Ribeiro
dff51cf6ea Merged from develop 2024-04-28 20:20:25 +01:00
Tiago Ribeiro
15dbadcc53 Solved a small bug 2024-04-28 20:19:21 +01:00
Tiago Ribeiro
624a3fb88e Created a discount system related to the user's e-mail address and applied to the packages 2024-04-26 20:41:46 +01:00
Tiago Ribeiro
00feee2179 Disabled the short length exams 2024-04-24 08:53:53 +01:00
Tiago Ribeiro
0f8f9bc05b Added a button to review the exam from the selected module forward 2024-04-21 21:29:43 +01:00
Tiago Ribeiro
f76b7578a6 Disabled the editing of the country manager of a corporate from the payment record 2024-04-21 12:22:02 +01:00
Tiago Ribeiro
1a17689cd2 Updated the code to name the field companyArabName and made it so it returns it when arabic 2024-04-21 00:37:08 +01:00
Tiago Ribeiro
a958e2ff0d Added a field for the agent where they can put their arab name 2024-04-20 16:01:35 +01:00
Tiago Ribeiro
36b861266f ENCOA-18: Improve the loading of the company names on the Group and Users lists 2024-04-18 16:03:09 +01:00
Tiago Ribeiro
771262fc18 ENCOA-16: Added a creation date to the Code List 2024-04-18 14:18:29 +01:00
Tiago Ribeiro
0f03ce95e7 Remove a console.log 2024-04-18 11:27:40 +01:00
Tiago Ribeiro
6a6e010daa ENCOA-13: Add filter for "In Use" and "Unused" for the Code List
ENCOA-15: Checkbox to select/unselect all for the Code List
2024-04-18 09:40:47 +01:00
Tiago Ribeiro
13496387c4 ENCOA-6: Updated the Linked Corporate column in the Group List 2024-04-11 11:29:08 +01:00
Tiago Ribeiro
4ecb21e0ae ENCOA-4: Added the ability to filter by Creator on the Code List 2024-04-11 11:23:13 +01:00
Tiago Ribeiro
8663fe13bd Prevented users from deleted in use codes 2024-04-11 10:56:40 +01:00
Tiago Ribeiro
de4638bc46 - ENCOA-3: Added the ability to delete multiple codes at once;
- ENCOA-5 Added a column for the Creator on the code list;
2024-04-11 10:22:02 +01:00
Tiago Ribeiro
c9740fe8ee ENCOA-1: Added expired teachers on the Admin dashboard 2024-04-11 09:53:34 +01:00
Tiago Ribeiro
9b9b67c6cd Added a "Linked Corporate" column to the Groups list 2024-04-05 09:04:40 +01:00
Tiago Ribeiro
fe2abaacae Added a list for codes, for users to delete unused ones 2024-04-04 23:05:12 +01:00
João Ramos
11e2ea3249 Merged in bug-fixing-2-Abril (pull request #51)
Minor change regarding user id on the pdf footer

Approved-by: Tiago Ribeiro
2024-04-04 08:25:39 +00:00
Tiago Ribeiro
2de4b7c715 Show the Company Name of the Teachers and students that are linked in the User List View 2024-04-03 22:46:31 +01:00
Joao Ramos
a8ffebe944 Minor change regarding user id on the pdf footer 2024-04-02 22:19:32 +01:00
Tiago Ribeiro
9ab7c3ed59 Merge branch 'develop' 2024-04-02 14:53:48 +01:00
Tiago Ribeiro
f374d91ef8 Solved an issue where the company name of country managers wasn't able to be updated 2024-04-02 10:53:34 +01:00
Tiago Ribeiro
62ecc4e395 Added the ability to edit the options of a Level Exam 2024-04-02 10:32:59 +01:00
Tiago Ribeiro
46764cacfa Updated the stats 2024-04-02 00:25:49 +01:00
Tiago Ribeiro
0b9e1bd734 Merged in develop (pull request #50)
Update 31/02/2024
2024-03-31 22:33:55 +00:00
João Ramos
bddb2ed18e Merged in bug-fixing-30-MAR (pull request #49)
Added missing cards

Approved-by: Tiago Ribeiro
2024-03-31 22:33:15 +00:00
Joao Ramos
e8fbeff77a Added missing cards 2024-03-30 14:43:08 +00:00
Tiago Ribeiro
b64593df90 Solved a problem with the record page not being able to reload 2024-03-28 12:28:24 +00:00
Tiago Ribeiro
2657cb409c Solved a bug where it would not send the correct link to the e-mail 2024-03-28 08:21:45 +00:00
Tiago Ribeiro
329ed573b3 Improved a bit more the error prevention 2024-03-26 21:48:55 +00:00
Tiago Ribeiro
bb7558afb8 Updated the writing evaluation to use the different endpoints 2024-03-26 21:46:53 +00:00
Tiago Ribeiro
259ed03ee4 Solved a bug where users could change their e-mail to another user's email 2024-03-26 16:13:39 +00:00
Tiago Ribeiro
bf6c805487 Updated the Group List to show the name of the corporate 2024-03-26 14:03:58 +00:00
Tiago Ribeiro
1086e78936 Updated the MatchSentences exercise to work better now 2024-03-26 00:42:39 +00:00
Tiago Ribeiro
7d0d930140 Updated the Listening partial to not show the introductory audio 2024-03-25 01:34:58 +00:00
Tiago Ribeiro
f02fff55e7 Solved the exercise counter bug 2024-03-25 01:16:56 +00:00
Tiago Ribeiro
08e71c4dd8 Updated the ID of the matchSentences when generating 2024-03-25 00:47:03 +00:00
João Ramos
6f5a74844c Merged in pdf-bullet-points (pull request #48)
Added support for PDF bulletpoints

Approved-by: Tiago Ribeiro
2024-03-24 23:51:53 +00:00
João Ramos
c4011cd456 Merged develop into pdf-bullet-points 2024-03-24 23:43:42 +00:00
Joao Ramos
5ef2568aa5 Added support for PDF bulletpoints 2024-03-24 23:42:02 +00:00
Tiago Ribeiro
6d817e6d27 Added Match Sentences as a possible exercise type for Reading 2024-03-24 23:32:14 +00:00
Tiago Ribeiro
5decfb098d Removed the "Correct" and stuff from the Finish for the Writing and Speaking 2024-03-24 12:28:42 +00:00
Tiago Ribeiro
c2b6be4425 Solved the solution duplication bug 2024-03-24 12:22:52 +00:00
Tiago Ribeiro
f320fee416 This is better 2024-03-24 03:24:12 +00:00
Tiago Ribeiro
445e486cd2 Added a filter that should not exist but whatever 2024-03-24 03:23:01 +00:00
Tiago Ribeiro
ee26b50cf6 Helped solve a bug where it would get stuck 2024-03-24 03:18:00 +00:00
Tiago Ribeiro
22f2b43692 Prevented the bug where the application is crashing 2024-03-24 02:32:12 +00:00
Tiago Ribeiro
29b2c8b3b8 Updated the code to solve the double stats creation 2024-03-24 00:46:42 +00:00
Tiago Ribeiro
51cc1e3f36 Oops 2024-03-23 18:40:37 +00:00
Tiago Ribeiro
d9fce10538 Updated the level to be out of its total and not 9.0 2024-03-23 18:30:43 +00:00
Tiago Ribeiro
bd74313bd5 Updated the radial result accordingly 2024-03-23 18:28:12 +00:00
Tiago Ribeiro
18df890ef9 Updated the PDF report to show the level instead of the score 2024-03-23 17:16:52 +00:00
Tiago Ribeiro
13ebb9bbd8 Solved a bug where the speaking and interactive speaking were not being correctly evaluated 2024-03-23 15:43:25 +00:00
João Ramos
38c0c823e1 Merged in bug-fixing-19-MAR (pull request #47)
Bug fixing 19 MAR

Approved-by: Tiago Ribeiro
2024-03-22 11:17:43 +00:00
Tiago Ribeiro
b50e15d1d9 Merge branch 'develop' into bug-fixing-19-MAR 2024-03-22 11:15:30 +00:00
Tiago Ribeiro
969698d8b8 Updated the PDF report to give the value out of 9.0 2024-03-22 09:07:36 +00:00
Tiago Ribeiro
7d83ebc5c5 Maybe this helps I guess 2024-03-21 16:30:01 +00:00
Tiago Ribeiro
e99650ecd8 Removed unused console.logs 2024-03-21 11:28:09 +00:00
João Ramos
7287a9ce9a Merged develop into bug-fixing-19-MAR 2024-03-21 11:21:28 +00:00
Joao Ramos
8cc7e6a57d Removed change setup for debug 2024-03-21 10:57:55 +00:00
Joao Ramos
0a24cb9978 Added PDF Manuals 2024-03-21 10:56:56 +00:00
Joao Ramos
a5c1286748 Removed decimals from export pdf 2024-03-21 10:48:46 +00:00
Tiago Ribeiro
06684a4900 Added the exam information to the ticket submission 2024-03-20 21:24:09 +00:00
Tiago Ribeiro
1823538058 Added a link for admins to go to the CMS 2024-03-20 12:46:55 +00:00
Joao Ramos
60ccc822b5 Fixed missing threshold 2024-03-19 19:22:28 +00:00
Joao Ramos
9abd69c5e5 Stats and Records are now hidden for country managers 2024-03-19 19:01:37 +00:00
Joao Ramos
2667891bdd If the subscrition is unlimited, do not provide the link 2024-03-19 18:57:10 +00:00
Tiago Ribeiro
65485a0d1f Solved a problem with the API call 2024-03-13 23:31:11 +00:00
Tiago Ribeiro
74dd96d000 Applied the same fix for other pages 2024-03-13 09:21:16 +00:00
Tiago Ribeiro
49ee3c45e5 Merge branch 'develop' 2024-03-13 09:19:26 +00:00
Tiago Ribeiro
49d2680a07 Solved a bug with the redirection 2024-03-13 09:18:14 +00:00
Tiago Ribeiro
9dac7fd19e Merge branch 'develop' 2024-03-12 19:06:18 +00:00
Tiago Ribeiro
528299571c Fixed a small bug 2024-03-12 19:05:44 +00:00
Tiago Ribeiro
dcc630b8e5 Merge branch 'develop' 2024-03-12 17:56:57 +00:00
João Ramos
be5125e5b0 Merged in bug-fixing-11-MAR (pull request #46)
Bug fixing 11 MAR

Approved-by: Tiago Ribeiro
2024-03-12 17:51:36 +00:00
Joao Ramos
0adf45c6ad Added propagate status changes 2024-03-12 15:52:10 +00:00
Joao Ramos
d9b93a3470 Added default value for postal_code 2024-03-11 17:13:48 +00:00
Joao Ramos
83e4173750 removed broken debugger 2024-03-11 17:05:20 +00:00
Joao Ramos
e2d5f6ac9d Removed debugger; 2024-03-11 17:04:10 +00:00
Joao Ramos
37c3c6f7f4 Updated redirect implementation 2024-03-11 17:00:38 +00:00
Joao Ramos
3b4dfb9648 Merge branch 'develop' 2024-03-11 15:45:49 +00:00
Joao Ramos
330c177ff9 Paypal integration improvements 2024-03-07 11:18:48 +00:00
Tiago Ribeiro
0cff310354 Turned the e-mails to be dependent on the environment 2024-03-07 10:21:13 +00:00
Joao Ramos
87a1d7c288 Minor imporvements and logs 2024-03-06 18:59:11 +00:00
Joao Ramos
8e1fe15a24 Fixed loading blocking the paypal box 2024-03-06 11:38:08 +00:00
Joao Ramos
c95c0eff9b Removed vault: true from paypal as requested 2024-03-06 11:03:31 +00:00
Joao Ramos
eaf94f458a Added console.error on RAAS 2024-03-05 18:18:26 +00:00
Tiago Ribeiro
ba85596e79 Merged in develop (pull request #45)
Update - 05/03/2024
2024-03-05 17:29:33 +00:00
João Ramos
c6a478c406 Merged in feature-paypal-simple (pull request #44)
Improvements on the Paypal Integration

Approved-by: Tiago Ribeiro
2024-03-05 16:04:28 +00:00
Joao Ramos
2a27fbd02f Merge branch 'develop' into feature-paypal-simple 2024-03-05 14:45:51 +00:00
Joao Ramos
a86ed9f76c Added RAAS api and tracking
Improvements on paypal UI
2024-03-05 14:37:02 +00:00
Tiago Ribeiro
20b52d049d Updated the default to be 100 2024-03-04 00:14:04 +00:00
Tiago Ribeiro
165e33b188 Updated an error with the session 2024-03-03 12:01:05 +00:00
João Ramos
04cbcbc4cb Merged in feature-homepage-contacts-languages (pull request #43)
Feature homepage contacts languages

Approved-by: Tiago Ribeiro
2024-02-29 23:54:04 +00:00
Tiago Ribeiro
2feb9223c1 Merged develop into feature-homepage-contacts-languages 2024-02-29 23:53:42 +00:00
Joao Ramos
02d2d07f6c Paypal tab is now only displayed to admin or devs 2024-02-29 21:33:47 +00:00
Joao Ramos
ecd66d61f2 Updated exam abandon message 2024-02-29 21:33:07 +00:00
Joao Ramos
424b72efaf Uppercased the country code to prevent API errors 2024-02-29 18:35:30 +00:00
Joao Ramos
79e51d6294 Added support for the homepage languages 2024-02-29 18:26:41 +00:00
Tiago Ribeiro
773480875f Extremely wrong approach, but want to test 2024-02-28 22:44:18 +00:00
Joao Ramos
96d1b85f56 Removed HeaderGroup types on CSV export 2024-02-28 22:34:58 +00:00
Joao Ramos
cf20920fd8 Updated possible HeaderGroup types on CSV export 2024-02-28 22:29:07 +00:00
Joao Ramos
4114971244 Prevented date from wrapping 2024-02-28 20:01:43 +00:00
João Ramos
bee20388d9 Merged in feature-paypal-payments (pull request #40)
Added integration for paypal payments
2024-02-27 14:20:35 +00:00
João Ramos
bd97529658 Merged in feature-tickets-with-admin (pull request #42)
Added corporate display to Tickets table

Approved-by: Tiago Ribeiro
2024-02-27 14:17:43 +00:00
Tiago Ribeiro
d3c24d738c Merged develop into feature-tickets-with-admin 2024-02-27 14:17:28 +00:00
João Ramos
eac43a160d Merged in tickets-source (pull request #41)
Added Approach to accept ticket source

Approved-by: Tiago Ribeiro
2024-02-27 14:17:06 +00:00
Joao Ramos
24c3f506c6 Added corporate display to Tickets table 2024-02-26 23:27:10 +00:00
Joao Ramos
3e13ed5830 Added Approach to accept ticket source 2024-02-26 19:09:42 +00:00
Joao Ramos
9b5ff70037 Added search text to Paypal payments
Updated CSV Downlaod
Reordered the UI of the page
2024-02-26 18:46:21 +00:00
Joao Ramos
d7f1a4f6b2 Added integration for paypal payments 2024-02-25 16:59:21 +00:00
Tiago Ribeiro
b663e5c706 Updated the labels for the level 2024-02-24 22:35:13 +00:00
Tiago Ribeiro
efb99b31f2 Merge branch 'develop' 2024-02-23 15:20:10 +00:00
Tiago Ribeiro
03882d2a7e Improved some visual things requested by the client 2024-02-23 15:18:45 +00:00
Tiago Ribeiro
a3087567ea Merge branch 'develop' 2024-02-23 15:07:42 +00:00
João Ramos
e37afd5bbc Merged in feature-homepage-users (pull request #39)
Added API endpoints for agents load for the homepage

Approved-by: Alvaro Doria
2024-02-19 14:35:29 +00:00
João Ramos
cf5e827ca7 Merged in feature-archive-assignments (pull request #38)
Added approach to archive past assignments

Approved-by: Alvaro Doria
2024-02-19 14:35:20 +00:00
João Ramos
bfa9d039e2 Merged in bug-user-level-progress (pull request #37)
Bug user level progress

Approved-by: Alvaro Doria
2024-02-19 14:35:08 +00:00
Joao Ramos
62b915fbc1 Added API endpoints for agents load for the homepage 2024-02-18 18:04:54 +00:00
Joao Ramos
cdfafb3eea Added approach to archive past assignments 2024-02-18 11:46:08 +00:00
Joao Ramos
29cae5c3d2 Stats for Level exam are now being properly calculated 2024-02-18 11:09:25 +00:00
Joao Ramos
04f97b62c3 Filtered out level from students history display 2024-02-17 14:22:46 +00:00
Joao Ramos
52d309e7f4 Fixed NaN display on level progress 2024-02-17 10:43:55 +00:00
Tiago Ribeiro
dbf5b17f64 Merge branch 'develop' 2024-02-15 16:10:06 +00:00
Tiago Ribeiro
703fb0df5f Updated the generic Listening intro 2024-02-15 14:07:30 +00:00
Tiago Ribeiro
be4d2de76f Updated the module title to keep in mind the previous time spent 2024-02-14 11:31:35 +00:00
Tiago Ribeiro
44c61c2e5d Fixed a bug with the module evaluation, no idea why it was happening 2024-02-14 11:19:55 +00:00
João Ramos
764064bc28 Merged in feature-ticket-badge (pull request #36)
Added a badge with the amount of pending tickets assigned to the user

Approved-by: Tiago Ribeiro
2024-02-14 00:06:27 +00:00
Tiago Ribeiro
d87de9fea9 Updated the styling a little bit 2024-02-14 00:05:08 +00:00
Joao Ramos
b63ba3f316 Added a badge with the amount of pending tickets assigned to the user 2024-02-13 17:29:55 +00:00
Tiago Ribeiro
64b1d9266e Updated the Speaking Generation to save the topic as well 2024-02-13 16:26:56 +00:00
Tiago Ribeiro
b7cd1fb141 Merge branch 'feature/user-choose-topics' into develop 2024-02-13 16:07:12 +00:00
Tiago Ribeiro
30cb2f460c Changed the label on the Save button 2024-02-13 16:05:13 +00:00
Tiago Ribeiro
6a38b7a32e Updated the exam selection to get exams related to the user's topic preference 2024-02-13 16:04:46 +00:00
Joao Ramos
2a1b5236ee Revert "Updated the label for the Tickets button"
This reverts commit a99f6fd20e.
2024-02-13 15:43:24 +00:00
Joao Ramos
a99f6fd20e Updated the label for the Tickets button 2024-02-13 15:37:37 +00:00
Tiago Ribeiro
c0c9d22864 Added the ability for a user to select their preferred topics 2024-02-13 11:39:19 +00:00
Tiago Ribeiro
718782cfd5 Merged in develop (pull request #35)
Updated the main branch - 13/02/24
2024-02-13 00:52:45 +00:00
Tiago Ribeiro
f643430068 Merge branch 'develop' into feature/user-choose-topics 2024-02-13 00:45:56 +00:00
João Ramos
2823af7ef8 Merged in privacy-policy (pull request #33)
Added checkbox for accepted terms

Approved-by: Tiago Ribeiro
2024-02-13 00:44:55 +00:00
Tiago Ribeiro
57116f50e8 Hard coded the website link because there are some problems with ENV variables in the frontend 2024-02-13 00:44:16 +00:00
Tiago Ribeiro
e382a09ae8 Added the key "topic" to Writing, Speaking and Interactive Speaking exercises 2024-02-13 00:42:09 +00:00
João Ramos
b4c7c9a911 Merged in contact-us-form (pull request #32)
Contact Us support

Approved-by: Tiago Ribeiro
2024-02-13 00:40:23 +00:00
Tiago Ribeiro
86e920f102 Merged develop into contact-us-form 2024-02-13 00:39:59 +00:00
João Ramos
6f12a4a1db Merged in email-notification-ticket-close (pull request #34)
Added an email notification when the ticket is submitted
2024-02-13 00:39:39 +00:00
Tiago Ribeiro
a27a3c1fb0 Merged develop into contact-us-form 2024-02-13 00:38:56 +00:00
Joao Ramos
63618405bc Added an email notification when the ticket is submitted 2024-02-12 22:37:16 +00:00
João Ramos
7ab67fdf15 Merged develop into privacy-policy 2024-02-12 21:49:11 +00:00
Joao Ramos
17ec004a59 Added checkbox for accepted terms 2024-02-12 21:45:37 +00:00
Tiago Ribeiro
417bd7fecb Added tooltips to the student dashboard 2024-02-12 19:26:55 +00:00
Joao Ramos
e82895351d Published the tickets POST route 2024-02-12 18:18:25 +00:00
Tiago Ribeiro
4802310474 Added the ability to delete already finished assignments 2024-02-12 11:15:04 +00:00
Tiago Ribeiro
dc3373be6a Added the ability to choose a difficulty when generating an exam 2024-02-10 13:26:08 +00:00
Tiago Ribeiro
2e894622d0 Updated users to get exams related to their level 2024-02-10 09:10:52 +00:00
Tiago Ribeiro
1895b9e183 Disabled the navigation on the mobile menu 2024-02-09 16:48:25 +00:00
Tiago Ribeiro
03f78ceb46 Added the same functionality to the Assignments 2024-02-09 13:23:35 +00:00
Tiago Ribeiro
872cc62fe4 - Added the ability for a student/developer to choose a gender for a speaking instructor;
- Made it so, if chosen, the user will only get speaking exams with their chosen gender;
- Added the ability for speaking exams to select a gender when generating;
2024-02-09 12:14:47 +00:00
Joao Ramos
ce7032c8a7 Added agent as a possible idsplay on the list for ticket 2024-02-08 19:49:53 +00:00
Tiago Ribeiro
71f07af2eb Added "instructorGender" key to Speaking exams 2024-02-08 14:04:52 +00:00
Tiago Ribeiro
89250fb98e Merge branch 'develop' into feature/exam-session-persistence 2024-02-08 11:55:16 +00:00
Tiago Ribeiro
b09fe79cb7 Updated the InteractiveSpeaking to also work with the session persistence 2024-02-08 11:43:01 +00:00
Joao Ramos
870ed57166 Swapped the grade for the level on the level display for an exam 2024-02-07 20:35:30 +00:00
Tiago Ribeiro
2a9e204041 Updated the Speaking to also work the with exam session persistence 2024-02-07 17:15:41 +00:00
João Ramos
00f6aaf058 Merged in feature-pdf-version (pull request #31)
PDF Versioning

Approved-by: Tiago Ribeiro
2024-02-06 20:45:13 +00:00
Joao Ramos
044a4f91aa Added PDF version to footer 2024-02-06 19:19:03 +00:00
Tiago Ribeiro
65fe1ec8ed Made it so it currently is possible to save the progress on the Writing exercise as well 2024-02-06 15:51:55 +00:00
Tiago Ribeiro
779fb76b8b Solved some issues related to the listening loading 2024-02-06 15:24:51 +00:00
Tiago Ribeiro
4ec439492e Added the capability for users to resume their previously stopped sessions 2024-02-06 14:44:22 +00:00
Tiago Ribeiro
c4b61c4787 - Adapted the exam to store all of its information to Zustand;
- Made it so, every time there is a change or every X seconds, it saves the session;
2024-02-06 12:34:45 +00:00
Joao Ramos
934394b17f Added approach to allow versioning of PDFs (Requires env var!) 2024-02-05 20:01:50 +00:00
Tiago Ribeiro
8baa25c445 Added a Scroll To Top function 2024-02-05 17:59:46 +00:00
Tiago Ribeiro
f6166ca9e1 Added an "instructions" panel to the Listening before it actually starts 2024-02-05 14:52:35 +00:00
Tiago Ribeiro
e6017854fd From now on, when a student accepts an invite from a corporate, they are removed from previous corporate groups 2024-02-05 10:40:39 +00:00
Tiago Ribeiro
0bd8b0ab24 Solved a small display issue 2024-02-05 10:07:04 +00:00
Tiago Ribeiro
401d212d85 Added a better way to distinguish the options 2024-02-04 00:32:34 +00:00
Tiago Ribeiro
9383929ebb Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-02-04 00:23:59 +00:00
Tiago Ribeiro
5dcab23fdb Extracted the PayPalScriptProvider 2024-02-03 23:40:31 +00:00
Joao Ramos
d111be2f70 Fixed the assignments export based on unique exams. 2024-02-03 22:51:21 +00:00
Tiago Ribeiro
00c171b161 Merge branch 'develop' 2024-02-03 15:15:24 +00:00
João Ramos
53d3f843da Merged in stats-tooltip (pull request #30)
Added tooltip to stats screen
2024-02-03 15:14:49 +00:00
Tiago Ribeiro
8d7f312a83 Merged develop into stats-tooltip 2024-02-03 15:14:25 +00:00
Joao Ramos
6f11818876 Added tooltip to stats screen 2024-02-03 15:11:19 +00:00
Tiago Ribeiro
81bc4e7a0c Merge branch 'develop' 2024-02-03 15:02:49 +00:00
Tiago Ribeiro
48265a8e54 Reverted the name 2024-02-03 15:01:54 +00:00
Tiago Ribeiro
0053105dd3 Updated the use of the Desired Levels to be configurable 2024-02-03 14:46:35 +00:00
Tiago Ribeiro
846d829d10 Had left some code behind? 2024-02-03 12:40:27 +00:00
Tiago Ribeiro
c0c3e37568 Aligned the selection text to the left;\nUpdated the service account for the Firebase. 2024-02-01 14:00:34 +00:00
Tiago Ribeiro
a872190e1b Turned it bold 2024-01-31 23:45:36 +00:00
Tiago Ribeiro
147a450be2 Changed the Level test finish screen to only show the grade 2024-01-31 23:45:09 +00:00
Tiago Ribeiro
908ce5b5b9 Added an Invite list to the payment due page as well 2024-01-31 23:42:46 +00:00
Tiago Ribeiro
0ec62c107c Corrected a bug where a corporate with unlimited subscription could not generate multiple codes 2024-01-31 23:00:43 +00:00
Tiago Ribeiro
626655d0d0 Merge branch 'develop' 2024-01-31 11:46:07 +00:00
Tiago Ribeiro
16eeba76fd Added the tickets link to the mobile menu 2024-01-31 11:45:23 +00:00
Tiago Ribeiro
85729116e7 Updated the tickets to allow agents to also view theirs 2024-01-31 10:47:14 +00:00
Tiago Ribeiro
2de9636c8b Merged in feature/ticket-system (pull request #29)
Features: Ticket and Invite systems
2024-01-30 18:27:49 +00:00
Tiago Ribeiro
bcad5b5646 Added date sorting to the ticket list 2024-01-30 18:02:21 +00:00
Tiago Ribeiro
4e40dc9c8c Prevented the creation of multiple equal invites 2024-01-30 17:50:48 +00:00
Tiago Ribeiro
e3bcaf6b30 Merge branch 'develop' into feature/ticket-system 2024-01-29 21:03:47 +00:00
Tiago Ribeiro
a35c85545e Updated the code to set the participant's expiration date to use the corporate's one if it is better 2024-01-29 21:02:52 +00:00
Tiago Ribeiro
c4707d6426 Merge branch 'develop' into feature/ticket-system 2024-01-29 15:41:20 +00:00
Tiago Ribeiro
3564d0af6b Solved the same bug but for the e-mail 2024-01-29 15:40:50 +00:00
Tiago Ribeiro
e7acdb5858 Merge branch 'develop' into feature/ticket-system 2024-01-29 15:33:55 +00:00
Tiago Ribeiro
8bff64dd13 Solved a bug on the assignments because of the multiple exams 2024-01-29 15:31:41 +00:00
Tiago Ribeiro
2c4168a014 Created an e-mail type to be sent to the ticket reporter with the ticket's information 2024-01-29 13:19:12 +00:00
Tiago Ribeiro
800d04da37 Updated the login and register to transform the e-mail to lowercase 2024-01-29 09:53:47 +00:00
Tiago Ribeiro
b7b2718387 Merge branch 'develop' into feature/ticket-system 2024-01-29 09:51:50 +00:00
Tiago Ribeiro
a862e59574 Updated the login and register to transform the e-mail to lowercase 2024-01-29 09:51:11 +00:00
Tiago Ribeiro
688d8ba0b2 Created a simple invite system that notifies users via e-mail when a corporate uploads an Excel file with already registered students 2024-01-29 09:36:59 +00:00
Tiago Ribeiro
8b7e550a70 Created a new page for ticket handling as well as submission 2024-01-28 21:05:17 +00:00
João Ramos
cf1cb6f270 Merged in bug-fixing-27-jan-24 (pull request #28)
Bug fixing 27 jan 24
2024-01-28 18:47:10 +00:00
Tiago Ribeiro
476a6b0188 Merged develop into bug-fixing-27-jan-24 2024-01-28 18:46:54 +00:00
Tiago Ribeiro
01e55f970d Solved a bug where if two evaluations where too fast, they would overwrite each other 2024-01-28 18:10:56 +00:00
Joao Ramos
bca73dff2e Fixed userid 2024-01-27 20:10:15 +00:00
Joao Ramos
aef3800c08 Fixed issue with serialization on forgot password locally 2024-01-27 20:08:44 +00:00
Joao Ramos
a40c21ca53 Fixed table columns headers 2024-01-27 20:08:10 +00:00
Joao Ramos
34b1c7f25b Fixed footer paddings 2024-01-27 20:07:40 +00:00
Joao Ramos
7c641508ce Added user id to the footer of the report 2024-01-27 19:34:15 +00:00
Joao Ramos
4163076524 Added passportid to the table ; removed gender column from table 2024-01-27 19:27:28 +00:00
Joao Ramos
009c610033 Removed some candidate information 2024-01-27 19:26:48 +00:00
Joao Ramos
c05df7d6b7 Removed condition that blocked admins/devs from exporting a pdf for assignment 2024-01-27 19:12:05 +00:00
Joao Ramos
b881969bd4 Exporting a report from an user now allows access to the user data of the user that did the exam instead of the current session 2024-01-27 19:10:41 +00:00
Tiago Ribeiro
5e6af11156 Update the Group List to always allow editing for developers and admins 2024-01-26 19:13:44 +00:00
Tiago Ribeiro
c1162c5e88 Merge branch 'develop' 2024-01-26 16:33:55 +00:00
Tiago Ribeiro
213bdd0c8f Added the ability for developers to choose what screen they wanna see 2024-01-26 16:21:34 +00:00
Tiago Ribeiro
13401562fb Added the ability for assignments to use partial exams as well 2024-01-26 16:16:28 +00:00
Tiago Ribeiro
4e199931aa Solved another weird bug 2024-01-26 11:49:03 +00:00
Tiago Ribeiro
3eafc799ab Solved a quick bug 2024-01-26 11:41:31 +00:00
Tiago Ribeiro
9b87764afb Updated the code generator to only generate after the e-mails are sent 2024-01-25 12:14:12 +00:00
Tiago Ribeiro
a969e90c98 Added a confirmation when generating codes 2024-01-25 11:32:29 +00:00
Tiago Ribeiro
c38c1d9ff6 Removed the need for it to alway use Passport ID 2024-01-25 10:28:45 +00:00
Tiago Ribeiro
bcacbbdd15 Made the phone row optional 2024-01-25 10:19:06 +00:00
Tiago Ribeiro
fa481dc50e Merge branch 'develop' 2024-01-24 15:58:35 +00:00
Tiago Ribeiro
710c7931aa Updated the permissions to disallow corporate and teachers from editing other users 2024-01-24 15:40:05 +00:00
Tiago Ribeiro
d3f80603c4 Improved the Speaking Generation 2024-01-24 15:37:51 +00:00
Tiago Ribeiro
fea2d311ae Updated it so an agent can't edit the commission on a corporate 2024-01-24 15:24:45 +00:00
Tiago Ribeiro
5f475fb7a7 Finalized the Speaking exam generation 2024-01-24 00:37:54 +00:00
Tiago Ribeiro
bd0fab4c8f Updated and fixed part of the partial test generation 2024-01-23 11:45:32 +00:00
Tiago Ribeiro
74d3f30c93 Added the ability to choose between partial and full exams 2024-01-23 10:11:04 +00:00
Tiago Ribeiro
67c2e06575 Improved the Generation frontend 2024-01-22 23:34:42 +00:00
Tiago Ribeiro
506ee1e0e4 Updated the zIndex of the Select 2024-01-22 22:56:08 +00:00
Tiago Ribeiro
81943dbf42 Updated the module generation to allow for only certain parts to be made 2024-01-22 18:50:12 +00:00
Tiago Ribeiro
c868ea8795 Turned the nav to Gray when it is disabled 2024-01-22 14:18:08 +00:00
Tiago Ribeiro
cfde8ac9f0 Updated it so the Corporate is updated into Active when its payment is accepted 2024-01-21 20:35:35 +00:00
Tiago Ribeiro
8c1da3a84a Updated the UserCard to only show buttons to adequate users 2024-01-21 20:28:42 +00:00
Tiago Ribeiro
52143d2472 Solved a bug that caused some user's profile page to crash 2024-01-21 20:23:48 +00:00
Tiago Ribeiro
c7f303e410 Solved a bug 2024-01-21 20:20:08 +00:00
Tiago Ribeiro
da93b79c78 Solved an issue with sorting 2024-01-21 13:34:48 +00:00
Tiago Ribeiro
83b8ab7774 Allowed admins and others to download reports related to other users 2024-01-21 12:48:29 +00:00
Tiago Ribeiro
f6bb69f994 Updated the condition to close assignment: to be end date or when all students finish the assignment 2024-01-21 00:30:44 +00:00
Tiago Ribeiro
a97c40dc47 Merge branch 'develop' 2024-01-20 15:24:38 +00:00
Tiago Ribeiro
3de0357369 Oops, left an ID accidentally 2024-01-20 15:24:02 +00:00
Tiago Ribeiro
8eb8a7af46 Added a new card for the Corporate to show their user balance 2024-01-20 15:09:42 +00:00
Tiago Ribeiro
9773f1da72 Updated the user deletion to allow corporate to remove users from their groups, instead of deleting them 2024-01-20 13:33:22 +00:00
Tiago Ribeiro
2ef86344cd Updated the Assignment default start date to be the current time 2024-01-20 01:17:22 +00:00
Tiago Ribeiro
5e8b6f96bb Added a timezone selector to the Demographic Input 2024-01-20 01:15:12 +00:00
Tiago Ribeiro
b757cbbed7 Solved a date sorting bug 2024-01-20 01:09:03 +00:00
Tiago Ribeiro
4e08afb259 Merge branch 'develop' 2024-01-18 00:27:31 +00:00
Tiago Ribeiro
68069d118f Added correction visualizers for the Speaking transcript correction 2024-01-17 23:40:46 +00:00
Tiago Ribeiro
b7ae9fb837 Merge branch 'develop' 2024-01-17 14:29:33 +00:00
Tiago Ribeiro
9baf3109c9 Merge branch 'develop' 2024-01-14 07:44:23 +00:00
Tiago Ribeiro
8aca34e8b5 Merged in Tiago-Ribeiro/batchcodegeneratortsx-edited-online-with-1705170801981 (pull request #22)
BatchCodeGenerator.tsx edited online with Bitbucket
2024-01-13 18:34:01 +00:00
Tiago Ribeiro
aaaf7f646d BatchCodeGenerator.tsx edited online with Bitbucket 2024-01-13 18:33:26 +00:00
329 changed files with 40828 additions and 8876 deletions

2
.gitignore vendored
View File

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

View File

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

17
components.json Normal file
View File

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

View File

@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
const nextConfig = {
reactStrictMode: true,
reactStrictMode: false,
output: "standalone",
async headers() {
return [
@@ -8,7 +9,7 @@ const nextConfig = {
source: "/api/packages",
headers: [
{key: "Access-Control-Allow-Credentials", value: "false"},
{key: "Access-Control-Allow-Origin", value: "https://encoach.com"},
{key: "Access-Control-Allow-Origin", value: websiteUrl},
{
key: "Access-Control-Allow-Methods",
value: "GET",
@@ -19,6 +20,36 @@ const nextConfig = {
},
],
},
{
source: "/api/tickets",
headers: [
{key: "Access-Control-Allow-Credentials", value: "false"},
{key: "Access-Control-Allow-Origin", value: websiteUrl},
{
key: "Access-Control-Allow-Methods",
value: "POST,OPTIONS",
},
{
key: "Access-Control-Allow-Headers",
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
},
],
},
{
source: "/api/users/agents",
headers: [
{key: "Access-Control-Allow-Credentials", value: "false"},
{key: "Access-Control-Allow-Origin", value: websiteUrl},
{
key: "Access-Control-Allow-Methods",
value: "POST,OPTIONS",
},
{
key: "Access-Control-Allow-Headers",
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
},
],
},
];
},
};

11549
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,30 +11,40 @@
},
"dependencies": {
"@beam-australia/react-env": "^3.1.1",
"@headlessui/react": "^1.7.13",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@firebase/util": "^1.9.7",
"@headlessui/react": "^2.1.2",
"@mdi/js": "^7.1.96",
"@mdi/react": "^1.6.1",
"@next/font": "13.1.6",
"@paypal/paypal-js": "^7.1.0",
"@paypal/react-paypal-js": "^8.1.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.1.1",
"@react-pdf/renderer": "^3.1.14",
"@react-spring/web": "^9.7.4",
"@tanstack/react-table": "^8.10.1",
"@types/node": "18.13.0",
"@types/react": "18.0.27",
"@types/react-dom": "18.0.10",
"axios": "^1.3.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@use-gesture/react": "^10.3.1",
"axios": "^1",
"axios-cache-interceptor": "^1",
"bcrypt": "^5.1.1",
"chart.js": "^4.2.1",
"clsx": "^1.2.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"countries-list": "^3.0.1",
"country-codes-list": "^1.6.11",
"currency-symbol-map": "^5.1.0",
"daisyui": "^3.1.5",
"eslint": "8.33.0",
"eslint-config-next": "13.1.6",
"exceljs": "^4.4.0",
"express-handlebars": "^7.1.2",
"firebase": "9.19.1",
"firebase-admin": "^11.10.1",
"firebase-scrypt": "^2.2.0",
"formidable": "^3.5.0",
"formidable-serverless": "^1.1.1",
"framer-motion": "^9.0.2",
@@ -43,7 +53,8 @@
"lodash": "^4.17.21",
"moment": "^2.29.4",
"moment-timezone": "^0.5.44",
"next": "13.1.6",
"mongodb": "^6.8.1",
"next": "^14.2.5",
"nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0",
"primeicons": "^6.0.1",
@@ -58,7 +69,7 @@
"react-diff-viewer": "^3.1.1",
"react-dom": "18.2.0",
"react-firebase-hooks": "^5.1.1",
"react-icons": "^4.8.0",
"react-icons": "^5.3.0",
"react-lineto": "^3.3.0",
"react-media-recorder": "1.6.5",
"react-phone-number-input": "^3.3.6",
@@ -66,12 +77,15 @@
"react-select": "^5.7.5",
"react-string-replace": "^1.1.0",
"react-toastify": "^9.1.2",
"react-tooltip": "^5.27.1",
"react-xarrows": "^2.0.2",
"read-excel-file": "^5.7.1",
"short-unique-id": "^5.0.2",
"short-unique-id": "5.0.2",
"stripe": "^13.10.0",
"swr": "^2.1.3",
"swr": "^2.2.5",
"tailwind-merge": "^2.5.2",
"tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss-animate": "^1.0.7",
"typescript": "4.9.5",
"use-file-picker": "^2.1.0",
"uuid": "^9.0.0",
@@ -79,6 +93,7 @@
"zustand": "^4.3.6"
},
"devDependencies": {
"@simbathesailor/use-what-changed": "^2.0.0",
"@types/blob-stream": "^0.1.33",
"@types/formidable": "^3.4.0",
"@types/howler": "^2.2.11",
@@ -94,7 +109,6 @@
"autoprefixer": "^10.4.13",
"husky": "^8.0.3",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.4",
"types/": "paypal/react-paypal-js"
"tailwindcss": "^3.2.4"
}
}

BIN
public/audio/error.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
public/manuals/student.pdf Normal file

Binary file not shown.

BIN
public/manuals/teacher.pdf Normal file

Binary file not shown.

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

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

After

Width:  |  Height:  |  Size: 535 B

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

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

View File

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

View File

@@ -1,5 +1,5 @@
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
import {FormEvent, useState} from "react";
import {FormEvent, useEffect, useState} from "react";
import countryCodes from "country-codes-list";
import {RadioGroup} from "@headlessui/react";
import Input from "./Low/Input";
@@ -12,6 +12,8 @@ import {KeyedMutator} from "swr";
import CountrySelect from "./Low/CountrySelect";
import GenderInput from "@/components/High/GenderInput";
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
import TimezoneSelect from "./Low/TImezoneSelect";
import moment from "moment";
interface Props {
user: User;
@@ -19,13 +21,18 @@ interface Props {
}
export default function DemographicInformationInput({user, mutateUser}: Props) {
const [country, setCountry] = useState<string>();
const [phone, setPhone] = useState<string>();
const [country, setCountry] = useState(user.demographicInformation?.country);
const [phone, setPhone] = useState(user.demographicInformation?.phone);
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
const [gender, setGender] = useState<Gender>();
const [employment, setEmployment] = useState<EmploymentStatus>();
const [position, setPosition] = useState<string>();
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
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 [commercialRegistration, setCommercialRegistration] = useState<string>();
@@ -43,6 +50,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
employment: user.type === "corporate" ? undefined : employment,
position: user.type === "corporate" ? position : undefined,
passport_id,
timezone,
},
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
})
@@ -81,7 +89,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} />
</div>
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} value={phone} placeholder="Enter phone number" required />
</div>
{user.type === "student" && (
<Input
@@ -94,9 +102,15 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
required
/>
)}
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Timezone</label>
<TimezoneSelect value={timezone} onChange={setTimezone} />
</div>
<GenderInput value={gender} onChange={setGender} />
{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} />}
</form>

View File

@@ -14,6 +14,7 @@ import {useEffect, useState} from "react";
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
import {toast} from "react-toastify";
import Button from "./Low/Button";
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
interface Props {
user: User;
@@ -36,7 +37,7 @@ export default function Diagnostic({onFinish}: Props) {
};
const selectExam = () => {
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true));
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true, "partial"));
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
@@ -90,111 +91,17 @@ export default function Diagnostic({onFinish}: Props) {
</div>
</div>
</div>
<div className="flex flex-col items-center justify-center gap-8 w-full">
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
<div className="flex flex-col gap-32 w-full mb-20">
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16 mb-24">
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Reading</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsBook className="text-ielts-reading" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.reading === -1 ? "Select your reading level" : `Level ${levels.reading}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, reading: x}))}
className="w-full py-4 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Listening</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsHeadphones className="text-ielts-listening" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.listening === -1 ? "Select your listening level" : `Level ${levels.listening}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, listening: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Writing</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsPen className="text-ielts-writing" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.writing === -1 ? "Select your writing level" : `Level ${levels.writing}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, writing: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Speaking</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsMegaphone className="text-ielts-speaking" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.speaking === -1 ? "Select your speaking level" : `Level ${levels.speaking}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, speaking: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
<ModuleLevelSelector levels={levels} setLevels={setLevels} />
</div>
<div className="flex flex-col items-center justify-center gap-8 w-full mb-44">
<h2 className="font-semibold text-xl">What is your desired IELTS level?</h2>
<ModuleLevelSelector levels={desiredLevels} setLevels={setDesiredLevels} />
</div>
<div className="md:self-end flex -md:flex-col justify-between w-full gap-8 absolute bottom-8 left-0 px-4 md:px-8">
<div className="w-full tooltip" data-tip="Your screen size is too small to perform a diagnostic test">
<Button
@@ -224,7 +131,5 @@ export default function Diagnostic({onFinish}: Props) {
</Button>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,239 @@
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import reactStringReplace from "react-string-replace";
import { CommonProps } from "..";
import Button from "../../Low/Button";
import { v4 } from "uuid";
import MCDropdown from "./MCDropdown";
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
id,
type,
prompt,
solutions,
text,
words,
userSolutions,
variant,
onNext,
onBack,
}) => {
const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
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 excludeWordMCType = (x: any) => {
return typeof x === "string" ? x : (x as { letter: string; word: string });
};
useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
let correctWords: any;
if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
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 total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers!.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
if (!solution) return false;
const option = correctWords!.find((w: any) => {
if (typeof w === "string") {
return w.toLowerCase() === x.solution.toLowerCase();
} else if ("letter" in w) {
return w.letter.toLowerCase() === x.solution.toLowerCase();
} else {
return w.id.toString() === x.id.toString();
}
});
if (!option) return false;
if (typeof option === "string") {
return solution.toLowerCase() === option.toLowerCase();
} else if ("letter" in option) {
return solution.toLowerCase() === option.word.toLowerCase();
} else if ("options" in option) {
return option.options[solution as keyof typeof option.options] == x.solution;
}
return false;
}).length;
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing };
};
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
const renderLines = useCallback(
(line: string) => {
return (
<div className="text-xl leading-5" key={v4()} ref={dropdownRef}>
{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 transition duration-300 ease-in-out my-1 px-5 py-2 text-center w-fit",
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
);
const currentSelection = words.find((x) => {
if (typeof x !== "string" && "id" in x) {
return (x as FillBlanksMCOption).id.toString() == id.toString();
}
return false;
}) as FillBlanksMCOption;
return variant === "mc" ? (
<MCDropdown
id={id}
options={currentSelection.options}
onSelect={(value) => onSelection(id, value)}
selectedValue={userSolution?.solution}
className="inline-block py-2 px-1"
width={220}
isOpen={openDropdownId === id}
onToggle={()=> setOpenDropdownId(prevId => prevId === id ? null : id)}
/>
) : (
<input
className={styles}
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
value={userSolution?.solution}
/>
);
})
}
</div >
);
},
[variant, words, answers, openDropdownId],
);
const memoizedLines = useMemo(() => {
return text.split("\\n").map((line, index) => (
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
{renderLines(line)}
<br />
</p>
));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, variant, renderLines]);
const onSelection = (questionID: string, value: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]);
};
useEffect(() => {
if (variant === "mc") {
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;

View File

@@ -5,6 +5,8 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
import dynamic from "next/dynamic";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
import {downloadBlob} from "@/utils/evaluation";
import axios from "axios";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
@@ -14,31 +16,90 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
export default function InteractiveSpeaking({
id,
title,
text,
first_title,
second_title,
examID,
type,
prompts,
updateIndex,
userSolutions,
onNext,
onBack,
}: InteractiveSpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
const [promptIndex, setPromptIndex] = useState(0);
const [answers, setAnswers] = useState<{prompt: string; blob: string}[]>([]);
const [answers, setAnswers] = useState<{prompt: string; blob: string; questionIndex: number}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const back = async () => {
setIsLoading(true);
const answer = await saveAnswer(questionIndex);
if (questionIndex - 1 >= 0) {
setQuestionIndex(questionIndex - 1);
setIsLoading(false);
return;
}
setIsLoading(false);
onBack({
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 100, total: 100, missing: 0},
type,
});
};
const next = async () => {
setIsLoading(true);
const answer = await saveAnswer(questionIndex);
if (questionIndex + 1 < prompts.length) {
setQuestionIndex(questionIndex + 1);
setIsLoading(false);
return;
}
setIsLoading(false);
setQuestionIndex(0);
onNext({
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 100, total: 100, missing: 0},
type,
});
};
useEffect(() => {
if (updateIndex) updateIndex(promptIndex);
}, [promptIndex, updateIndex]);
if (userSolutions.length > 0 && answers.length === 0) {
const solutions = userSolutions as unknown as typeof answers;
setAnswers(solutions);
if (!mediaBlob) setMediaBlob(solutions.find((x) => x.questionIndex === questionIndex)?.blob);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userSolutions, mediaBlob, answers]);
useEffect(() => {
if (hasExamEnded) {
const answer = {
questionIndex,
prompt: prompts[questionIndex].text,
blob: mediaBlob!,
};
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 100, total: 100, missing: 0},
type,
});
}
@@ -59,31 +120,57 @@ export default function InteractiveSpeaking({
}, [isRecording]);
useEffect(() => {
if (promptIndex === answers.length - 1) {
setMediaBlob(answers[promptIndex].blob);
if (questionIndex <= answers.length - 1) {
const blob = answers.find((x) => x.questionIndex === questionIndex)?.blob;
setMediaBlob(blob);
}
}, [answers, promptIndex]);
}, [answers, questionIndex]);
const saveAnswer = () => {
const saveAnswer = async (index: number) => {
const answer = {
prompt: prompts[promptIndex].text,
questionIndex,
prompt: prompts[questionIndex].text,
blob: mediaBlob!,
};
setAnswers((prev) => [...prev, answer]);
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
setMediaBlob(undefined);
setUserSolutions([
...storeUserSolutions.filter((x) => x.exercise !== id),
{
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 100, total: 100, missing: 0},
module: "speaking",
exam: examID,
type,
},
]);
return answer;
};
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" 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 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">{title}</span>
<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={promptIndex} autoPlay controls className="max-w-3xl rounded-xl">
<source src={prompts[promptIndex].video_url} />
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
<source src={prompts[questionIndex].video_url} />
</video>
</div>
)}
@@ -91,7 +178,7 @@ export default function InteractiveSpeaking({
<ReactMediaRecorder
audio
key={promptIndex}
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">
@@ -208,46 +295,14 @@ export default function InteractiveSpeaking({
/>
<div className="self-end flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: answers,
score: {correct: 1, total: 1, missing: 0},
type,
})
}
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
</Button>
<Button
color="purple"
disabled={!mediaBlob}
onClick={() => {
saveAnswer();
if (promptIndex + 1 < prompts.length) {
setPromptIndex((prev) => prev + 1);
return;
}
onNext({
exercise: id,
solutions: [
...answers,
{
prompt: prompts[promptIndex].text,
blob: mediaBlob!,
},
],
score: {correct: 1, total: 1, missing: 0},
type,
});
}}
className="max-w-[200px] self-end w-full">
{promptIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
<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>
);
}

View File

@@ -1,5 +1,5 @@
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {MatchSentencesExercise} from "@/interfaces/exam";
import {MatchSentenceExerciseOption, MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
@@ -9,13 +9,81 @@ import {CommonProps} from ".";
import Button from "../Low/Button";
import Xarrow from "react-xarrows";
import useExamStore from "@/stores/examStore";
import {DndContext, DragEndEvent, useDraggable, useDroppable} from "@dnd-kit/core";
function DroppableQuestionArea({question, answer}: {question: MatchSentenceExerciseSentence; answer?: string}) {
const {isOver, setNodeRef} = useDroppable({id: `droppable_sentence_${question.id}`});
return (
<div className="grid grid-cols-3 gap-4" ref={setNodeRef}>
<div className="flex items-center gap-3 cursor-pointer col-span-2">
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out",
)}>
{question.id}
</button>
<span>{question.sentence}</span>
</div>
<div
key={`answer_${question.id}_${answer}`}
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
{answer && `Paragraph ${answer}`}
</div>
</div>
);
}
function DraggableOptionArea({option}: {option: MatchSentenceExerciseOption}) {
const {attributes, listeners, setNodeRef, transform} = useDraggable({
id: `draggable_option_${option.id}`,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: 99,
}
: undefined;
return (
<div className={clsx("flex items-center justify-start gap-6 cursor-pointer")} ref={setNodeRef} style={style} {...listeners} {...attributes}>
<button
id={`option_${option.id}`}
// onClick={() => selectOption(id)}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple px-3 py-2 rounded-full z-10",
"transition duration-300 ease-in-out",
option.id,
)}>
Paragraph {option.id}
</button>
</div>
);
}
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
const [selectedQuestion, setSelectedQuestion] = useState<string>();
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
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) => {
if (event.over && event.over.id.toString().startsWith("droppable")) {
const optionID = event.active.id.toString().replace("draggable_option_", "");
const sentenceID = event.over.id.toString().replace("droppable_sentence_", "");
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), {question: sentenceID, option: optionID}]);
}
};
const calculateScore = () => {
const total = sentences.length;
const correct = answers.filter(
@@ -26,20 +94,31 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
return {total, correct, missing};
};
const selectOption = (option: string) => {
if (!selectedQuestion) return;
setAnswers((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
setSelectedQuestion(undefined);
};
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
<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">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
@@ -48,47 +127,29 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
</Fragment>
))}
</span>
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
<DndContext onDragEnd={handleDragEnd}>
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
<div className="flex flex-col gap-4">
{sentences.map(({sentence, id}) => (
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
<span>{sentence} </span>
<button
id={id}
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out",
selectedQuestion === id && "!text-white !bg-mti-purple",
id,
)}>
{id}
</button>
</div>
{sentences.map((question) => (
<DroppableQuestionArea
key={`question_${question.id}`}
question={question}
answer={answers.find((x) => x.question.toString() === question.id.toString())?.option}
/>
))}
</div>
<div className="flex flex-col gap-4">
{options.map(({sentence, id}) => (
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
<button
id={id}
onClick={() => selectOption(id)}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out",
id,
)}>
{id}
</button>
<span>{sentence}</span>
</div>
<span>Drag one of these paragraphs into the slots above:</span>
<div className="flex gap-4 flex-wrap justify-center items-center max-w-lg">
{options.map((option) => (
<DraggableOptionArea key={`answer_${option.id}`} option={option} />
))}
</div>
{answers.map((solution, index) => (
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#7872BF" showHead={false} />
))}
</div>
</div>
</DndContext>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
@@ -106,6 +167,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
Next
</Button>
</div>
</>
</div>
);
}

View File

@@ -1,43 +1,67 @@
/* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import {MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import {useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import Button from "../Low/Button";
import {v4} from "uuid";
function Question({
id,
variant,
prompt,
options,
userSolution,
onSelectOption,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
}: MultipleChoiceQuestion & {
userSolution: string | undefined;
onSelectOption?: (option: string) => void;
showSolution?: boolean;
}) {
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u key={v4()}>{word}</u> : null;
});
};
return (
<div className="flex flex-col gap-10">
<span className="">{prompt}</span>
<div className="flex flex-col gap-8">
{isNaN(Number(id)) ? (
<span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
) : (
<span className="text-lg">
<>
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
</>
</span>
)}
<div className="flex flex-wrap gap-4 justify-between">
{variant === "image" &&
options.map((option) => (
<div
key={option.id.toString()}
key={v4()}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
className={clsx(
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
userSolution === option.id.toString() && "border-mti-purple-light",
)}>
<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()}`} />
</div>
))}
{variant === "text" &&
options.map((option) => (
<div
key={option.id.toString()}
key={v4()}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
className={clsx(
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
userSolution === option.id.toString() && "border-mti-purple-light",
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
userSolution === option.id.toString() && "!bg-mti-purple-light !text-white",
)}>
<span className="font-semibold">{option.id.toString()}.</span>
<span>{option.text}</span>
@@ -48,83 +72,150 @@ function Question({
);
}
export default function MultipleChoice({
id,
prompt,
type,
questions,
userSolutions,
updateIndex,
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 [questionIndex, setQuestionIndex] = useState(0);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const {questionIndex, exerciseIndex, exam, shuffles, hasExamEnded, partIndex, setQuestionIndex, setCurrentSolution} = useExamStore(
(state) => state,
);
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
const onSelectOption = (option: string) => {
const question = questions[questionIndex];
const onSelectOption = (option: string, question: MultipleChoiceQuestion) => {
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 total = questions.length;
const correct = answers.filter(
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
).length;
const missing = total - answers.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
const correct = answers.filter((x) => {
const matchingQuestion = questions.find((y) => {
return y.id.toString() === x.question.toString();
});
let isSolutionCorrect;
if (!shuffleMaps) {
isSolutionCorrect = matchingQuestion?.solution === x.option;
} else {
const shuffleMap = shuffleMaps.find((map) => map.questionID == x.question);
if (shuffleMap) {
isSolutionCorrect = getShuffledSolution(x.option, shuffleMap) == matchingQuestion?.solution;
} else {
isSolutionCorrect = matchingQuestion?.solution === x.option;
}
}
return isSolutionCorrect || false;
}).length;
const missing = total - answers!.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length;
return {total, correct, missing};
};
const next = () => {
if (questionIndex === questions.length - 1) {
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
if (questionIndex + 1 >= questions.length - 1) {
onNext({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
} else {
setQuestionIndex((prev) => prev + 1);
setQuestionIndex(questionIndex + 2);
}
scrollToTop();
};
const back = () => {
if (questionIndex === 0) {
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
onBack({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
} else {
setQuestionIndex((prev) => prev - 1);
if (exam?.module === "level" && typeof exam.parts[0].intro !== "undefined" && questionIndex === 0) return;
setQuestionIndex(questionIndex - 2);
}
scrollToTop();
};
return (
<>
<div className="flex flex-col gap-2 mt-4 h-fit mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
<span className="text-xl font-semibold">{prompt}</span>
{questionIndex < questions.length && (
<Question
{...questions[questionIndex]}
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
onSelectOption={onSelectOption}
/>
)}
</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">
<div className="flex flex-col gap-4">
<div className="flex justify-between w-full gap-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">
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>
</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

@@ -5,28 +5,61 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
import dynamic from "next/dynamic";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
import {downloadBlob} from "@/utils/evaluation";
import axios from "axios";
import Modal from "../Modal";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false,
});
export default function Speaking({id, title, text, video_url, type, prompts, 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 [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
const [audioURL, setAudioURL] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
const [inputText, setInputText] = useState("");
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) {
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
});
const saveToStorage = async () => {
if (mediaBlob && mediaBlob.startsWith("blob")) {
const blobBuffer = await downloadBlob(mediaBlob);
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
const seed = Math.random().toString().replace("0.", "");
const formData = new FormData();
formData.append("audio", audioFile, `${seed}.wav`);
formData.append("root", "speaking_recordings");
const config = {
headers: {
"Content-Type": "audio/wav",
},
};
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
if (audioURL) await axios.post("/api/storage/delete", {path: audioURL});
return response.data.path;
}
return undefined;
};
useEffect(() => {
if (userSolutions.length > 0) {
const {solution} = userSolutions[0] as {solution?: string};
if (solution && !mediaBlob) setMediaBlob(solution);
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
}
}, [userSolutions, mediaBlob]);
useEffect(() => {
if (hasExamEnded) next();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
@@ -43,11 +76,79 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
};
}, [isRecording]);
const next = async () => {
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 0, total: 100, missing: 0},
type,
});
};
const back = async () => {
onBack({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 0, total: 100, missing: 0},
type,
});
};
const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value;
const words = newText.match(/\S+/g);
const wordCount = words ? words.length : 0;
if (wordCount <= 100) {
setInputText(newText);
} else {
let count = 0;
let lastIndex = 0;
const matches = newText.matchAll(/\S+/g);
for (const match of matches) {
count++;
if (count > 100) break;
lastIndex = match.index! + match[0].length;
}
setInputText(newText.slice(0, lastIndex));
}
};
return (
<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" 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 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) => (
@@ -59,7 +160,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
</span>
)}
</div>
<div className="flex gap-6">
<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">
@@ -67,20 +168,23 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
</video>
</div>
)}
{prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4">
<span className="font-bold">You should talk about the following things:</span>
<div className="flex flex-col gap-1 ml-4">
{prompts.map((x, index) => (
<li className="italic" key={index}>
{x}
</li>
))}
{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>
)}
</div>
</div>
<ReactMediaRecorder
audio
@@ -89,7 +193,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
<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" && (
{status === "idle" && !mediaBlob && (
<>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && (
@@ -168,9 +272,9 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
</div>
</>
)}
{status === "stopped" && mediaBlobUrl && (
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
<>
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<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"
@@ -200,35 +304,14 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
/>
<div className="self-end flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
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
</Button>
<Button
color="purple"
disabled={!mediaBlob}
onClick={() =>
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
</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 hasExamEnded = useExamStore((state) => state.hasExamEnded);
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
useEffect(() => {
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};
};
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 answer = answers.find((x) => x.id === questionId);
if (answer && answer.solution === solution) {
@@ -39,8 +45,25 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
};
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
<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">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
@@ -116,6 +139,6 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
Next
</Button>
</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) {
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const {hasExamEnded, setCurrentSolution} = useExamStore((state) => state);
useEffect(() => {
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};
};
useEffect(() => {
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, setAnswers]);
const renderLines = (line: string) => {
return (
<span className="text-base leading-5">
@@ -87,8 +92,25 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
};
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
<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">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<span key={index}>
@@ -123,6 +145,6 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
Next
</Button>
</div>
</>
</div>
);
}

View File

@@ -22,9 +22,31 @@ export default function Writing({
const [isModalOpen, setIsModalOpen] = useState(false);
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
const [saveTimer, setSaveTimer] = useState(0);
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
const saveTimerInterval = setInterval(() => {
setSaveTimer((prev) => prev + 1);
}, 1000);
return () => {
clearInterval(saveTimerInterval);
};
}, []);
useEffect(() => {
if (inputText.length > 0 && saveTimer % 10 === 0) {
setUserSolutions([
...storeUserSolutions.filter((x) => x.exercise !== id),
{exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"},
]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [saveTimer]);
useEffect(() => {
if (localStorage.getItem("enable_paste")) return;
@@ -42,7 +64,8 @@ export default function Writing({
}, []);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type});
if (hasExamEnded)
onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type, module: "writing"});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
@@ -61,7 +84,34 @@ export default function Writing({
}, [inputText, wordCounter]);
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 && (
<Transition show={isModalOpen} as={Fragment}>
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
@@ -125,18 +175,28 @@ export default function Writing({
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type})}
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}], score: {correct: 1, total: 1, missing: 0}, type})}
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>
</>
</div>
);
}

View File

@@ -22,46 +22,38 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
export interface CommonProps {
updateIndex?: (internalIndex: number) => void;
examID?: string;
onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void;
}
export const renderExercise = (
exercise: Exercise,
examID: string,
onNext: (userSolutions: UserSolution) => void,
onBack: (userSolutions: UserSolution) => void,
updateIndex?: (internalIndex: number) => void,
) => {
switch (exercise.type) {
case "fillBlanks":
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "trueFalse":
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "matchSentences":
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "multipleChoice":
return (
<MultipleChoice
key={exercise.id}
{...(exercise as MultipleChoiceExercise)}
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}
/>
);
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "writeBlanks":
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "writing":
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "speaking":
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "interactiveSpeaking":
return (
<InteractiveSpeaking
key={exercise.id}
{...(exercise as InteractiveSpeakingExercise)}
updateIndex={updateIndex}
examID={examID}
onNext={onNext}
onBack={onBack}
/>

View File

@@ -0,0 +1,130 @@
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import React from "react";
import Input from "@/components/Low/Input";
import clsx from "clsx";
interface Props {
exercise: FillBlanksExercise;
updateExercise: (data: any) => void;
}
const FillBlanksEdit = (props: 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 (
<>
<Input
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
label="Prompt"
name="prompt"
required
value={exercise.prompt}
onChange={(value) =>
updateExercise({
prompt: value,
})
}
/>
<Input
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
label="Text"
name="text"
required
value={exercise.text}
onChange={(value) =>
updateExercise({
text: exercise?.variant && exercise.variant === "mc" ? value : value,
})
}
/>
<h1 className="mt-4">Solutions</h1>
<div className="w-full flex flex-wrap -mx-2">
{exercise.solutions.map((solution, index) => (
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
<Input
type="text"
label={`Solution ${index + 1}`}
name="solution"
required
value={solution.solution}
onChange={(value) =>
updateExercise({
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? { ...sol, solution: value } : sol)),
})
}
/>
</div>
))}
</div>
<h1 className="mt-4">Words</h1>
<div className={clsx(exercise?.variant && exercise.variant === "mc" ? "w-full flex flex-row" : "w-full flex flex-wrap -mx-2")}>
{exercise?.variant && exercise.variant === "mc" && typeCheckWordsMC(exercise.words) ?
(
<div className="flex flex-col w-full">
{exercise.words.flatMap((mcOptions, wordIndex) =>
<>
<label className="font-semibold">{`Word ${wordIndex + 1}`}</label>
<div className="flex flex-row">
{Object.entries(mcOptions.options).map(([key, value], optionIndex) => (
<div key={`${wordIndex}-${optionIndex}-${key}`} className="flex sm:w-1/2 lg:w-1/4 px-2 mb-4">
<Input
type="text"
label={`Option ${key}`}
name="word"
required
value={value}
onChange={(newValue) =>
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>
</>
);
};
export default FillBlanksEdit;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import axios from "axios";
import {useState} from "react";
import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id";
import Button from "../Low/Button";
import Input from "../Low/Input";
import Select from "../Low/Select";
interface Props {
user: User;
page: string;
onClose: () => void;
}
export default function TicketSubmission({user, page, onClose}: Props) {
const [subject, setSubject] = useState("");
const [type, setType] = useState<TicketType>();
const [description, setDescription] = useState("");
const [isLoading, setIsLoading] = useState(false);
const examState = useExamStore((state) => state);
const submit = () => {
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
if (subject.trim() === "")
return toast.error("Please input a subject!", {
toastId: "missing-subject",
});
if (description.trim() === "")
return toast.error("Please describe your ticket!", {
toastId: "missing-desc",
});
setIsLoading(true);
const shortUID = new ShortUniqueId();
const ticket: Ticket = {
id: shortUID.randomUUID(8),
date: new Date().toISOString(),
reporter: {
id: user.id,
email: user.email,
name: user.name,
type: user.type,
},
status: "submitted",
subject,
type,
reportedFrom: page,
description,
examInformation:
page.includes("exam") || page.includes("exercises")
? {
exam: examState.exam?.id || "",
exams: examState.exams.map((x) => x.id),
exerciseIndex: examState.exerciseIndex,
moduleIndex: examState.moduleIndex,
partIndex: examState.partIndex,
questionIndex: examState.questionIndex,
selectedModules: examState.selectedModules,
}
: undefined,
};
axios
.post(`/api/tickets`, ticket)
.then(() => {
toast.success(`Your ticket has been submitted! You will be contacted by e-mail for further discussion.`, {toastId: "submitted"});
onClose();
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong, please try again later!", {
toastId: "error",
});
})
.finally(() => setIsLoading(false));
};
return (
<form className="flex flex-col gap-4 pt-8">
<Input label="Subject" type="text" name="subject" placeholder="Subject..." onChange={(e) => setSubject(e)} />
<div className="-md:flex-col flex w-full items-center gap-4">
<div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal">Type</label>
<Select
options={Object.keys(TicketTypeLabel).map((x) => ({
value: x,
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
}))}
onChange={(value) => setType((value?.value as TicketType) ?? undefined)}
placeholder="Type..."
/>
</div>
<Input label="Reporter" type="text" name="reporter" onChange={() => null} value={`${user.name} - ${user.email}`} disabled />
</div>
<textarea
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
onChange={(e) => setDescription(e.target.value)}
placeholder="Write your ticket's description here..."
spellCheck
/>
<div className="mt-2 flex w-full items-center justify-end gap-4">
<Button type="button" color="red" className="w-full max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
Cancel
</Button>
<Button type="button" className="w-full max-w-[200px]" isLoading={isLoading} onClick={submit}>
Submit
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,47 @@
import React, { useCallback } from "react";
import { HighlightConfig, HighlightTarget } from "@/training/TrainingInterfaces";
interface HighlightedContentProps {
html: string;
highlightConfigs: HighlightConfig[];
contentType: HighlightTarget;
currentSegmentIndex?: number;
}
const HighlightedContent: React.FC<HighlightedContentProps> = ({
html,
highlightConfigs,
contentType,
currentSegmentIndex
}) => {
const createHighlightedContent = useCallback(() => {
let highlightedHtml = html;
highlightConfigs.forEach(config => {
if (config.targets.includes(contentType) || config.targets.includes('all')) {
const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
const regex = new RegExp(config.phrases.map(escapeRegExp).join('|'), 'g');
if (contentType === 'segment' && currentSegmentIndex !== undefined) {
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 };
}, [html, highlightConfigs, contentType, currentSegmentIndex]);
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
};
export default HighlightedContent;

View File

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

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

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

View File

@@ -4,11 +4,12 @@ import {BsArrowRepeat} from "react-icons/bs";
interface Props {
children: ReactNode;
color?: "rose" | "purple" | "red" | "green" | "gray";
color?: "rose" | "purple" | "red" | "green" | "gray" | "pink";
variant?: "outline" | "solid";
className?: string;
disabled?: boolean;
isLoading?: boolean;
padding?: string;
onClick?: () => void;
type?: "button" | "reset" | "submit";
}
@@ -21,6 +22,7 @@ export default function Button({
className,
children,
type,
padding = "py-4 px-6",
onClick,
}: Props) {
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
@@ -49,6 +51,11 @@ export default function Button({
outline:
"bg-transparent text-mti-rose-light border border-mti-rose-light hover:bg-mti-rose-light disabled:text-mti-rose disabled:bg-mti-rose-ultralight disabled:border-none selection:bg-mti-rose-dark hover:text-white selection:text-white",
},
pink: {
solid: "bg-ielts-speaking text-white border border-ielts-speaking hover:bg-ielts-speaking disabled:text-ielts-speaking disabled:bg-ielts-speaking-transparent selection:bg-ielts-speaking",
outline:
"bg-transparent text-ielts-speaking border border-ielts-speaking hover:bg-ielts-speaking disabled:text-ielts-speaking disabled:bg-ielts-speaking-transparent disabled:border-none selection:bg-ielts-speaking hover:text-white selection:text-white",
},
};
return (
@@ -56,9 +63,10 @@ export default function Button({
type={type}
onClick={onClick}
className={clsx(
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer",
className,
"rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer select-none",
padding,
colorClassNames[color][variant],
className,
)}
disabled={disabled || isLoading}>
{!isLoading && children}

View File

@@ -11,14 +11,16 @@ interface Props {
export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
return (
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => {
<div
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer"
onClick={() => {
if (disabled) return;
onChange(!isChecked);
}}>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"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",
isChecked && "!bg-mti-purple-light ",
)}>

View File

@@ -42,7 +42,9 @@ export default function CountrySelect({value, disabled = false, onChange}: Props
displayValue={(code: string) => {
const country = countries[code as unknown as keyof TCountries];
return `${countryCodes.findOne("countryCode" as any, code).flag} ${country.name} (+${country.phone})`;
return `${countryCodes.findOne("countryCode" as any, code)?.flag || ""} ${country?.name || "N/A"} (+${
country?.phone || "N/A"
})`;
}}
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">

View File

@@ -2,7 +2,7 @@ import clsx from "clsx";
import { useState } from "react";
interface Props {
type: "email" | "text" | "password" | "tel" | "number";
type: "email" | "text" | "password" | "tel" | "number" | "textarea";
roundness?: "full" | "xl";
required?: boolean;
label?: string;
@@ -11,6 +11,7 @@ interface Props {
value?: string | number;
className?: string;
disabled?: boolean;
max?: number;
name: string;
onChange: (value: string) => void;
}
@@ -23,6 +24,7 @@ export default function Input({
required = false,
value,
defaultValue,
max,
className,
roundness = "full",
disabled = false,
@@ -30,6 +32,20 @@ export default function Input({
}: Props) {
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") {
return (
<div className="relative flex flex-col gap-3 w-full">
@@ -72,6 +88,7 @@ export default function Input({
name={name}
disabled={disabled}
value={value}
max={max}
onChange={(e) => onChange(e.target.value)}
min={type === "number" ? 0 : undefined}
placeholder={placeholder}

View File

@@ -5,12 +5,14 @@ interface Props {
label: string;
percentage: number;
color: "red" | "rose" | "purple" | Module;
mark?: number;
markLabel?: string;
useColor?: boolean;
className?: string;
textClassName?: string;
}
export default function ProgressBar({label, percentage, color, useColor = false, className, textClassName}: Props) {
export default function ProgressBar({label, percentage, color, mark, markLabel, useColor = false, className, textClassName}: Props) {
const progressColorClass: {[key in typeof color]: string} = {
red: "bg-mti-red-light",
rose: "bg-mti-rose-light",
@@ -30,6 +32,9 @@ export default function ProgressBar({label, percentage, color, useColor = false,
!useColor ? "bg-mti-gray-anti-flash" : progressColorClass[color],
useColor && "bg-opacity-20",
)}>
{mark && (
<div style={{left: `${mark}%`}} className={clsx("w-3 h-2 bg-mti-gray-davy/60 absolute -translate-x-1/2 top-0 z-20 cursor-pointer")} />
)}
<div
style={{width: `${percentage}%`}}
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])}

View File

@@ -0,0 +1,70 @@
import clsx from "clsx";
import {ComponentProps, useEffect, useState} from "react";
import ReactSelect, {GroupBase, StylesConfig} from "react-select";
interface Option {
[key: string]: any;
value: string | null;
label: string;
}
interface Props {
defaultValue?: Option;
value?: Option | null;
options: Option[];
disabled?: boolean;
placeholder?: string;
onChange: (value: Option | null) => void;
isClearable?: boolean;
styles?: StylesConfig<Option, boolean, GroupBase<Option>>;
className?: string;
}
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, className}: Props) {
const [target, setTarget] = useState<HTMLElement>();
useEffect(() => {
if (document) setTarget(document.body);
}, []);
return (
<ReactSelect
className={
styles
? undefined
: clsx(
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
className,
)
}
options={options}
value={value}
onChange={onChange as any}
placeholder={placeholder}
menuPortalTarget={target}
defaultValue={defaultValue}
styles={
styles || {
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}
}
isDisabled={disabled}
isClearable={isClearable}
/>
);
}

View File

@@ -0,0 +1,77 @@
import { Invite } from "@/interfaces/invite";
import { User } from "@/interfaces/user";
import axios from "axios";
import { useState } from "react";
import { BsArrowRepeat } from "react-icons/bs";
import { toast } from "react-toastify";
interface Props {
invite: Invite;
users: User[];
reload: () => void;
}
export default function InviteCard({ invite, users, reload }: Props) {
const [isLoading, setIsLoading] = useState(false);
const inviter = users.find((u) => u.id === invite.from);
const name = !inviter
? null
: inviter.type === "corporate"
? inviter.corporateInformation?.companyInformation?.name || inviter.name
: inviter.name;
const decide = (decision: "accept" | "decline") => {
if (!confirm(`Are you sure you want to ${decision} this invite?`)) return;
setIsLoading(true);
axios
.get(`/api/invites/${decision}/${invite.id}`)
.then(() => {
toast.success(
`Successfully ${decision === "accept" ? "accepted" : "declined"} the invite!`,
{ toastId: "success" },
);
reload();
})
.catch((e) => {
toast.success(`Something went wrong, please try again later!`, {
toastId: "error",
});
reload();
})
.finally(() => setIsLoading(false));
};
return (
<div className="border-mti-gray-anti-flash flex min-w-[200px] flex-col gap-6 rounded-xl border p-4 text-black">
<span>Invited by {name}</span>
<div className="flex items-center gap-2">
<button
onClick={() => decide("accept")}
disabled={isLoading}
className="bg-mti-green-ultralight hover:bg-mti-green-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
>
{!isLoading && "Accept"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
<button
onClick={() => decide("decline")}
disabled={isLoading}
className="bg-mti-red-ultralight hover:bg-mti-red-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
>
{!isLoading && "Decline"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import {Module} from "@/interfaces";
import {writingMarking} from "@/utils/score";
import {Menu} from "@headlessui/react";
import {Dispatch, SetStateAction} from "react";
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
type Levels = {[key in Module]: number};
interface Props {
levels: Levels;
setLevels: Dispatch<SetStateAction<Levels>>;
}
export default function ModuleLevelSelector({levels, setLevels}: Props) {
return (
<div className="flex flex-col gap-32 w-full">
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16">
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Reading</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsBook className="text-ielts-reading" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.reading === -1 ? "Select your reading level" : `Level ${levels.reading}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, reading: x}))}
className="w-full py-4 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Listening</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsHeadphones className="text-ielts-listening" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.listening === -1 ? "Select your listening level" : `Level ${levels.listening}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-50 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, listening: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Writing</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsPen className="text-ielts-writing" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.writing === -1 ? "Select your writing level" : `Level ${levels.writing}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, writing: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
<div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim">
<span className="font-bold">Speaking</span> level
</span>
<Menu>
<Menu.Button className="w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-between items-center gap-12 bg-white">
<BsMegaphone className="text-ielts-speaking" size={34} />
<span className="text-mti-gray-cool text-sm">
{levels.speaking === -1 ? "Select your speaking level" : `Level ${levels.speaking}`}
</span>
<BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => (
<Menu.Item key={x}>
<span
onClick={() => setLevels((prev) => ({...prev, speaking: x}))}
className="w-full py-5 text-center cursor-pointer bg-white hover:bg-mti-gray-platinum transition ease-in-out duration-300">
Level {x}
</span>
</Menu.Item>
))}
</Menu.Items>
</Menu>
</div>
</div>
</div>
);
}

View File

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

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

@@ -0,0 +1,111 @@
import {Session} from "@/hooks/useSessions";
import useExamStore from "@/stores/examStore";
import {sortByModuleName} from "@/utils/moduleUtils";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import moment from "moment";
import {useState} from "react";
import {BsArrowRepeat, BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import {toast} from "react-toastify";
export default function SessionCard({
session,
reload,
loadSession,
}: {
session: Session;
reload: () => void;
loadSession: (session: Session) => Promise<void>;
}) {
const [isLoading, setIsLoading] = useState(false);
const deleteSession = async () => {
if (!confirm("Are you sure you want to delete this session?")) return;
setIsLoading(true);
await axios
.delete(`/api/sessions/${session.sessionId}`)
.then(() => {
toast.success(`Successfully delete session "${session.sessionId}"`);
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later");
})
.finally(() => {
reload();
setIsLoading(false);
});
};
return (
<div className="border-mti-gray-anti-flash flex w-64 flex-col justify-between gap-3 rounded-xl border p-4 text-black">
<div className="flex flex-col gap-3">
<span className="flex gap-1">
<b>ID:</b>
{session.sessionId}
</span>
<span className="flex gap-1">
<b>Date:</b>
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
</span>
{session.assignment && (
<span className="flex flex-col gap-0">
<b>Assignment:</b>
{session.assignment.name}
</span>
)}
</div>
<div className="flex flex-col gap-3">
<div className="flex w-full items-center justify-between">
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
{session.selectedModules.sort(sortByModuleName).map((module) => (
<div
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>
<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>
);
}

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

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

View File

@@ -0,0 +1,70 @@
import topics from "@/resources/topics";
import {useState} from "react";
import {BsArrowLeft, BsArrowRight} from "react-icons/bs";
import Button from "../Low/Button";
import Modal from "../Modal";
interface Props {
isOpen: boolean;
initialTopics: string[];
onClose: VoidFunction;
selectTopics: (topics: string[]) => void;
}
export default function TopicModal({isOpen, initialTopics, onClose, selectTopics}: Props) {
const [selectedTopics, setSelectedTopics] = useState([...initialTopics]);
return (
<Modal isOpen={isOpen} onClose={onClose} title="Preferred Topics">
<div className="flex flex-col w-full h-full gap-4 mt-4">
<div className="w-full h-full grid grid-cols-2 -md:gap-1 gap-4">
<div className="flex flex-col gap-2">
<span className="border-b border-b-neutral-400/30">Available Topics</span>
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
{topics
.filter((x) => !selectedTopics.includes(x))
.map((x) => (
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center">
<span>{x}</span>
<button
onClick={() => setSelectedTopics((prev) => [...prev, x])}
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
<BsArrowRight />
</button>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="border-b border-b-neutral-400/30">Preferred Topics ({selectedTopics.length || "All"})</span>
<div className=" max-h-[500px] overflow-y-scroll scrollbar-hide">
{selectedTopics.map((x) => (
<div key={x} className="odd:bg-mti-purple-ultralight/40 p-2 flex justify-between items-center text-right">
<button
onClick={() => setSelectedTopics((prev) => [...prev.filter((y) => y !== x)])}
className="border border-mti-purple-light cursor-pointer p-2 rounded-lg bg-white drop-shadow transition ease-in-out duration-300 hover:bg-mti-purple hover:text-white">
<BsArrowLeft />
</button>
<span>{x}</span>
</div>
))}
</div>
</div>
</div>
<div className="w-full flex gap-4 items-center justify-end">
<Button variant="outline" color="rose" className="w-full max-w-[200px]" onClick={onClose}>
Close
</Button>
<Button
className="w-full max-w-[200px]"
onClick={() => {
selectTopics(selectedTopics);
onClose();
}}>
Select
</Button>
</div>
</div>
</Modal>
);
}

View File

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

View File

@@ -1,17 +1,20 @@
import {Dialog, Transition} from "@headlessui/react";
import clsx from "clsx";
import {Fragment, ReactElement} from "react";
interface Props {
isOpen: boolean;
onClose: () => void;
title?: string;
className?: string;
titleClassName?: string;
children?: ReactElement;
}
export default function Modal({isOpen, title, onClose, children}: Props) {
export default function Modal({isOpen, title, className, titleClassName, onClose, children}: Props) {
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}>
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -33,9 +36,13 @@ export default function Modal({isOpen, title, onClose, children}: Props) {
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<Dialog.Panel className="w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Panel
className={clsx(
"w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all",
className,
)}>
{title && (
<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}
</Dialog.Title>
)}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
import {PaymentIntention} from "@/interfaces/paymob";
import {DurationUnit} from "@/interfaces/paypal";
import {User} from "@/interfaces/user";
import axios from "axios";
import {useRouter} from "next/router";
import {useState} from "react";
import Button from "./Low/Button";
import Input from "./Low/Input";
import Modal from "./Modal";
interface Props {
user: User;
currency: string;
price: number;
setIsPaymentLoading: (v: boolean) => void;
duration: number;
duration_unit: DurationUnit;
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
}
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleCardPayment = async () => {
try {
setIsPaymentLoading(true);
const paymentIntention: PaymentIntention = {
amount: price * 1000,
currency: "OMR",
items: [],
payment_methods: [],
customer: {
email: user.email,
first_name: user.name.split(" ")[0],
last_name: [...user.name.split(" ")].pop() || "N/A",
extras: {
re: user.id,
},
},
billing_data: {
apartment: "N/A",
building: "N/A",
country: user.demographicInformation?.country || "N/A",
email: user.email,
first_name: user.name.split(" ")[0],
last_name: [...user.name.split(" ")].pop() || "N/A",
floor: "N/A",
phone_number: user.demographicInformation?.phone || "N/A",
state: "N/A",
street: "N/A",
},
extras: {
userID: user.id,
duration,
duration_unit,
},
};
const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention);
router.push(response.data.iframeURL);
} catch (error) {
console.error("Error starting card payment process:", error);
}
};
return (
<>
<Button isLoading={isLoading} onClick={handleCardPayment}>
Select
</Button>
</>
);
}

View File

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

View File

@@ -12,6 +12,7 @@ interface Props {
icon: ReactElement;
value: string | number;
label: string;
tooltip?: string;
}[];
children?: ReactElement;
}
@@ -48,7 +49,10 @@ export default function ProfileSummary({user, items}: Props) {
<div className="flex justify-between w-full mt-8 -md:hidden">
{items.map((item) => (
<div className="flex gap-4 items-center" key={item.label}>
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
<div
className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl relative group tooltip tooltip-bottom"
data-tip={item.tooltip}
>
{item.icon}
</div>
<div className="flex flex-col">

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,11 @@ import {
BsShieldFill,
BsCloudFill,
BsCurrencyDollar,
BsClipboardData,
BsFileLock,
BsPeople,
} from "react-icons/bs";
import {CiDumbbell} from "react-icons/ci";
import {RiLogoutBoxFill} from "react-icons/ri";
import {SlPencil} from "react-icons/sl";
import {FaAward} from "react-icons/fa";
@@ -20,16 +24,19 @@ import {useRouter} from "next/router";
import axios from "axios";
import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled";
import {useState} from "react";
import {useEffect, useState} from "react";
import usePreferencesStore from "@/stores/preferencesStore";
import {Type} from "@/interfaces/user";
import {User} from "@/interfaces/user";
import useTicketsListener from "@/hooks/useTicketsListener";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
interface Props {
path: string;
navDisabled?: boolean;
focusMode?: boolean;
onFocusLayerMouseEnter?: () => void;
className?: string;
userType?: Type;
user: User;
}
interface NavProps {
@@ -39,27 +46,44 @@ interface NavProps {
keyPath: string;
disabled?: boolean;
isMinimized?: boolean;
badge?: number;
}
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false}: NavProps) => (
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, badge}: NavProps) => {
return (
<Link
href={!disabled ? keyPath : ""}
className={clsx(
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white",
"transition-all duration-300 ease-in-out",
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
"transition-all duration-300 ease-in-out relative",
disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer",
path === keyPath && "bg-mti-purple-light text-white",
isMinimized ? "w-fit" : "w-full min-w-[200px] 2xl:min-w-[220px] px-8",
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
)}>
<Icon size={24} />
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
{!!badge && badge > 0 && (
<div
className={clsx(
"bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white",
"transition ease-in-out duration-300",
isMinimized && "absolute right-0 top-0",
)}>
{badge}
</div>
)}
</Link>
);
};
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className}: Props) {
export default function Sidebar({path, navDisabled = false, focusMode = false, user, onFocusLayerMouseEnter, className}: Props) {
const router = useRouter();
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
const {totalAssignedTickets} = useTicketsListener(user.id);
const {permissions} = usePermissions(user.id);
const logout = async () => {
axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500);
@@ -71,35 +95,31 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
return (
<section
className={clsx(
"h-full flex bg-transparent flex-col justify-between px-4 py-4 pb-8 relative",
isMinimized ? "w-fit" : "w-1/6 -xl:w-fit",
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
className,
)}>
<div className="xl:flex -xl:hidden flex-col gap-3">
<div className="-xl:hidden flex-col gap-3 xl:flex">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
{(userType === "student" || userType === "teacher" || userType === "developer") && (
<>
<Nav
disabled={disableNavigation}
Icon={BsFileEarmarkText}
label="Exams"
path={path}
keyPath="/exam"
isMinimized={isMinimized}
/>
<Nav
disabled={disableNavigation}
Icon={BsPencil}
label="Exercises"
path={path}
keyPath="/exercises"
isMinimized={isMinimized}
/>
</>
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && (
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
)}
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExercises") && (
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
)}
{checkAccess(user, ["developer", "admin", "teacher", "student"], permissions) && (
<Nav disabled={disableNavigation} Icon={BsPeople} label="Groups" path={path} keyPath="/groups" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
{["admin", "developer", "agent", "corporate"].includes(userType || "") && (
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
)}
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], permissions, "viewPaymentRecords") && (
<Nav
disabled={disableNavigation}
Icon={BsCurrencyDollar}
@@ -109,7 +129,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
isMinimized={isMinimized}
/>
)}
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
{checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]) && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
@@ -119,7 +139,18 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
isMinimized={isMinimized}
/>
)}
{userType === "developer" && (
{checkAccess(user, ["admin", "developer", "agent"], permissions, "viewTickets") && (
<Nav
disabled={disableNavigation}
Icon={BsClipboardData}
label="Tickets"
path={path}
keyPath="/tickets"
isMinimized={isMinimized}
badge={totalAssignedTickets}
/>
)}
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
@@ -129,28 +160,65 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
isMinimized={isMinimized}
/>
)}
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "agent"]) && (
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={isMinimized}
/>
)}
</div>
<div className="xl:hidden -xl:flex flex-col gap-3">
<div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
{userType !== "student" && (
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["student"])) && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
)}
{userType === "developer" && (
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
{checkAccess(user, getTypesOfUser(["student"])) && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Permissions" path={path} keyPath="/permissions" isMinimized={true} />
)}
{checkAccess(user, ["developer"]) && (
<>
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={true}
/>
</>
)}
</div>
<div className="flex flex-col gap-0 bottom-12 fixed">
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8">
<div
role="button"
tabIndex={1}
onClick={toggleMinimize}
className={clsx(
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose -xl:hidden transition duration-300 ease-in-out",
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)}>
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
@@ -161,11 +229,11 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
tabIndex={1}
onClick={focusMode ? () => {} : logout}
className={clsx(
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose transition duration-300 ease-in-out",
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)}>
<RiLogoutBoxFill size={24} />
{!isMinimized && <span className="text-lg font-medium -xl:hidden">Log Out</span>}
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>}
</div>
</div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}

View File

@@ -1,53 +1,132 @@
import {FillBlanksExercise} from "@/interfaces/exam";
import {FillBlanksExercise, FillBlanksMCOption, ShuffleMap} from "@/interfaces/exam";
import clsx from "clsx";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {Fragment} from "react";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
export default function FillBlanksSolutions({id, type, prompt, solutions, words, text, onNext, onBack}: FillBlanksExercise & CommonProps) {
const storeUserSolutions = useExamStore((state) => state.userSolutions);
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const correctUserSolutions = storeUserSolutions.find((solution) => solution.exercise === id)?.solutions;
const shuffles = useExamStore((state) => state.shuffles);
export default function FillBlanksSolutions({id, type, prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter(
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
).length;
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
const correct = correctUserSolutions!.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
if (!solution) return false;
const option = words.find((w) => {
if (typeof w === "string") {
return w.toLowerCase() === x.solution.toLowerCase();
} else if ("letter" in w) {
return w.letter.toLowerCase() === x.solution.toLowerCase();
} else {
return w.id.toString() === x.id.toString();
}
});
if (!option) return false;
if (typeof option === "string") {
return solution.toLowerCase() === option.toLowerCase();
} else if ("letter" in option) {
return solution.toLowerCase() === option.word.toLowerCase();
} else if ("options" in option) {
return option.options[solution as keyof typeof option.options] == x.solution;
}
return false;
}).length;
const missing = total - correctUserSolutions!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing};
};
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
};
const renderLines = (line: string) => {
return (
<span>
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id);
const solution = solutions.find((x) => x.id === id)!;
const questionId = match.replaceAll(/[\{\}]/g, "");
const userSolution = correctUserSolutions!.find((x) => x.id.toString() === questionId.toString());
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) {
let answerText;
if (typeCheckWordsMC(words)) {
const options = words.find((x) => x.id.toString() === questionId.toString());
const correctKey = Object.keys(options!.options).find((key) => key.toLowerCase() === newAnswerSolution);
answerText = options!.options[correctKey as keyof typeof options];
} else {
answerText = answerSolution;
}
return (
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-gray-davy transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-gray-davy",
)}>
{solution?.solution}
{answerText}
</button>
);
}
if (userSolution.solution === solution.solution) {
const userSolutionWord = words.find((w) =>
typeof w === "string"
? w.toLowerCase() === userSolution.solution.toLowerCase()
: "letter" in w
? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
: "options" in w
? w.id === userSolution.questionId
: false,
);
const userSolutionText =
typeof userSolutionWord === "string"
? userSolutionWord
: userSolutionWord && "letter" in userSolutionWord
? userSolutionWord.word
: userSolutionWord && "options" in userSolutionWord
? userSolution.solution
: userSolution.solution;
let correct;
let solutionText;
if (typeCheckWordsMC(words)) {
const options = words.find((x) => x.id.toString() === questionId.toString());
if (options) {
const correctKey = Object.keys(options.options).find((key) => key.toLowerCase() === newAnswerSolution);
correct = userSolution.solution == options.options[correctKey as keyof typeof options.options];
solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution;
} else {
correct = false;
solutionText = answerSolution;
}
} else {
correct = userSolutionText === answerSolution;
solutionText = answerSolution;
}
if (correct) {
return (
<button
className={clsx(
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)}>
{solution.solution}
{solutionText}
</button>
);
}
if (userSolution.solution !== solution.solution) {
} else {
return (
<>
<button
@@ -55,7 +134,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
"rounded-full hover:text-white hover:bg-mti-rose transition duration-300 ease-in-out my-1 mr-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light",
)}>
{userSolution.solution}
{userSolutionText}
</button>
<button
@@ -63,7 +142,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)}>
{solution.solution}
{solutionText}
</button>
</>
);
@@ -74,18 +153,28 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
};
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>
<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">
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{userSolutions &&
{correctUserSolutions &&
text.split("\\n").map((line, index) => (
<p key={index}>
{renderLines(line)}
@@ -113,18 +202,19 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
className="max-w-[200px] w-full">
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: userSolutions, score: calculateScore(), type})}
onClick={() => onNext({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
</div>
);
}

View File

@@ -8,6 +8,8 @@ import axios from "axios";
import {speakingReverseMarking} from "@/utils/score";
import {Tab} from "@headlessui/react";
import clsx from "clsx";
import Modal from "../Modal";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
@@ -22,9 +24,21 @@ export default function InteractiveSpeaking({
onBack,
}: InteractiveSpeakingExercise & CommonProps) {
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
const [diffNumber, setDiffNumber] = useState(0);
const tooltips: {[key: string]: string} = {
"Grammatical Range and Accuracy":
"Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
"Fluency and Coherence":
"Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
Pronunciation:
"Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
"Lexical Resource":
"Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
};
useEffect(() => {
if (userSolutions && userSolutions.length > 0) {
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(
(values) => {
setSolutionsURL(
@@ -41,7 +55,79 @@ export default function InteractiveSpeaking({
}, [userSolutions]);
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)}>
<>
{userSolutions &&
userSolutions.length > 0 &&
diffNumber !== 0 &&
userSolutions[0].evaluation &&
userSolutions[0].evaluation[`transcript_${diffNumber}`] &&
userSolutions[0].evaluation[`fixed_text_${diffNumber}`] && (
<div className="w-full h-full rounded-xl overflow-hidden flex flex-col mt-4">
<div className="w-full grid grid-cols-2 bg-neutral-100">
<span className="p-3 font-medium text-lg border-r border-r-neutral-200">Transcript</span>
<span className="p-3 font-medium text-lg border-l border-l-neutral-200">Recommended Improvements</span>
</div>
<ReactDiffViewer
styles={{
contentText: {
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px",
},
marker: {display: "none"},
diffRemoved: {padding: "32px 28px"},
diffAdded: {padding: "32px 28px"},
wordRemoved: {padding: "0px", display: "initial"},
wordAdded: {padding: "0px", display: "initial"},
wordDiff: {padding: "0px", display: "initial"},
}}
oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")}
splitView
hideLineNumbers
showDiffOnly={false}
/>
</div>
)}
</>
</Modal>
<div className="flex flex-col h-full w-full gap-8 mb-20">
<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">
@@ -67,10 +153,23 @@ export default function InteractiveSpeaking({
{solutionsURL.map((x, index) => (
<div
key={index}
className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex flex-col gap-4">
<div className="flex gap-8 items-center justify-center py-8">
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
</div>
{userSolutions &&
userSolutions.length > 0 &&
userSolutions[0].evaluation &&
userSolutions[0].evaluation[`transcript_${index + 1}`] &&
userSolutions[0].evaluation[`fixed_text_${index + 1}`] && (
<Button
className="w-full max-w-[180px] !py-2 self-center"
color="pink"
variant="outline"
onClick={() => setDiffNumber(index + 1)}>
View Correction
</Button>
)}
</div>
))}
</div>
@@ -78,16 +177,38 @@ export default function InteractiveSpeaking({
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}>
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div
className={clsx(
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
index === 0 && "tooltip-right",
)}
key={key}
data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade}
</div>
))}
);
})}
</div>
{userSolutions[0].evaluation &&
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? (
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length > 0 ? (
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
General Feedback
</Tab>
<Tab
className={({selected}) =>
clsx(
@@ -99,59 +220,66 @@ export default function InteractiveSpeaking({
}>
Evaluation
</Tab>
{Object.keys(userSolutions[0].evaluation)
.filter((x) => x.startsWith("perfect_answer"))
.map((key, index) => (
<Tab
key={key}
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
selected
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Recommended Answer (Prompt 1)
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Recommended Answer (Prompt 2)
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Recommended Answer (Prompt 3)
Recommended Answer
<br />
(Prompt {index + 1})
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div key={key} className="flex flex-col gap-2">
<div
className={clsx(
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit",
)}
key={key}>
{key}: Level {grade}
</div>
{typeof taskResponse !== "number" && (
<span className="px-2 py-2">{taskResponse.comment}</span>
)}
</div>
);
})}
</div>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation)
.filter((x) => x.startsWith("perfect_answer"))
.map((key, index) => (
<Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer_1!.replaceAll(/\s{2,}/g, "\n\n")}
</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer_2!.replaceAll(/\s{2,}/g, "\n\n")}
</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer_3!.replaceAll(/\s{2,}/g, "\n\n")}
{userSolutions[0].evaluation![`perfect_answer_${index + 1}`].answer.replaceAll(
/\s{2,}/g,
"\n\n",
)}
</span>
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
) : (
@@ -185,7 +313,11 @@ export default function InteractiveSpeaking({
onNext({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}
@@ -193,6 +325,6 @@ export default function InteractiveSpeaking({
Next
</Button>
</div>
</>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import {MatchSentencesExercise} from "@/interfaces/exam";
import {MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
import clsx from "clsx";
import LineTo from "react-lineto";
import {CommonProps} from ".";
@@ -8,6 +8,49 @@ import Icon from "@mdi/react";
import {Fragment} from "react";
import Button from "../Low/Button";
import Xarrow from "react-xarrows";
import useExamStore from "@/stores/examStore";
function QuestionSolutionArea({
question,
userSolution,
}: {
question: MatchSentenceExerciseSentence;
userSolution?: {question: string; option: string};
}) {
return (
<div className="grid grid-cols-3 gap-4">
<div className="flex items-center gap-3 cursor-pointer col-span-2">
<button
className={clsx(
"text-white w-8 h-8 rounded-full z-10",
!userSolution
? "bg-mti-gray-davy"
: userSolution.option.toString() === question.solution.toString()
? "bg-mti-purple"
: "bg-mti-rose",
"transition duration-300 ease-in-out",
)}>
{question.id}
</button>
<span>{question.sentence}</span>
</div>
<div
className={clsx(
"w-56 h-10 border rounded-xl items-center justify-center flex gap-3 px-2",
!userSolution
? "border-mti-gray-davy"
: userSolution.option.toString() === question.solution.toString()
? "border-mti-purple"
: "border-mti-rose",
)}>
<span className="line-through">
{userSolution && userSolution?.option.toString() !== question.solution.toString() && `Paragraph ${userSolution.option}`}
</span>
<span className="font-semibold">Paragraph {question.solution}</span>
</div>
</div>
);
}
export default function MatchSentencesSolutions({
id,
@@ -19,6 +62,8 @@ export default function MatchSentencesSolutions({
onNext,
onBack,
}: MatchSentencesExercise & CommonProps) {
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const calculateScore = () => {
const total = sentences.length;
const correct = userSolutions.filter(
@@ -30,8 +75,26 @@ export default function MatchSentencesSolutions({
};
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
<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">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
@@ -40,57 +103,18 @@ export default function MatchSentencesSolutions({
</Fragment>
))}
</span>
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
<div className="flex flex-col gap-4">
{sentences.map(({sentence, id, solution}) => (
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
<span>{sentence} </span>
<button
id={id}
className={clsx(
"w-8 h-8 rounded-full z-10 text-white",
"transition duration-300 ease-in-out",
!userSolutions.find((x) => x.question.toString() === id.toString()) && "!bg-mti-gray-davy",
userSolutions.find((x) => x.question.toString() === id.toString())?.option === solution && "bg-mti-purple",
userSolutions.find((x) => x.question.toString() === id.toString())?.option !== solution && "bg-mti-rose",
)}>
{id}
</button>
</div>
))}
</div>
<div className="flex flex-col gap-4">
{options.map(({sentence, id}) => (
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
<button
id={id}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
"transition duration-300 ease-in-out",
)}>
{id}
</button>
<span>{sentence}</span>
</div>
))}
</div>
{userSolutions &&
sentences.map((sentence, index) => (
<Xarrow
key={index}
start={sentence.id}
end={sentence.solution}
lineColor={
!userSolutions.find((x) => x.question === sentence.id)
? "#CC5454"
: userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution
? "#7872BF"
: "#CC5454"
}
showHead={false}
{sentences.map((question) => (
<QuestionSolutionArea
question={question}
userSolution={userSolutions.find((x) => x.question.toString() === question.id.toString())}
key={`question_${question.id}`}
/>
))}
</div>
</div>
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
@@ -109,7 +133,8 @@ export default function MatchSentencesSolutions({
color="purple"
variant="outline"
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
</Button>
@@ -120,6 +145,6 @@ export default function MatchSentencesSolutions({
Next
</Button>
</div>
</>
</div>
);
}

View File

@@ -1,52 +1,87 @@
/* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import {MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import {useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import Button from "../Low/Button";
import {v4} from "uuid";
function Question({
id,
variant,
prompt,
solution,
options,
userSolution,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
const {userSolutions} = useExamStore((state) => state);
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
if (foundMap) return foundMap;
return userSolution.shuffleMaps?.find((map) => map.questionID === id) || null;
}, null as ShuffleMap | null);
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u>{word}</u> : null;
});
};
const optionColor = (option: string) => {
if (option === solution && !userSolution) {
return "!border-mti-gray-davy !text-mti-gray-davy";
if (option === newSolution && !userSolution) {
return "!bg-mti-gray-davy !text-white";
}
if (option === solution) {
return "!border-mti-purple-light !text-mti-purple-light";
if (option === newSolution) {
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 (
<div className="flex flex-col items-center gap-4">
<span>{prompt}</span>
<div className="grid grid-cols-4 gap-4 place-items-center">
<div className="flex flex-col gap-4">
{isNaN(Number(id)) ? (
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
) : (
<span className="text-lg" key={v4()}>
<>
{id} -{" "}
<span className="text-lg" key={v4()}>
{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}{" "}
</span>
</>
</span>
)}
<div className="flex flex-wrap gap-4 justify-between">
{variant === "image" &&
options.map((option) => (
<div
key={option.id}
key={option?.id}
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",
optionColor(option.id),
"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),
)}>
<span className={clsx("text-sm", solution !== option.id && userSolution !== option.id && "opacity-50")}>{option.id}</span>
<img src={option.src!} alt={`Option ${option.id}`} />
<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}`} />}
</div>
))}
{variant === "text" &&
options.map((option) => (
<div
key={option.id}
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", optionColor(option.id))}>
<span className="font-semibold">{option.id}.</span>
<span>{option.text}</span>
key={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>{option?.text}</span>
</div>
))}
</div>
@@ -54,37 +89,32 @@ function Question({
);
}
export default function MultipleChoice({
id,
type,
prompt,
questions,
userSolutions,
updateIndex,
onNext,
onBack,
}: MultipleChoiceExercise & CommonProps) {
const [questionIndex, setQuestionIndex] = useState(0);
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const stats = useExamStore((state) => state.userSolutions);
const calculateScore = () => {
const total = questions.length;
const correct = userSolutions.filter(
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
).length;
const questionShuffleMap = stats.find((x) => x.exercise == id)?.shuffleMaps;
const correct = userSolutions.filter((x) => {
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;
return {total, correct, missing};
};
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
const next = () => {
if (questionIndex === questions.length - 1) {
if (questionIndex + 1 >= questions.length - 1) {
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
} else {
setQuestionIndex((prev) => prev + 1);
setQuestionIndex(questionIndex + 2);
}
};
@@ -92,15 +122,31 @@ export default function MultipleChoice({
if (questionIndex === 0) {
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
} else {
setQuestionIndex((prev) => prev - 1);
setQuestionIndex(questionIndex - 2);
}
};
return (
<>
<div className="flex flex-col gap-4 w-full h-full mb-20">
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<span className="text-xl font-semibold">{prompt}</span>
<div className="flex flex-col gap-4">
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={back}
className="max-w-[200px] w-full"
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]}
@@ -108,6 +154,17 @@ export default function MultipleChoice({
/>
)}
</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 className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<div className="w-4 h-4 rounded-full bg-mti-purple" />
@@ -125,7 +182,12 @@ export default function MultipleChoice({
</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">
<Button
color="purple"
variant="outline"
onClick={back}
className="max-w-[200px] w-full"
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
Back
</Button>
@@ -133,6 +195,6 @@ export default function MultipleChoice({
Next
</Button>
</div>
</>
</div>
);
}

View File

@@ -8,14 +8,21 @@ import axios from "axios";
import {speakingReverseMarking} from "@/utils/score";
import {Tab} from "@headlessui/react";
import clsx from "clsx";
import Modal from "../Modal";
import {BsQuestionCircleFill} from "react-icons/bs";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
const [solutionURL, setSolutionURL] = useState<string>();
const [showDiff, setShowDiff] = useState(false);
useEffect(() => {
if (userSolutions && userSolutions.length > 0) {
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
const solution = userSolutions[0].solution;
if (solution.startsWith("https://")) return setSolutionURL(solution);
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
const blob = new Blob([data], {type: "audio/wav"});
const url = URL.createObjectURL(blob);
@@ -25,8 +32,89 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
}
}, [userSolutions]);
const tooltips: {[key: string]: string} = {
"Grammatical Range and Accuracy":
"Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
"Fluency and Coherence":
"Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
Pronunciation:
"Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
"Lexical Resource":
"Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
};
return (
<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)}>
<>
{userSolutions &&
userSolutions.length > 0 &&
userSolutions[0].evaluation?.transcript_1 &&
userSolutions[0].evaluation?.fixed_text_1 && (
<div className="w-full h-full rounded-xl overflow-hidden flex flex-col mt-4">
<div className="w-full grid grid-cols-2 bg-neutral-100">
<span className="p-3 font-medium text-lg border-r border-r-neutral-200">Transcript</span>
<span className="p-3 font-medium text-lg border-l border-l-neutral-200">Recommended Improvements</span>
</div>
<ReactDiffViewer
styles={{
contentText: {
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px",
},
marker: {display: "none"},
diffRemoved: {padding: "32px 28px"},
diffAdded: {padding: "32px 28px"},
wordRemoved: {padding: "0px", display: "initial"},
wordAdded: {padding: "0px", display: "initial"},
wordDiff: {padding: "0px", display: "initial"},
}}
oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")}
splitView
hideLineNumbers
showDiffOnly={false}
/>
</div>
)}
</>
</Modal>
<div className="flex flex-col h-full w-full gap-8 mb-20">
<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">
@@ -65,25 +153,56 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
</div>
</div>
<div className="w-full h-full flex flex-col gap-8">
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<div className="w-full h-full flex flex-col gap-8 relative">
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center relative">
<div className="flex gap-8 items-center justify-center py-8">
{solutionURL && <Waveform audio={solutionURL} waveColor="#FCDDEC" progressColor="#EF5DA8" />}
{userSolutions &&
userSolutions.length > 0 &&
userSolutions[0].evaluation?.transcript_1 &&
userSolutions[0].evaluation?.fixed_text_1 && (
<Button className="w-full max-w-[180px] !py-2" color="pink" variant="outline" onClick={() => setShowDiff(true)}>
View Correction
</Button>
)}
</div>
</div>
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}>
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div
className={clsx(
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
index === 0 && "tooltip-right",
)}
key={key}
data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade}
</div>
))}
);
})}
</div>
{userSolutions[0].evaluation &&
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
General Feedback
</Tab>
<Tab
className={({selected}) =>
clsx(
@@ -108,9 +227,35 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
</Tab>
</Tab.List>
<Tab.Panels>
{/* General Feedback */}
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div key={key} className="flex flex-col gap-2">
<div
className={clsx(
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit",
)}
key={key}>
{key}: Level {grade}
</div>
{typeof taskResponse !== "number" && (
<span className="px-2 py-2">{taskResponse.comment}</span>
)}
</div>
);
})}
</div>
</Tab.Panel>
{/* Evaluation */}
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel>
{/* Recommended Answer */}
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer &&
@@ -152,7 +297,11 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
onNext({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}
@@ -160,6 +309,6 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
Next
</Button>
</div>
</>
</div>
);
}

View File

@@ -4,10 +4,13 @@ import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {Fragment} from "react";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
type Solution = "true" | "false" | "not_given";
export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const calculateScore = () => {
const total = questions.length || 0;
const correct = userSolutions.filter(
@@ -37,8 +40,26 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
};
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
<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">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
@@ -121,7 +142,8 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
color="purple"
variant="outline"
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
</Button>
@@ -132,6 +154,6 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
Next
</Button>
</div>
</>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {toast} from "react-toastify";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
function Blank({
id,
@@ -49,15 +50,11 @@ function Blank({
{userSolution && !isUserSolutionCorrect() && (
<div
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
placeholder={id}
contentEditable={disabled}>
{userSolution}
</div>
)}
<div
className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())}
placeholder={id}
contentEditable={disabled}>
<div className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())} contentEditable={disabled}>
{!solutions ? userInput : solutions.join(" / ")}
</div>
</span>
@@ -75,6 +72,8 @@ export default function WriteBlanksSolutions({
onNext,
onBack,
}: WriteBlanksExercise & CommonProps) {
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter(
@@ -106,8 +105,26 @@ export default function WriteBlanksSolutions({
};
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
<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">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
@@ -146,7 +163,8 @@ export default function WriteBlanksSolutions({
color="purple"
variant="outline"
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
</Button>
@@ -157,6 +175,6 @@ export default function WriteBlanksSolutions({
Next
</Button>
</div>
</>
</div>
);
}

View File

@@ -7,13 +7,64 @@ import {Dialog, Tab, Transition} from "@headlessui/react";
import {writingReverseMarking} from "@/utils/score";
import clsx from "clsx";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
import useUser from "@/hooks/useUser";
import AIDetection from "../AIDetection";
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [showDiff, setShowDiff] = useState(false);
const {user} = useUser();
const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined;
const tooltips: {[key: string]: string} = {
"Lexical Resource":
"Assesses the diversity and accuracy of vocabulary used. A higher score indicates varied and precise word choice; a lower score points to limited vocabulary and inaccuracies.",
"Task Achievement":
"Evaluates how well the task requirements are fulfilled. A higher score means all parts of the task are addressed thoroughly; a lower score shows incomplete or inadequate task response.",
"Coherence and Cohesion":
"Measures logical organization and flow of writing. A higher score reflects well-structured and connected ideas; a lower score indicates disorganized writing and poor linkage between ideas.",
"Grammatical Range and Accuracy":
"Looks at the range and precision of grammatical structures. A higher score shows varied and accurate grammar use; a lower score suggests frequent errors and limited range.",
};
return (
<>
<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 && (
<Transition show={isModalOpen} as={Fragment}>
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
@@ -76,7 +127,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
</div>
</>
)}
{showDiff && (
{showDiff && userSolutions[0].evaluation && (
<>
<span>Correction:</span>
<div className="w-full h-full max-h-[320px] overflow-y-scroll scrollbar-hide cursor-text border-2 overflow-x-hidden border-mti-gray-platinum bg-white rounded-3xl">
@@ -117,15 +168,37 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
<div className="bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2" key={key}>
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div
className={clsx(
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
index === 0 && "tooltip-right",
)}
key={key}
data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade}
</div>
))}
);
})}
</div>
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
)
}>
General Feedback
</Tab>
<Tab
className={({selected}) =>
clsx(
@@ -148,16 +221,60 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
}>
Recommended Answer
</Tab>
{aiEval && user?.type !== "student" && (
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
)
}>
AI Use
</Tab>
)}
</Tab.List>
<Tab.Panels>
{/* Global */}
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return (
<div key={key} className="flex flex-col gap-2">
<div
className={clsx(
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 w-fit",
)}
key={key}>
{key}: Level {grade}
</div>
{typeof taskResponse !== "number" && (
<span className="px-2 py-2">{taskResponse.comment}</span>
)}
</div>
);
})}
</div>
</Tab.Panel>
{/* Evaluation */}
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel>
{/* Recommended Answer */}
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
</span>
</Tab.Panel>
{aiEval && user?.type !== "student" && (
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<AIDetection {...aiEval} />
</Tab.Panel>
)}
</Tab.Panels>
</Tab.Group>
) : (
@@ -191,7 +308,11 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
onNext({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
score: {
total: 100,
missing: 0,
correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
},
type,
})
}
@@ -199,6 +320,6 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
Next
</Button>
</div>
</>
</div>
);
}

View File

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

View File

@@ -0,0 +1,43 @@
import React from "react";
import ExerciseWalkthrough from "@/training/ExerciseWalkthrough";
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
const TrainingExercise: React.FC<ITrainingTip> = (trainingTip: ITrainingTip) => {
const tip = {
"category": "",
"embedding": "",
"text": "",
"html": "",
"id": "",
"verified": true,
"standalone": false,
"exercise": {
"question": "",
"additional": "",
"segments": []
}
}
const mockTip: ITrainingTip = {
id: "some random id",
tipCategory: tip.category,
tipHtml: tip.html,
standalone: tip.standalone,
exercise: {
question: tip.exercise.question,
additional: tip.exercise.additional,
segments: tip.exercise.segments as WalkthroughConfigs[]
}
}
const formattedTip = formatTip(mockTip);
return (
<ExerciseWalkthrough {...formatTip(trainingTip)}
/>
);
}
export default TrainingExercise;

View File

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

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

View File

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

View File

@@ -1,5 +1,5 @@
import useStats from "@/hooks/useStats";
import {EMPLOYMENT_STATUS, User} from "@/interfaces/user";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User, Type, Stat, Gender} from "@/interfaces/user";
import {groupBySession, averageScore} from "@/utils/stats";
import {RadioGroup} from "@headlessui/react";
import axios from "axios";
@@ -8,7 +8,7 @@ import moment from "moment";
import {Divider} from "primereact/divider";
import {useEffect, useState} from "react";
import ReactDatePicker from "react-datepicker";
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
import {BsFileEarmarkText, BsPencil, BsPerson, BsPersonAdd, BsStar} from "react-icons/bs";
import {toast} from "react-toastify";
import Button from "./Low/Button";
import Checkbox from "./Low/Checkbox";
@@ -19,6 +19,11 @@ import Select from "react-select";
import useUsers from "@/hooks/useUsers";
import {USER_TYPE_LABELS} from "@/resources/user";
import {CURRENCIES} from "@/resources/paypal";
import useCodes from "@/hooks/useCodes";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import {PERMISSIONS} from "@/constants/userPermissions";
import {PermissionType} from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions";
const expirationDateColor = (date: Date) => {
const momentDate = moment(date);
@@ -36,7 +41,11 @@ interface Props {
onViewStudents?: () => void;
onViewTeachers?: () => void;
onViewCorporate?: () => void;
maxUserAmount?: number;
disabled?: boolean;
disabledFields?: {
countryManager?: boolean;
};
}
const USER_STATUS_OPTIONS = [
@@ -59,34 +68,67 @@ const USER_TYPE_OPTIONS = Object.keys(USER_TYPE_LABELS).map((type) => ({
label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS],
}));
const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({value: currency, label}));
const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({
value: currency,
label,
}));
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false}: Props) => {
const UserCard = ({
user,
loggedInUser,
maxUserAmount,
onClose,
onViewStudents,
onViewTeachers,
onViewCorporate,
disabled = false,
disabledFields = {},
}: Props) => {
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
const [type, setType] = useState(user.type);
const [status, setStatus] = useState(user.status);
const [referralAgentLabel, setReferralAgentLabel] = useState<string>();
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
const [position, setPosition] = useState<string | 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(
user.type === "corporate"
user.type === "corporate" || user.type === "mastercorporate"
? user.corporateInformation?.companyInformation.name
: user.type === "agent"
? user.agentInformation?.companyName
: undefined,
);
const [arabName, setArabName] = useState(user.type === "agent" ? user.agentInformation?.companyArabName : undefined);
const [commercialRegistration, setCommercialRegistration] = useState(
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
);
const [userAmount, setUserAmount] = useState(user.type === "corporate" ? user.corporateInformation?.companyInformation.userAmount : undefined);
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : "EUR");
const [monthlyDuration, setMonthlyDuration] = useState(user.type === "corporate" ? user.corporateInformation?.monthlyDuration : undefined);
const [commissionValue, setCommission] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.commission : undefined);
const {stats} = useStats(user.id);
const [userAmount, setUserAmount] = useState(
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.companyInformation.userAmount : undefined,
);
const [paymentValue, setPaymentValue] = useState(
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.payment?.value : undefined,
);
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 {codes} = useCodes(user.id);
const {permissions} = usePermissions(loggedInUser.id);
useEffect(() => {
if (users && users.length > 0) {
@@ -101,25 +143,37 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
}, [users, referralAgent]);
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!");
if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
axios
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
...user,
subscriptionExpirationDate: expiryDate,
studentID,
type,
status,
name,
demographicInformation: {
...(!!user.demographicInformation ? user.demographicInformation : {}),
phone,
},
agentInformation:
type === "agent"
? {
name: companyName,
companyName,
commercialRegistration,
arabName,
}
: undefined,
corporateInformation:
type === "corporate"
type === "corporate" || type === "mastercorporate"
? {
referralAgent,
monthlyDuration,
@@ -144,11 +198,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
});
};
return (
<>
<ProfileSummary
user={user}
items={[
const generalProfileItems = [
{
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: Object.keys(groupBySession(stats)).length,
@@ -157,25 +207,61 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
{
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: stats.length,
label: "Exercises",
label: "Modules",
},
{
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score",
},
]}
];
const corporateProfileItems =
user.type === "corporate" || user.type === "mastercorporate"
? [
{
icon: <BsPerson className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: codes.length,
label: "Users Used",
},
{
icon: <BsPersonAdd className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: user.corporateInformation?.companyInformation?.userAmount,
label: "Number of Users",
},
]
: [];
const updateUserPermission = PERMISSIONS.updateUser[user.type] as {
list: Type[];
perm: PermissionType;
};
return (
<>
<ProfileSummary
user={user}
items={user.type === "corporate" || user.type === "mastercorporate" ? corporateProfileItems : generalProfileItems}
/>
{user.type === "agent" && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 w-full">
<Input
label="Corporate Name"
label="Company Name (Arabic)"
type="text"
name="arabName"
onChange={setArabName}
placeholder="Enter their company's name in arabic"
defaultValue={arabName}
required
disabled={disabled}
/>
<Input
label="Company Name (English)"
type="text"
name="companyName"
onChange={setCompanyName}
placeholder="Enter corporate name"
placeholder="Enter their company's name in english"
defaultValue={companyName}
required
disabled={disabled}
@@ -194,7 +280,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<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">
<Input
@@ -204,16 +290,31 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
onChange={setCompanyName}
placeholder="Enter corporate name"
defaultValue={companyName}
disabled={disabled}
disabled={
disabled ||
checkAccess(
loggedInUser,
getTypesOfUser(
user.type === "mastercorporate" ? ["developer", "admin"] : ["developer", "admin", "mastercorporate"],
),
)
}
/>
<Input
label="Number of Users"
type="number"
name="userAmount"
max={maxUserAmount}
onChange={(e) => setUserAmount(e ? parseInt(e) : undefined)}
placeholder="Enter number of users"
defaultValue={userAmount}
disabled={disabled}
disabled={
disabled ||
checkAccess(
loggedInUser,
getTypesOfUser(["developer", "admin", ...((user.type === "corporate" ? ["mastercorporate"] : []) as Type[])]),
)
}
/>
<Input
label="Monthly Duration"
@@ -222,28 +323,30 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
onChange={(e) => setMonthlyDuration(e ? parseInt(e) : undefined)}
placeholder="Enter monthly duration"
defaultValue={monthlyDuration}
disabled={disabled}
disabled={disabled || checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"]))}
/>
<div className="flex flex-col gap-3 w-full lg:col-span-2">
<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>
<div className="w-full grid grid-cols-5 gap-2">
<div className="w-full grid grid-cols-6 gap-2">
<Input
name="paymentValue"
onChange={(e) => setPaymentValue(e ? parseInt(e) : undefined)}
type="number"
defaultValue={paymentValue || 0}
className="col-span-3"
disabled={disabled}
disabled={disabled || checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"]))}
/>
<Select
className={clsx(
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
"px-4 py-4 col-span-3 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
)}
options={CURRENCIES_OPTIONS}
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
onChange={(value) => setPaymentCurrency(value?.value)}
menuPortalTarget={document?.body}
styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({
...styles,
paddingLeft: "4px",
@@ -259,7 +362,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
color: state.isFocused ? "black" : styles.color,
}),
}}
isDisabled={disabled}
isDisabled={disabled || checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"]))}
/>
</div>
</div>
@@ -271,19 +374,26 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<Select
className={clsx(
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
!["developer", "admin"].includes(loggedInUser.type) &&
(checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"])) || disabledFields.countryManager) &&
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
)}
options={[
{value: "", label: "No referral"},
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
...users
.filter((u) => u.type === "agent")
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
})),
]}
defaultValue={{
value: referralAgent,
label: referralAgentLabel,
}}
menuPortalTarget={document?.body}
onChange={(value) => setReferralAgent(value?.value)}
styles={{
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({
...styles,
paddingLeft: "4px",
@@ -300,7 +410,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
}),
}}
// editing country manager should only be available for dev/admin
isDisabled={!["developer", "admin"].includes(loggedInUser.type)}
isDisabled={checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"])) || disabledFields.countryManager}
/>
)}
</div>
@@ -314,7 +424,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
type="number"
defaultValue={commissionValue || 0}
className="col-span-3"
disabled={disabled}
disabled={disabled || loggedInUser.type === "agent"}
/>
</>
) : (
@@ -331,10 +441,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
label="Name"
type="text"
name="name"
onChange={() => null}
onChange={setName}
placeholder="Enter your name"
defaultValue={user.name}
disabled
defaultValue={name}
disabled={disabled}
/>
<Input
label="E-mail Address"
@@ -356,14 +466,15 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
type="tel"
name="phone"
label="Phone number"
onChange={() => null}
onChange={setPhone}
placeholder="Enter phone number"
defaultValue={user.demographicInformation?.phone}
disabled
defaultValue={phone}
disabled={disabled}
/>
</div>
{user.type === "student" && (
<div className="flex flex-col md:flex-row gap-8 w-full">
<Input
type="text"
name="passport_id"
@@ -374,10 +485,20 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
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">
{user.type !== "corporate" && (
{user.type !== "corporate" && user.type !== "mastercorporate" && (
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
<RadioGroup
@@ -403,12 +524,12 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
</RadioGroup>
</div>
)}
{user.type === "corporate" && (
{(user.type === "corporate" || user.type === "mastercorporate") && (
<Input
name="position"
onChange={setPosition}
type="text"
label="Position"
label="Department"
defaultValue={position}
placeholder="CEO, Head of Marketing..."
disabled
@@ -419,7 +540,8 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
<RadioGroup
value={user.demographicInformation?.gender}
value={gender}
onChange={(e) => setGender(e)}
className="flex flex-row gap-4 justify-between"
disabled={disabled}>
<RadioGroup.Option value="male">
@@ -472,7 +594,11 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<Checkbox
isChecked={!!expiryDate}
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
disabled={disabled}>
disabled={
disabled ||
(!["admin", "developer", "mastercorporate", "corporate"].includes(loggedInUser.type) &&
!!loggedInUser.subscriptionExpirationDate)
}>
Enabled
</Checkbox>
</div>
@@ -511,7 +637,12 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
</div>
</div>
</div>
{(loggedInUser.type === "developer" || loggedInUser.type === "admin") && (
{checkAccess(
loggedInUser,
["developer", "admin", "corporate", "mastercorporate"],
permissions,
user.type === "teacher" ? "editTeacher" : user.type === "student" ? "editStudent" : undefined,
) && (
<>
<Divider className="w-full !m-0" />
<div className="flex flex-col md:flex-row gap-8 w-full">
@@ -519,7 +650,11 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<label className="font-normal text-base text-mti-gray-dim">Status</label>
<Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={USER_STATUS_OPTIONS}
options={USER_STATUS_OPTIONS.filter((x) => {
if (checkAccess(loggedInUser, ["admin", "developer"])) return true;
return x.value !== "paymentDue";
})}
menuPortalTarget={document?.body}
value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
onChange={(value) => setStatus(value?.value as typeof user.status)}
styles={{
@@ -532,6 +667,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
outline: "none",
},
}),
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -545,7 +681,29 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<label className="font-normal text-base text-mti-gray-dim">Type</label>
<Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={USER_TYPE_OPTIONS}
options={USER_TYPE_OPTIONS.filter((x) => {
if (x.value === "student")
return checkAccess(
loggedInUser,
["developer", "admin", "corporate", "mastercorporate"],
permissions,
"editStudent",
);
if (x.value === "teacher")
return checkAccess(
loggedInUser,
["developer", "admin", "corporate", "mastercorporate"],
permissions,
"editTeacher",
);
if (x.value === "corporate")
return checkAccess(loggedInUser, ["developer", "admin", "mastercorporate"], permissions, "editCorporate");
return checkAccess(loggedInUser, ["developer", "admin"]);
})}
menuPortalTarget={document?.body}
value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
onChange={(value) => setType(value?.value as typeof user.type)}
styles={{
@@ -558,6 +716,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
outline: "none",
},
}),
menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -574,17 +733,17 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<div className="flex gap-4 justify-between mt-4 w-full">
<div className="self-start flex gap-4 justify-start items-center w-full">
{onViewCorporate && (
{onViewCorporate && ["student", "teacher"].includes(user.type) && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}>
View Corporate
</Button>
)}
{onViewStudents && (
{onViewStudents && ["corporate", "teacher"].includes(user.type) && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
View Students
</Button>
)}
{onViewTeachers && (
{onViewTeachers && ["student", "corporate"].includes(user.type) && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewTeachers}>
View Teachers
</Button>
@@ -594,7 +753,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}>
Close
</Button>
<Button disabled={disabled} onClick={updateUser} className="w-full max-w-[200px]">
<Button
disabled={disabled || !checkAccess(loggedInUser, updateUserPermission.list, permissions, updateUserPermission.perm)}
onClick={updateUser}
className="w-full max-w-[200px]">
Update
</Button>
</div>

View File

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

View File

@@ -1,4 +1,4 @@
export type Error = "E001" | "E002";
export type Error = "E001" | "E002" | "E003";
export interface ErrorMessage {
error: Error;
message: string;
@@ -7,4 +7,5 @@ export interface ErrorMessage {
export const errorMessages: {[key in Error]: string} = {
E001: "Wrong password!",
E002: "Invalid e-mail",
E003: "E-mail already in use!",
};

View File

@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "storied-phalanx-349916",
"private_key_id": "c9e05f6fe413b1031a71f981160075ff4b044444",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdgavFB63nMHyb\n38ncwijTrUmqU9UyzNJ8wlZCWAWuoz25Gng988fkKNDXnHY+ap9esHyNYg9IdSA7\nAuZeHpzTZmKiWZzFWq61KWSTgIn1JwKHGHJJdmVhTYfCe9I51cFLa5q2lTFzJ0ce\nbP7/X/7kw53odgva+M8AhDTbe60akpemgZc+LFwO0Abm7erH2HiNyjoNZzNw525L\n933PCaQwhZan04s1u0oRdVlBIBwMk+J0ojgVEpUiJOzF7gkN+UpDXujalLYdlR4q\nhkGgScXQhDYJkECC3GuvOnEo1YXGNjW9D73S6sSH+Lvqta4wW1+sTn0kB6goiQBI\n7cA1G6x3AgMBAAECggEAZPMwAX/adb7XS4LWUNH8IVyccg/63kgSteErxtiu3kRv\nYOj7W+C6fPVNGLap/RBCybjNSvIh3PfkVICh1MtG1eGXmj4VAKyvaskOmVq/hQbe\nVAuEKo7W7V2UPcKIsOsGSQUlYYjlHIIOG4O5Q1HQrRmp4cPK62Txkl6uaEkZPz4u\nbvIK2BJI8aHRwxE3Phw09blwlLqQQQ8nrhK29x5puaN+ft++IlzIOVsLz+n4kTdB\n6qkG/dhenn3K8o3+NkmSN6eNRbdJd36zXTo4Oatbvqb7r0E8vYn/3Llawo2X75zn\nec7jMHrOmcwtiu9H3PsrTWtzdSjxPHy0UtEn1HWK4QKBgQD+c/V8tAvbaUGVoZf6\ntKtDSKF6IHuY2vUO33v950mVdjrTursqOG2d+SLfSnKpc+sjDlj7/S5u4uRP+qUN\ng1rb2U7oIA7tsDa2ZTSkIx6HkPUzS+fBOxELLrbgMoJ2RLzgkiPhS95YgXJ/rYG5\nWQTehzCT5roes0RvtgM0gl3EhQKBgQDe2m7PRIU4g3RJ8HTx92B4ja8W9FVCYDG5\nPOAdZB8WB6Bvu4BJHBDLr8vDi930pKj+vYObRqBDQuILW4t8wZQJ834dnoq6EpUz\nhbVEURVBP4A/nEHrQHfq0Lp+cxThy2rw7obRQOLPETtC7p3WFgSHT6PRTcpGzCCX\n+76a30yrywKBgC/5JNtyBppDaf4QDVtTHMb+tpMT9LmI7pLzR6lDJfhr5gNtPURk\nhyY1hoGaw6t3E2n0lopL3alCVdFObDfz//lbKylQggAGLQqOYjJf/K2KgvA862Df\nBgOZtxjl7PrnUsT0SJd9elotbazsxXxwcB6UVnBMG+MV4V0+b7RCr/MRAoGBAIfp\nTcVIs7roqOZjKN9dEE/VkR/9uXW2tvyS/NfP9Ql5c0ZRYwazgCbJOwsyZRZLyek6\naWYsp5b91mA435QhdwiuoI6t30tmA+qdNBTLIpxdfvjMcoNoGPpzfBmcU/L1HW58\n+mnqGalRiAPlBQvI99ASKQWAXMnaulIWrYNEhj0LAoGBALi+QZ2pp+hDeC59ezWr\nbP1zbbONceHKGgJcevChP2k1OJyIOIqmBYeTuM4cPc5ofZYQNaMC31cs8SVeSRX1\nNTxQZmvCjMyTe/WYWYNFXdgkVz4egFXbeochCGzMYo57HV1PCkPBrARRZO8OfdDD\n8sDu//ohb7nCzceEI0DnWs13\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-3ml0u@storied-phalanx-349916.iam.gserviceaccount.com",
"client_id": "114163760341944984396",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-3ml0u%40storied-phalanx-349916.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

@@ -1,13 +0,0 @@
{
"type": "service_account",
"project_id": "mti-ielts",
"private_key_id": "22b783a14c760d1215a8d1f5de0fa40a33a840e7",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDoNkd7s/izUBRb\nlmJYWl0xk4X9wEVJU4LKA4HPeha8RFDse4T4suVP08oCP9ODSXF5A83+IqXNMs/N\na7PtFABBAx433JrB7I4NsAUrDSjI4LeYEIqh6YzHsQvBU53HAmPChX525S4i0IBy\ncNnyXut0nmlHz5ZwCPXgqg4eN44C+m0f7sxzivcnPth/zLupnMiDAHFZrxQolWO2\n6JfozMWGw0TmCkUxngzeGBMVYmsGiKRIxEi3MWeuwjYjGO4nR1krEUlcpjCbx4UX\nxYXicJb17HOs9LTcSh9bpDWZPHKXR48hxd2cMLr+XQzw7Otwu2p8fEUOJ+CiTyNz\nlkN9p7OhAgMBAAECggEAB5DsMZdGu1X4wdazr+AK4RCG2UKkZ0wbqvgkCMX4O2xo\n7BmmtqFCmEAk+P+KJWEVW81wTu9jUl0tWOrBVzBThUrEF2seVkL+SmshsfpI6cmr\npb5lO/sTgZau1L7kGU3GQRpvKVHUl+EODFyJt2xZFOjL8qFsjAw4sbgsw1aJT6a4\nFilm6Gapi1qSKOPSlXVmi0NJ9DUtNbKaQK8/coqEJRizeXs9MORvzyKQaV8PBmWI\noEnkxahKOD48U2kmI7rT9/YsCuaP2BlGdLxvANXLjAKcrDccVZkYEH82tPtCicED\noow3i956HPdWSXQgUOU65MfGccjOmqGaGa4zUTICyQKBgQD6zLMwL9YS+n9EKZaK\nEbzRybN2d+eKbXyDJzkDi6FnSGVre2ndShsimoOtwZDLmOF/XhN79YOLJVbI124p\npAWO+WxAfe9Xy3iFEBmL4kSREA873Sd8EN5OfYS2DsN7IbjZkoaLuM8QlyXL9ZRS\nBJDVGjx+wFKRjnClcBNbVMMXiQKBgQDtBumKZS0ZCtJuBeuwLGJ1ZJtYECykIrsD\nUtQ7zxwXJzPGqZ2c5JLpHdDm/bb9nllpLsh4SpDRqxFa2H2FF8x5KWaS7JQUsS8e\ner6x5wUt6wAJqV/ZvttVrLZCa8VYn+K7bTANnkPNJZHTqBTJbxkXMDTtkwWXUN2z\nQP3N9lodWQKBgFBHiewYw9ubV3WIImnbt6cne0ymoPUMitioi3V5Epcu81fuTzrI\nZ9sxvoi19xVUwIm2oWICerLlptvvKZImsKjNajtSlHRz6wYc2zCNowkULOwqpGLw\nO1jAkOR94VDewH7UikDbTVywJSceWvXOBFZSaZ7hDQ0OnTw3ndqUTUaRAoGAd2BG\n2PPyDa28o7sJpBYGlJdSAb1LrnLre1YJHAJIZITS99hPUEhykUP6BYx80CkjYO01\n/BeZ7m9Y80cbmJ+O1Or8BT1vqyg90f0B8/mlSyYTQ8pxQupz7ydoN/WtU+BawgjQ\n7drqzPSCCHab2YPBwEMANTMZ2sbYkcJG0aekZSkCgYBbnFJm8kUy57isxHyvrci+\nR30KQl2Y9okPytF8PpLH+yNjLDoduTOHL/hZoFC0M4Gklx4wPKpsEhImIrWmG9VC\n0UrQC6TT1WoY6/S3YehVmTXo/nBPD1XTUcbF/xxUrWDjmMjnt1IlXBbIzUPD3U4P\niRXzHnXb7yi+/iRxSDts2w==\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-dyg6p@mti-ielts.iam.gserviceaccount.com",
"client_id": "104980563453519094431",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-dyg6p%40mti-ielts.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

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

View File

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

View File

@@ -1,19 +1,31 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers from "@/hooks/useUsers";
import {User} from "@/interfaces/user";
import {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, BsBriefcaseFill, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPencilSquare, BsBank, BsCurrencyDollar} from "react-icons/bs";
import {
BsArrowLeft,
BsBriefcaseFill,
BsGlobeCentralSouthAsia,
BsPerson,
BsPersonFill,
BsPencilSquare,
BsBank,
BsCurrencyDollar,
BsLayoutWtf,
BsLayoutSidebar,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import IconCard from "./IconCard";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
import CorporateStudentsLevels from "./CorporateStudentsLevels";
interface Props {
user: User;
@@ -24,23 +36,21 @@ export default function AdminDashboard({user}: Props) {
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const {stats} = useStats(user.id);
const {users, reload} = useUsers();
const {groups} = useGroups();
const {users, reload, isLoading} = useUsers();
const {groups} = useGroups({});
const {pending, done} = usePaymentStatusUsers();
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
useEffect(() => {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
setShowModal(!!selectedUser && router.asPath === "/#");
}, [selectedUser, router.asPath]);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(reload, [page]);
const inactiveCountryManagerFilter = (x: User) =>
x.type === "agent" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
const inactiveCountryManagerFilter = (x: User) => x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate);
const UserDisplay = (displayUser: User) => (
<div
@@ -60,168 +70,206 @@ export default function AdminDashboard({user}: Props) {
const StudentsList = () => {
const filter = (x: User) =>
x.type === "student" &&
(!!selectedUser
!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id)
: true);
: true;
return (
<>
<UserList
user={user}
type="student"
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<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">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
<h2 className="text-2xl font-semibold">Students ({total})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
)}
/>
);
};
const TeachersList = () => {
const filter = (x: User) =>
x.type === "teacher" &&
(!!selectedUser
!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id) || false
: true);
: true;
return (
<>
<UserList
user={user}
type="teacher"
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<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">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
)}
/>
);
};
const AgentsList = () => {
const filter = (x: User) => x.type === "agent";
return (
<>
<UserList
user={user}
type="agent"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<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">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Country Managers ({users.filter(filter).length})</h2>
<h2 className="text-2xl font-semibold">Country Managers ({total})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
)}
/>
);
};
const CorporateList = () => (
<>
<UserList
user={user}
type="corporate"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<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">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Corporate ({users.filter((x) => x.type === "corporate").length})</h2>
<h2 className="text-2xl font-semibold">Corporate ({total})</h2>
</div>
<UserList user={user} filters={[(x) => x.type === "corporate"]} />
</>
)}
/>
);
const CorporatePaidStatusList = ({paid}: {paid: Boolean}) => {
const list = paid ? done : pending;
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
const filter = (x: User) => list.includes(x.id);
return (
<>
<UserList
user={user}
type="corporate"
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<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">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2>
<h2 className="text-2xl font-semibold">
{paid ? "Payment Done" : "Pending Payment"} ({total})
</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
)}
/>
);
};
const InactiveCountryManagerList = () => {
return (
<>
<UserList
user={user}
type="agent"
filters={[inactiveCountryManagerFilter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<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">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Inactive Country Managers ({users.filter(inactiveCountryManagerFilter).length})</h2>
<h2 className="text-2xl font-semibold">Inactive Country Managers ({total})</h2>
</div>
<UserList user={user} filters={[inactiveCountryManagerFilter]} />
</>
)}
/>
);
};
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 (
<>
<UserList
user={user}
type="student"
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<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">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Inactive Students ({users.filter(filter).length})</h2>
<h2 className="text-2xl font-semibold">Inactive Students ({total})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
)}
/>
);
};
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 (
<>
<UserList
user={user}
filters={[filter]}
type="corporate"
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<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">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(filter).length})</h2>
<h2 className="text-2xl font-semibold">Inactive Corporate ({total})</h2>
</div>
)}
/>
);
};
<UserList user={user} filters={[filter]} />
const CorporateStudentsLevelsHelper = () => {
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">Corporate Students Levels</h2>
</div>
<CorporateStudentsLevels />
</>
);
};
@@ -231,41 +279,47 @@ 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">
<IconCard
Icon={BsPersonFill}
isLoading={isLoading}
label="Students"
value={users.filter((x) => x.type === "student").length}
onClick={() => setPage("students")}
onClick={() => router.push("/#students")}
color="purple"
/>
<IconCard
Icon={BsPencilSquare}
isLoading={isLoading}
label="Teachers"
value={users.filter((x) => x.type === "teacher").length}
onClick={() => setPage("teachers")}
onClick={() => router.push("/#teachers")}
color="purple"
/>
<IconCard
Icon={BsBank}
isLoading={isLoading}
label="Corporate"
value={users.filter((x) => x.type === "corporate").length}
onClick={() => setPage("corporate")}
onClick={() => router.push("/#corporate")}
color="purple"
/>
<IconCard
Icon={BsBriefcaseFill}
isLoading={isLoading}
label="Country Managers"
value={users.filter((x) => x.type === "agent").length}
onClick={() => setPage("agents")}
onClick={() => router.push("/#agents")}
color="purple"
/>
<IconCard
Icon={BsGlobeCentralSouthAsia}
isLoading={isLoading}
label="Countries"
value={[...new Set(users.filter((x) => x.demographicInformation).map((x) => x.demographicInformation?.country))].length}
color="purple"
/>
<IconCard
onClick={() => setPage("inactiveStudents")}
onClick={() => router.push("/#inactiveStudents")}
Icon={BsPersonFill}
isLoading={isLoading}
label="Inactive Students"
value={
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
@@ -274,15 +328,17 @@ export default function AdminDashboard({user}: Props) {
color="rose"
/>
<IconCard
onClick={() => setPage("inactiveCountryManagers")}
onClick={() => router.push("/#inactiveCountryManagers")}
Icon={BsBriefcaseFill}
isLoading={isLoading}
label="Inactive Country Managers"
value={users.filter(inactiveCountryManagerFilter).length}
color="rose"
/>
<IconCard
onClick={() => setPage("inactiveCorporate")}
onClick={() => router.push("/#inactiveCorporate")}
Icon={BsBank}
isLoading={isLoading}
label="Inactive Corporate"
value={
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
@@ -291,19 +347,35 @@ export default function AdminDashboard({user}: Props) {
color="rose"
/>
<IconCard
onClick={() => setPage("paymentdone")}
onClick={() => router.push("/#paymentdone")}
Icon={BsCurrencyDollar}
isLoading={isLoading}
label="Payment Done"
value={done.length}
color="purple"
/>
<IconCard
onClick={() => setPage("paymentpending")}
onClick={() => router.push("/#paymentpending")}
Icon={BsCurrencyDollar}
isLoading={isLoading}
label="Pending Payment"
value={pending.length}
color="rose"
/>
<IconCard
onClick={() => router.push("https://cms.encoach.com/admin")}
Icon={BsLayoutSidebar}
isLoading={isLoading}
label="Content Management System (CMS)"
color="green"
/>
<IconCard
onClick={() => router.push("/#corporatestudentslevels")}
Icon={BsPersonFill}
isLoading={isLoading}
label="Corporate Students Levels"
color="purple"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
@@ -318,12 +390,27 @@ export default function AdminDashboard({user}: Props) {
))}
</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((x) => x.type === "teacher")
.sort((a, b) => {
return 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 corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter((x) => x.type === "corporate")
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.sort((a, b) => {
return dateSorter(a, b, "desc", "registrationDate");
})
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
@@ -356,7 +443,7 @@ export default function AdminDashboard({user}: Props) {
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Country Manager 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">
{users
.filter(
@@ -371,6 +458,22 @@ export default function AdminDashboard({user}: Props) {
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Country Manager expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
x.type === "agent" &&
x.subscriptionExpirationDate &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Corporate expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
@@ -400,7 +503,7 @@ export default function AdminDashboard({user}: Props) {
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Country Manager</span>
<span className="p-4">Expired Teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
@@ -411,6 +514,18 @@ export default function AdminDashboard({user}: Props) {
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Country Manager</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) => x.type === "agent" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
@@ -506,16 +621,17 @@ export default function AdminDashboard({user}: Props) {
)}
</>
</Modal>
{page === "students" && <StudentsList />}
{page === "teachers" && <TeachersList />}
{page === "corporate" && <CorporateList />}
{page === "agents" && <AgentsList />}
{page === "inactiveStudents" && <InactiveStudentsList />}
{page === "inactiveCorporate" && <InactiveCorporateList />}
{page === "inactiveCountryManagers" && <InactiveCountryManagerList />}
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
{page === "" && <DefaultDashboard />}
{router.asPath === "/#students" && <StudentsList />}
{router.asPath === "/#teachers" && <TeachersList />}
{router.asPath === "/#corporate" && <CorporateList />}
{router.asPath === "/#agents" && <AgentsList />}
{router.asPath === "/#inactiveStudents" && <InactiveStudentsList />}
{router.asPath === "/#inactiveCorporate" && <InactiveCorporateList />}
{router.asPath === "/#inactiveCountryManagers" && <InactiveCountryManagerList />}
{router.asPath === "/#paymentdone" && <CorporatePaidStatusList paid={true} />}
{router.asPath === "/#paymentpending" && <CorporatePaidStatusList paid={false} />}
{router.asPath === "/#corporatestudentslevels" && <CorporateStudentsLevelsHelper />}
{router.asPath === "/" && <DefaultDashboard />}
</>
);
}

View File

@@ -1,8 +1,8 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import useUsers from "@/hooks/useUsers";
import { User} from "@/interfaces/user";
import {Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
@@ -12,7 +12,7 @@ import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import IconCard from "./IconCard";
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
interface Props {
user: User;
@@ -23,9 +23,8 @@ export default function AgentDashboard({user}: Props) {
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const {stats} = useStats();
const {data: stats} = useFilterRecordsByUser<Stat[]>();
const {users, reload} = useUsers();
const {groups} = useGroups(user.id);
const {pending, done} = usePaymentStatusUsers();
useEffect(() => {
@@ -38,7 +37,7 @@ export default function AgentDashboard({user}: Props) {
const inactiveReferredCorporateFilter = (x: User) =>
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
const UserDisplay = ({ displayUser, allowClick = true }: {displayUser: User, allowClick?: boolean}) => (
const UserDisplay = ({displayUser, allowClick = true}: {displayUser: User; allowClick?: boolean}) => (
<div
onClick={() => allowClick && setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
@@ -56,7 +55,10 @@ export default function AgentDashboard({user}: Props) {
const ReferredCorporateList = () => {
return (
<>
<UserList
user={user}
filters={[referredCorporateFilter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
@@ -64,17 +66,19 @@ export default function AgentDashboard({user}: Props) {
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(referredCorporateFilter).length})</h2>
<h2 className="text-2xl font-semibold">Referred Corporate ({total})</h2>
</div>
<UserList user={user} filters={[referredCorporateFilter]} />
</>
)}
/>
);
};
const InactiveReferredCorporateList = () => {
return (
<>
<UserList
user={user}
filters={[inactiveReferredCorporateFilter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
@@ -82,11 +86,10 @@ export default function AgentDashboard({user}: Props) {
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Inactive Referred Corporate ({users.filter(inactiveReferredCorporateFilter).length})</h2>
<h2 className="text-2xl font-semibold">Inactive Referred Corporate ({total})</h2>
</div>
<UserList user={user} filters={[inactiveReferredCorporateFilter]} />
</>
)}
/>
);
};
@@ -94,7 +97,10 @@ export default function AgentDashboard({user}: Props) {
const filter = (x: User) => x.type === "corporate";
return (
<>
<UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
@@ -102,10 +108,10 @@ export default function AgentDashboard({user}: Props) {
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Corporate ({users.filter(filter).length})</h2>
<h2 className="text-2xl font-semibold">Corporate ({total})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
)}
/>
);
};
@@ -114,7 +120,10 @@ export default function AgentDashboard({user}: Props) {
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
return (
<>
<UserList
user={user}
filters={[filter]}
renderHeader={(total) => (
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
@@ -122,10 +131,12 @@ export default function AgentDashboard({user}: Props) {
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2>
<h2 className="text-2xl font-semibold">
{paid ? "Payment Done" : "Pending Payment"} ({total})
</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
)}
/>
);
};
@@ -153,13 +164,7 @@ export default function AgentDashboard({user}: Props) {
value={users.filter(corporateFilter).length}
color="purple"
/>
<IconCard
onClick={() => setPage("paymentdone")}
Icon={BsCurrencyDollar}
label="Payment Done"
value={done.length}
color="purple"
/>
<IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
<IconCard
onClick={() => setPage("paymentpending")}
Icon={BsCurrencyDollar}

View File

@@ -7,15 +7,48 @@ import clsx from "clsx";
import moment from "moment";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import {usePDFDownload} from "@/hooks/usePDFDownload";
import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
import {uniqBy} from "lodash";
import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
import {useAssignmentRelease} from "@/hooks/useAssignmentRelease";
import {getUserName} from "@/utils/users";
import {User} from "@/interfaces/user";
interface Props {
users: User[];
onClick?: () => void;
allowDownload?: boolean;
reload?: Function;
allowArchive?: boolean;
allowUnarchive?: boolean;
allowExcelDownload?: boolean;
}
export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick, allowDownload}: Assignment & Props) {
const {users} = useUsers();
export default function AssignmentCard({
id,
name,
assigner,
startDate,
endDate,
assignees,
results,
exams,
archived,
onClick,
allowDownload,
reload,
allowArchive,
allowUnarchive,
allowExcelDownload,
users,
released,
}: Assignment & Props) {
const renderPdfIcon = usePDFDownload("assignments");
const renderExcelIcon = usePDFDownload("assignments", "excel");
const renderArchiveIcon = useAssignmentArchive(id, reload);
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
const renderReleaseIcon = useAssignmentRelease(id, reload);
const calculateAverageModuleScore = (module: Module) => {
const resultModuleBandScores = results.map((r) => {
@@ -29,14 +62,44 @@ export default function AssignmentCard({id, name, assigner, startDate, endDate,
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 (
<div
onClick={onClick}
className="w-[350px] h-fit flex flex-col gap-6 bg-white border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
className="border-mti-gray-platinum flex h-fit w-[350px] cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
<div className="flex flex-col gap-3">
<div className="flex flex-row justify-between">
<h3 className="font-semibold text-xl">{name}</h3>
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
<h3 className="text-xl font-semibold">{name}</h3>
<div className="flex gap-2">
{shouldRenderPDF() && renderPdfIcon(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")}
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
{!released && renderReleaseIcon("text-mti-gray-dim", "text-mti-gray-dim")}
</div>
</div>
<ProgressBar
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
@@ -46,28 +109,31 @@ export default function AssignmentCard({id, name, assigner, startDate, endDate,
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
/>
</div>
<span className="flex gap-1 justify-between">
<div className="flex flex-col gap-1">
<span className="flex justify-between gap-1">
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span>
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
{exams.map(({module}) => (
<span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span>
</div>
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{uniqModules.map(({module}) => (
<div
key={module}
className={clsx(
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
"-md:px-4 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="w-4 h-4" />}
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
{module === "writing" && <BsPen className="w-4 h-4" />}
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
{module === "level" && <BsClipboard className="w-4 h-4" />}
{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>
)}

View File

@@ -2,7 +2,7 @@ import Input from "@/components/Low/Input";
import Modal from "@/components/Modal";
import {Module} from "@/interfaces";
import clsx from "clsx";
import {useState} from "react";
import {useEffect, useMemo, useState} from "react";
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {generate} from "random-words";
import {capitalize} from "lodash";
@@ -16,32 +16,62 @@ import moment from "moment";
import axios from "axios";
import {getExam} from "@/utils/exams";
import {toast} from "react-toastify";
import {uuidv4} from "@firebase/util";
import {Assignment} from "@/interfaces/results";
import Checkbox from "@/components/Low/Checkbox";
import {InstructorGender, Variant} from "@/interfaces/exam";
import Select from "@/components/Low/Select";
import useExams from "@/hooks/useExams";
interface Props {
isCreating: boolean;
assigner: string;
users: User[];
user: User;
groups: Group[];
assignment?: Assignment;
cancelCreation: () => void;
}
export default function AssignmentCreator({isCreating, assignment, assigner, groups, users, cancelCreation}: Props) {
export default function AssignmentCreator({isCreating, assignment, user, groups, users, cancelCreation}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []);
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 [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>(
assignment ? moment(assignment.startDate).toDate() : moment().hours(0).minutes(0).add(1, "day").toDate(),
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 [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "hour").toDate());
const [endDate, setEndDate] = useState<Date | null>(
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
);
const [variant, setVariant] = useState<Variant>("full");
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
// creates a new exam for each assignee or just one exam for all assignees
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 [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]);
const {exams} = useExams();
const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]);
const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]);
useEffect(() => {
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
}, [selectedModules]);
const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module);
@@ -52,6 +82,10 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
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 = () => {
setIsLoading(true);
@@ -59,9 +93,16 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
assignees,
name,
startDate,
examIDs: !useRandomExams ? examIDs : undefined,
endDate,
selectedModules,
generateMultiple,
teachers,
variant,
instructorGender,
released,
autoStart,
autoStartDate,
})
.then(() => {
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
@@ -93,15 +134,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 (
<Modal isOpen={isCreating} onClose={cancelCreation} title="New Assignment">
<div className="w-full flex flex-col gap-4">
<section className="w-full grid -md:grid-cols-1 md:grid-cols-2 place-items-center lg:grid-cols-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
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
@@ -118,7 +176,6 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
@@ -131,11 +188,30 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={
(!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
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("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">
@@ -152,7 +228,6 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : 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("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">
@@ -165,34 +240,13 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
? () => toggleModule("level")
: undefined
}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-3",
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsClipboard className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Level</span>
{!selectedModules.includes("level") && selectedModules.length === 0 && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
</section>
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required />
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<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
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
@@ -200,7 +254,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterDate={(date) => moment(date).isAfter(new Date())}
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
dateFormat="dd/MM/yyyy HH:mm"
selected={startDate}
showTimeSelect
@@ -216,14 +270,82 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterDate={(date) => moment(date).isAfter(startDate)}
filterTime={(date) => moment(date).isAfter(startDate)}
dateFormat="dd/MM/yyyy HH:mm"
selected={endDate}
showTimeSelect
onChange={(date) => setEndDate(date)}
/>
</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>
{selectedModules.includes("speaking") && (
<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>
<Select
value={{
value: instructorGender,
label: capitalize(instructorGender),
}}
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
disabled={!selectedModules.includes("speaking") || !!assignment}
options={[
{value: "male", label: "Male"},
{value: "female", label: "Female"},
{value: "varied", label: "Varied"},
]}
/>
</div>
)}
{selectedModules.length > 0 && (
<div className="flex flex-col gap-3 w-full">
<Checkbox isChecked={useRandomExams} onChange={setUseRandomExams}>
Random Exams
</Checkbox>
{!useRandomExams && (
<div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">{capitalize(module)} Exam</label>
<Select
value={{
value: examIDs.find((e) => e.module === module)?.id || null,
label: examIDs.find((e) => e.module === module)?.id || "",
}}
onChange={(value) =>
value
? setExamIDs((prev) => [...prev.filter((x) => x.module !== module), {id: value.value!, module}])
: setExamIDs((prev) => prev.filter((x) => x.module !== module))
}
options={exams
.filter((x) => !x.isDiagnostic && x.module === module)
.map((x) => ({value: x.id, label: x.id}))}
/>
</div>
))}
</div>
)}
</div>
)}
<section className="w-full flex flex-col gap-3">
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
@@ -250,7 +372,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
))}
</div>
<div className="flex flex-wrap -md:justify-center gap-4">
{users.map((user) => (
{userStudents.map((user) => (
<div
onClick={() => toggleAssignee(user)}
className={clsx(
@@ -281,16 +403,88 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
))}
</div>
</section>
<div className="flex gap-4 w-full justify-end">
{user.type !== "teacher" && (
<section className="w-full flex flex-col gap-3">
<span className="font-semibold">Teachers ({teachers.length} selected)</span>
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
{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>
<div className="flex flex-wrap -md:justify-center gap-4">
{userTeachers.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>
</section>
)}
<div className="flex gap-4 w-full items-end">
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
Full length exams
</Checkbox>
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
Generate different exams
</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 className="flex gap-4 w-full justify-end">
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
Cancel
</Button>
{assignment && (
<>
<Button
className="w-full max-w-[200px]"
color="green"
variant="outline"
onClick={startAssignment}
disabled={isLoading || moment().isAfter(startDate)}
isLoading={isLoading}>
Start
</Button>
<Button
className="w-full max-w-[200px]"
color="red"
@@ -300,9 +494,17 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
isLoading={isLoading}>
Delete
</Button>
</>
)}
<Button
disabled={selectedModules.length === 0 || !name || !startDate || !endDate || assignees.length === 0}
disabled={
selectedModules.length === 0 ||
!name ||
!startDate ||
!endDate ||
assignees.length === 0 ||
(!useRandomExams && examIDs.length < selectedModules.length)
}
className="w-full max-w-[200px]"
onClick={createAssignment}
isLoading={isLoading}>

View File

@@ -1,3 +1,4 @@
import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar";
import Modal from "@/components/Modal";
import useUsers from "@/hooks/useUsers";
@@ -9,11 +10,21 @@ import {getExamById} from "@/utils/exams";
import { sortByModule } from "@/utils/moduleUtils";
import { calculateBandScore } from "@/utils/score";
import { convertToUserSolutions } from "@/utils/stats";
import { getUserName } from "@/utils/users";
import axios from "axios";
import clsx from "clsx";
import { capitalize, uniqBy } from "lodash";
import moment from "moment";
import { useRouter } from "next/router";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import {
BsBook,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
} from "react-icons/bs";
import { toast } from "react-toastify";
import { futureAssignmentFilter } from "@/utils/assignments";
interface Props {
isOpen: boolean;
@@ -30,6 +41,36 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const deleteAssignment = async () => {
if (!confirm("Are you sure you want to delete this assignment?")) return;
axios
.delete(`/api/assignments/${assignment?.id}`)
.then(() =>
toast.success(
`Successfully deleted the assignment "${assignment?.name}".`
)
)
.catch(() => toast.error("Something went wrong, please try again later."))
.finally(onClose);
};
const startAssignment = () => {
if (assignment) {
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!");
});
}
};
const formatTimestamp = (timestamp: string) => {
const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm";
@@ -43,16 +84,29 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
const resultModuleBandScores = assignment.results.map((r) => {
const moduleStats = r.stats.filter((s) => s.module === module);
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
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);
});
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
return resultModuleBandScores.length === 0
? -1
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
assignment.results.length;
};
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = {
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,
@@ -93,10 +147,22 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
};
const customContent = (stats: Stat[], 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 customContent = (
stats: Stat[],
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 aggregatedLevels = aggregatedScores.map((x) => ({
module: x.module,
@@ -106,7 +172,9 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
const timeSpent = stats[0].timeSpent;
const selectExam = () => {
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
const examPromises = uniqBy(stats, "exam").map((stat) =>
getExamById(stat.module, stat.exam)
);
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
@@ -117,7 +185,7 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
.map((x) => x!.module)
);
router.push("/exercises");
}
@@ -126,13 +194,17 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
const content = (
<>
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
<div className="flex md:flex-col 2xl:flex-row md:gap-1 -md:gap-2 2xl:gap-2 -md:items-center 2xl:items-center">
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
<span className="font-medium">
{formatTimestamp(stats[0].date.toString())}
</span>
{timeSpent && (
<>
<span className="md:hidden 2xl:flex"> </span>
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
<span className="text-sm">
{Math.floor(timeSpent / 60)} minutes
</span>
</>
)}
</div>
@@ -140,31 +212,38 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
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",
)}>
correct / total < 0.3 && "text-mti-rose"
)}
>
Level{" "}
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
{(
aggregatedLevels.reduce(
(accumulator, current) => accumulator + current.level,
0
) / aggregatedLevels.length
).toFixed(1)}
</span>
</div>
<div className="w-full flex flex-col gap-1">
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
<div className="flex w-full flex-col gap-1">
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{aggregatedLevels.map(({ module, level }) => (
<div
key={module}
className={clsx(
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
"-md:px-4 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="w-4 h-4" />}
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
{module === "writing" && <BsPen className="w-4 h-4" />}
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
{module === "level" && <BsClipboard className="w-4 h-4" />}
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" />}
<span className="text-sm">{level.toFixed(1)}</span>
</div>
))}
@@ -184,48 +263,78 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
<div
key={user}
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:hidden",
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
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",
correct / total >= 0.3 &&
correct / total < 0.7 &&
"hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose"
)}
onClick={selectExam}
role="button">
role="button"
>
{content}
</div>
<div
key={user}
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",
"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",
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",
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">
role="button"
>
{content}
</div>
</div>
);
};
const shouldRenderStart = () => {
if (assignment) {
if (futureAssignmentFilter(assignment)) {
return true;
}
}
return false;
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
<div className="mt-4 flex flex-col w-full gap-4">
<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"
(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
}
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
/>
<div className="flex gap-8 items-start">
<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>
<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
@@ -233,29 +342,40 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
.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="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
{assignment?.exams.map(({module}) => (
<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(
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl tooltip",
"-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="w-4 h-4" />}
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
{module === "writing" && <BsPen className="w-4 h-4" />}
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
{module === "level" && <BsClipboard className="w-4 h-4" />}
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>
<span className="text-sm">
{calculateAverageModuleScore(module).toFixed(1)}
</span>
)}
</div>
))}
@@ -263,17 +383,51 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">
Results ({assignment?.results.length}/{assignment?.assignees.length})
Results ({assignment?.results.length}/{assignment?.assignees.length}
)
</span>
<div>
{assignment && assignment?.results.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 w-full gap-4 xl:gap-6">
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
<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="font-semibold ml-1">No results yet...</span>}
{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,315 +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 {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,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {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";
interface Props {
user: User;
}
export default function CorporateDashboard({user}: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const {stats} = useStats();
const {users, reload} = useUsers();
const {groups} = useGroups(user.id);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
useEffect(() => {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
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 (
<>
<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 ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
);
};
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 (
<>
<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 ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filters={[filter]} />
</>
);
};
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 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 = () => (
<>
<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(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
color="purple"
/>
<IconCard onClick={() => setPage("groups")} Icon={BsPersonAdd} label="Groups" value={groups.length} color="purple" />
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest 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 === "" && <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;

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