411 Commits

Author SHA1 Message Date
1b17dd845a Add user analysis view bars + nested element logic to doctor view also 2025-11-12 12:21:53 +02:00
2c0634f444 Fix types and condition 2025-11-12 11:50:01 +02:00
5757c44e12 Fix "comment" will never include IDs "doctors that received email: ", improve logging 2025-11-12 11:49:39 +02:00
487d604e19 Update analysis level bars in doctor view based on user view 2025-11-12 11:48:58 +02:00
f7fbbd2352 Fix /doctor/analysis/[id] expects id to be analysisResponse.id, not analysisOrder.id 2025-11-12 09:51:46 +02:00
a77e2a7f70 Make error logs clearer about what is missing 2025-11-12 09:50:55 +02:00
b216f7b211 Fix type errors 2025-11-12 09:50:37 +02:00
5ef7f58f5d Fix "ups" error for 1sec after logout 2025-11-12 08:53:21 +02:00
2cb6a0343a Make it clearer which date is shown inside tooltip 2025-11-12 08:53:05 +02:00
8f32fdf08d Move medipostPrivateMessage.service to separate classes, improve logging 2025-11-12 08:51:48 +02:00
Karli
0878b5d1bd Merge branch 'main' into develop 2025-11-10 16:56:54 +02:00
Karli
6ee2e65938 MED-238: Improve logging 2025-11-10 16:54:01 +02:00
Karli
633b6db1af MED-238: Fix "client.auth.getUser" cannot be used in job context 2025-11-07 10:24:33 +02:00
Karli
452440e8db MED-238: Fix "client.auth.getUser" cannot be used in job context 2025-11-07 10:24:10 +02:00
641ee91c90 Merge pull request #147 from MR-medreport/hotfix/error-logs-1107
hotfix: fix errors from aws logs
2025-11-07 10:02:12 +02:00
Karli
2aa2ce9ce1 MED-238: Update query limit, default=5 2025-11-07 10:01:29 +02:00
Karli
48381b2c27 MED-240: Fix error "[PostgrestError]: "failed to parse filter (not.is.COMPLETED)" (line 1, column 8)" 2025-11-07 10:01:16 +02:00
Karli
2972988211 MED-240: Fix error "Error syncing analysis results Error [AuthSessionMissingError]: Auth session missing!" 2025-11-07 10:01:10 +02:00
ac80460d93 Merge pull request #146 from MR-medreport/feature/MED-238-240
MED-238,240: Fix errors from logs
2025-11-07 09:27:05 +02:00
Karli
58958c8ace MED-238: Update query limit, default=5 2025-11-06 23:28:26 +02:00
Karli
b76bdf7622 MED-240: Use retries for medusa query, "ECONNRESET" 2025-11-06 23:16:51 +02:00
Karli
c002eeb74b MED-240: Fix error "[PostgrestError]: "failed to parse filter (not.is.COMPLETED)" (line 1, column 8)" 2025-11-06 20:17:11 +02:00
Karli
5c171fb930 MED-240: Fix error "Error syncing analysis results Error [AuthSessionMissingError]: Auth session missing!" 2025-11-06 20:10:11 +02:00
danelkungla
99f20cce39 Merge pull request #145 from MR-medreport/develop
fix wrapping
2025-10-29 11:02:28 +02:00
Danel Kungla
3e192c71c5 fix wrapping 2025-10-29 11:01:31 +02:00
danelkungla
f37d3e19fe Merge pull request #144 from MR-medreport/develop
main <- develop
2025-10-28 16:52:32 +02:00
Danel Kungla
ac95f7810e improve logging 2025-10-28 16:44:39 +02:00
Danel Kungla
8bc6089a7f add doctor feedback 2025-10-28 16:09:06 +02:00
danelkungla
b5b01648fc MED-213: lifestyle
MED-213: lifestyle
2025-10-24 10:05:00 +03:00
Danel Kungla
38c487b54f code fix 2025-10-24 09:35:35 +03:00
Danel Kungla
9a01c15a76 change recommendations to update through doctor 2025-10-23 16:48:27 +03:00
danelkungla
a43624e559 Merge pull request #142 from MR-medreport/develop
fix analysis order page
2025-10-23 12:32:22 +03:00
Danel Kungla
194934913e fix analysis order page 2025-10-23 12:27:25 +03:00
danelkungla
3ad7afe2be main <- develop
main <- develop
2025-10-23 12:23:01 +03:00
Danel Kungla
bdd740c9ae added updated_at field when changing time 2025-10-23 12:05:57 +03:00
Danel Kungla
b1435e8e47 fix booking editing in cart 2025-10-23 10:40:07 +03:00
Danel Kungla
cd6476821a Merge branch 'develop' into MED-213 2025-10-22 16:57:13 +03:00
Danel Kungla
cc1de3e2f3 update order 2025-10-22 15:25:17 +03:00
danelkungla
fb44ef577a Merge pull request #140 from MR-medreport/MED-177
MED-177: add booking confirmation
2025-10-22 10:31:02 +03:00
Danel Kungla
86b2d02a8f fix tto order tables 2025-10-22 10:29:07 +03:00
Danel Kungla
deee61e3ff remove confirmed api 2025-10-21 17:28:55 +03:00
Danel Kungla
1a83f1b45d Merge branch 'develop' into MED-177 2025-10-21 17:27:54 +03:00
Danel Kungla
76c2382e11 lifestyle development 2025-10-21 16:04:01 +03:00
danelkungla
4919c4fc12 Merge pull request #139 from MR-medreport/develop
main <- develop
2025-10-21 12:10:54 +03:00
Danel Kungla
ae5768dbcd remove constraint 2025-10-21 12:10:05 +03:00
Danel Kungla
b94e633742 Update CONNECTED_ONLINE url 2025-10-21 10:05:13 +03:00
Danel Kungla
6dcc91a206 WIP: add lifestyle block 2025-10-21 09:36:29 +03:00
Danel Kungla
c6a94fa60f Update to ipv4 2025-10-14 08:58:44 +03:00
danelkungla
766e44e5c3 Merge pull request #138 from MR-medreport/develop
main <- develop
2025-10-10 18:11:20 +03:00
Danel Kungla
fbdfdaf0c1 translations 2025-10-10 16:30:06 +03:00
Danel Kungla
f74c5a2fc6 improve doctor email audit logs 2025-10-10 16:14:36 +03:00
danelkungla
6aa5149789 MED-198: send notification if new responses
MED-198: send notification if new responses
2025-10-10 15:33:24 +03:00
Danel Kungla
30e6e47cc7 hasAccountTeamMembership should look at in user_id not account_id 2025-10-10 15:29:40 +03:00
Danel Kungla
2a3f7248a1 show ON_HOLD responses, but dont show if they have no responses 2025-10-10 14:51:05 +03:00
Danel Kungla
e84541339d only send not completed jobs 2025-10-10 14:42:41 +03:00
Danel Kungla
ce01f9a4c9 MED-198: send notification if new responses 2025-10-10 14:19:27 +03:00
danelkungla
0f962ef59a Merge pull request #136 from MR-medreport/develop
main <- develop
2025-10-09 19:01:09 +03:00
Danel Kungla
cf147919a6 fix account balance for deleted users 2025-10-09 18:59:03 +03:00
Danel Kungla
3e60745a92 improve mobile view for my orders
remove otp requirement from doctor
2025-10-09 17:47:59 +03:00
danelkungla
a5ddd790f6 MAIN <- develop
MAIN <- develop
2025-10-08 18:40:03 +03:00
Danel Kungla
963d2c6a01 MED-174: sidebar h-screen class was breaking sticky menu 2025-10-08 18:39:05 +03:00
Danel Kungla
db9d94a59d MED-197: add tooltip to default package feature 2025-10-08 18:02:05 +03:00
Danel Kungla
8386e541cb MED-198: add notification for new analysis result 2025-10-08 16:32:19 +03:00
Danel Kungla
3a8d73e742 MED-193: improve mobile design for cart tables 2025-10-08 13:50:04 +03:00
danelkungla
c07b97fb3a main <- develop
main <- develop
2025-10-07 18:44:59 +03:00
Danel Kungla
17e7a98534 eslint fix 2025-10-07 18:43:42 +03:00
Danel Kungla
f614759986 MED-189: add company to mobile menu 2025-10-07 18:30:31 +03:00
Danel Kungla
a50afea145 MED-174: added cart to mobile menu 2025-10-07 18:23:04 +03:00
danelkungla
f00fa95e9e MED-197: improve analyses package compare page
MED-197
2025-10-07 18:09:03 +03:00
Danel Kungla
ce0416f658 Merge branch 'develop' into MED-197 2025-10-07 18:08:21 +03:00
Danel Kungla
57f992a5c3 MED-197: improve analysis compare page 2025-10-07 18:07:59 +03:00
danelkungla
74dac271ef MED-155: add RLS to public schema
MED-155: add RLS to public schema
2025-10-07 16:31:02 +03:00
Danel Kungla
258bd62a7f MED-155: add RLS to public schema 2025-10-07 16:28:35 +03:00
danelkungla
85070fcadb MED-203: fix doctor feedback form
MED-203: fix doctor feedback form
2025-10-07 16:11:21 +03:00
Danel Kungla
2f81002e81 MED-203: fix doctor feedback form 2025-10-07 16:10:55 +03:00
danelkungla
0e14428518 main <- develop
main <- develop
2025-10-07 09:42:41 +03:00
danelkungla
918be18120 MED-194: fix order view for single analysis
MED-194: fix order view for single analysis
2025-10-07 09:42:04 +03:00
danelkungla
839882f616 main <- develop
main <- develop
2025-10-06 19:45:45 +03:00
danelkungla
5ea0a376c8 MED-202: fix doctor detail view
MED-202: fix doctor detail view
2025-10-06 19:45:18 +03:00
Danel Kungla
d2494f3456 fix doctor detail view 2025-10-06 19:44:09 +03:00
danelkungla
c66f71b01c main <- develop
main <- develop
2025-10-06 19:15:14 +03:00
Danel Kungla
6704dacc37 Merge branch 'develop' into MED-194 2025-10-06 15:16:48 +03:00
Danel Kungla
8958972d78 fix single analysis result page button 2025-10-06 15:16:13 +03:00
danelkungla
3f08fbc543 MED-188: add health benefit eligibility management
MED-188: add health benefit eligibility management
2025-10-06 11:09:55 +03:00
Danel Kungla
b3bea06d16 update tables if not eligible 2025-10-06 11:07:39 +03:00
Danel Kungla
98dcb881ac fix webhook 2025-10-06 08:33:18 +03:00
danelkungla
6f01f31f22 Merge pull request #124 from MR-medreport/develop
minor fixes
2025-10-06 07:43:13 +03:00
Danel Kungla
ec99b6ac96 minor fixes 2025-10-06 07:41:30 +03:00
danelkungla
975ee20254 Merge pull request #123 from MR-medreport/develop
fix mfa login after keycloak
2025-10-04 17:42:38 +03:00
Danel Kungla
3f17b82fdb fix mfa login after keycloak 2025-10-04 17:36:36 +03:00
Danel Kungla
e7b7be7562 fix logs 2025-10-03 16:58:53 +03:00
danelkungla
6c9ab76439 Merge pull request #122 from MR-medreport/develop
main <- develop
2025-10-03 16:42:53 +03:00
Danel Kungla
47e8bd873c add benefit eligibility setting to HR members 2025-10-03 15:33:38 +03:00
Danel Kungla
419bcc11cb Merge branch 'develop' into MED-188 2025-10-03 13:02:36 +03:00
Danel Kungla
a5e8d7fecc prettier fix 2025-10-03 13:02:24 +03:00
danelkungla
7d90b5b910 MED-186: added upsert to balance if increased
MED-186: added upsert to balance if increased
2025-10-03 12:59:06 +03:00
Danel Kungla
c47f41fab6 remove log 2025-10-03 12:46:14 +03:00
danelkungla
eeea6b0d6f Merge pull request #121 from MR-medreport/develop
develop -> main
2025-10-03 12:44:25 +03:00
Danel Kungla
b9bacd548d typo 2025-10-03 12:40:05 +03:00
Danel Kungla
70c74568be add typegen and minor style fixes 2025-10-03 12:38:10 +03:00
Danel Kungla
449818ed94 add new permission 2025-10-03 06:58:08 +03:00
Danel Kungla
5cc7c156ae MED-187 2025-10-02 19:02:15 +03:00
Danel Kungla
98896fe6de account_membership is not updated by this time 2025-10-02 18:51:16 +03:00
Danel Kungla
d8f314cb00 MED-186: added upsert to balance if increased
MED-185: add wallet balance. to new employee
2025-10-02 18:50:16 +03:00
Danel Kungla
ca88387071 restart prod 2025-10-02 11:29:08 +03:00
Danel Kungla
f73bbf54ad MED-177: add booking confirmation 2025-10-01 18:55:53 +03:00
danelkungla
0c5d38f274 MED-102
Med 102
2025-10-01 16:23:31 +03:00
Danel Kungla
5424032278 remove log 2025-10-01 16:23:14 +03:00
Danel Kungla
45f9283e55 restart prod 2025-10-01 16:21:52 +03:00
danelkungla
bac2fba473 Merge pull request #118 from MR-medreport/develop
revert migration
2025-10-01 13:33:14 +03:00
Danel Kungla
4cabf81f68 revert migration 2025-10-01 13:32:45 +03:00
Danel Kungla
96d3880229 Restart prod 2025-10-01 12:58:11 +03:00
Danel Kungla
8493d0e9ec add fields to tto order view 2025-10-01 11:55:47 +03:00
danelkungla
8b41653bb5 Merge pull request #117 from MR-medreport/develop
develop -> main
2025-10-01 11:48:46 +03:00
7f0968a071 Merge pull request #116 from MR-medreport/MED-171
feat(MED-171): update benefits info in company members table
2025-10-01 01:49:16 +03:00
Karli
68672985ec feat(MED-171): show full names for company members in table 2025-10-01 01:48:01 +03:00
Karli
b4ab02b4ef feat(MED-171): move sorting to loader to fix warning 2025-10-01 01:47:49 +03:00
Karli
aec736af3d feat(MED-171): update benefits info in company members table 2025-10-01 01:47:36 +03:00
38e2f34683 Merge pull request #115 from MR-medreport/MED-98
feat(MED-98): update benefits selection in cart view
2025-09-30 21:51:11 +03:00
Karli
04d266dacc feat(MED-98): prettier fix 2025-09-30 21:49:55 +03:00
Karli
d3442ae724 Merge branch 'develop' into MED-98 2025-09-30 21:48:02 +03:00
Karli
5c09cdcc2f feat(MED-98): fix local dev values 2025-09-30 21:43:22 +03:00
Danel Kungla
b967cecb80 Merge branch 'develop' into MED-102 2025-09-30 18:08:04 +03:00
danelkungla
94de67f801 MED-49: tto booking email confirmation
Med 49
2025-09-30 18:07:35 +03:00
Danel Kungla
67c69f6273 Merge branch 'develop' into MED-49 2025-09-30 18:07:07 +03:00
Danel Kungla
52eac590a4 feat: add location handling for TTO orders and update related components 2025-09-30 18:05:32 +03:00
Karli
7931057714 feat(MED-98): disable discount button while loading 2025-09-30 16:44:08 +03:00
Karli
2b63d7c529 feat(MED-98): update translations, fix 2025-09-30 16:41:27 +03:00
Karli
176e1e2a3c feat(MED-98): load packages page slightly faster 2025-09-30 16:22:10 +03:00
Karli
2af0ad4d15 feat(MED-98): fix merge issue from 7817ae0 2025-09-30 16:21:49 +03:00
Karli
99a530d672 feat(MED-98): use single <Form> in cart, add toggle for company benefits 2025-09-30 16:21:16 +03:00
Danel Kungla
72f6f2b716 feat: create email template for TTO reservation confirmation
feat: implement order notifications service with TTO reservation confirmation handling

feat: create migration for TTO booking email webhook trigger
2025-09-30 16:05:43 +03:00
Karli
f477bfaa13 feat: delete partial analysis responses in medipost after sync 2025-09-30 15:52:50 +03:00
Karli
2868875044 feat: fix % should be rounded, not 33.333... 2025-09-30 14:44:28 +03:00
Karli
bf10741786 feat: fix incorrect component imports 2025-09-30 14:40:57 +03:00
Karli
1e19e0ab8a feat: delete analysis responses in medipost in test/prod 2025-09-29 23:09:28 +03:00
Danel Kungla
c0c4f5e3db Restart prod 2025-09-29 11:39:10 +03:00
danelkungla
45b77f6291 Merge pull request #113 from MR-medreport/develop
include credentials
2025-09-29 11:12:10 +03:00
Danel Kungla
02645aa598 include credentials 2025-09-29 11:11:33 +03:00
danelkungla
5d4dc97d19 develop -> main
develop -> main
2025-09-29 11:11:03 +03:00
b26ff8819b Merge pull request #111 from MR-medreport/MED-97
feat(MED-97): add dev key for medusa benefits payment
2025-09-26 17:23:57 +03:00
e4fcafa57c feat(MED-97): add dev key for medusa benefits payment 2025-09-26 17:23:23 +03:00
Danel Kungla
4003284f3a Merge branch 'develop' into MED-49 2025-09-26 17:23:09 +03:00
Danel Kungla
b674640bd8 refactor time slots 2025-09-26 17:20:50 +03:00
f50f64ebcf Merge pull request #110 from MR-medreport/MED-97
feat(MED-97,MED-98): update company benefits views, payment with only benefits or split with montonio
2025-09-26 17:07:53 +03:00
f794a66147 feat(MED-97): fix new OpenAI() throws error when key is missing in env 2025-09-26 17:01:56 +03:00
6bdf5fbf12 feat(MED-97): fix duplicate element 2025-09-26 17:01:35 +03:00
c6a1400dc7 Merge branch 'develop' into MED-97 2025-09-26 17:01:24 +03:00
danelkungla
aef772c6b1 MED-102: improve tto orders view
MED-102
2025-09-26 16:39:29 +03:00
2d9e6f8df3 feat(MED-97): display accounts count, usage total 2025-09-26 16:34:10 +03:00
Danel Kungla
c99beea46a add openai validation 2025-09-26 16:18:50 +03:00
Danel Kungla
bfdd1ec62a add tto order 2025-09-26 15:58:25 +03:00
27689dbbff feat(MED-97): hide company logo upload if it doesn't work 2025-09-26 15:46:52 +03:00
a68f7c7ab5 feat(MED-97): save benefit_distribution_schedule_id to account_balance_entries 2025-09-26 15:46:32 +03:00
92dd792121 feat(MED-97): update translations in each language 2025-09-26 15:46:01 +03:00
eb6ef2abe1 feat(MED-97): show benefits amount for each member 2025-09-26 15:39:33 +03:00
428cbd9477 feat(MED-97): small fixes 2025-09-26 15:37:47 +03:00
0aa16c457a feat(MED-97): make sure new company employee accounts get benefits balance 2025-09-26 13:50:33 +03:00
1aeee0bc30 feat(MED-97): update benefit stats view in dashboards 2025-09-26 13:50:14 +03:00
fdc2e3e064 feat(MED-97): show person name instead of keycloak email in company invite form 2025-09-26 13:26:58 +03:00
beeea33842 feat(MED-97): use spinner in order confirmed view and wait for payment to be captured in webhook 2025-09-26 13:25:23 +03:00
4ed3924907 feat(MED-97): update orders page to load more than 10 orders 2025-09-26 13:24:36 +03:00
db38e602aa feat(MED-97): update cart flow for using benefits 2025-09-26 13:24:09 +03:00
56f84a003c feat(MED-97): move shared order placing logic to cart-actions 2025-09-26 09:47:28 +03:00
579ec7547e feat(MED-97): fix client type 2025-09-26 09:45:35 +03:00
f091ed5b49 feat(MED-97): update team account navigation links 2025-09-26 09:45:18 +03:00
fc63b9e7b7 feat(MED-97): move order status updating to user-analyses feature pkg 2025-09-26 09:43:49 +03:00
Danel Kungla
0a61371271 refactor env 2025-09-25 18:07:10 +03:00
Danel Kungla
1acde486f2 add dynamic export 2025-09-25 17:42:53 +03:00
Danel Kungla
0f2bfb74b4 disable revalidatePath for home/cart 2025-09-25 17:24:44 +03:00
Danel Kungla
2188b73f54 recreated montonio env 2025-09-25 17:11:00 +03:00
Danel Kungla
85c6621b7a use env 2025-09-25 16:11:08 +03:00
Danel Kungla
c69a1af094 added CONNECTED_ONLINE_URL env 2025-09-25 16:06:34 +03:00
Danel Kungla
cc8c4093ff updated openai Prompt for live 2025-09-25 16:02:09 +03:00
danelkungla
1e02cd99bc MED-103: create connected api job
MED-103: create job
2025-09-25 15:52:28 +03:00
Danel Kungla
5d88121e78 refactor demo clinic condition 2025-09-25 15:51:43 +03:00
Danel Kungla
2e2498577f MED-103: create job 2025-09-25 15:40:37 +03:00
Danel Kungla
6c3ae1eda6 MED-102: clean order page for tto orders 2025-09-25 15:30:07 +03:00
danelkungla
5dc625c903 MED-129 + MED-103: add shopping cart functionality for TTO services
MED-129 + MED-103: add shopping cart functionality for TTO services
2025-09-24 18:49:10 +03:00
Danel Kungla
6c6e7a6847 fix conflict duplicates 2025-09-24 18:45:29 +03:00
d37a99d7cd Merge pull request #107 from MR-medreport/develop
develop -> main
2025-09-24 17:02:45 +03:00
Danel Kungla
c7298d2b7e refactor 2025-09-24 16:54:36 +03:00
b4cdc08532 fix circular dependency due to index.ts import/export all same files 2025-09-24 16:48:55 +03:00
d941d82373 rerun pipeline for updated aws parameters 2025-09-24 15:27:59 +03:00
cf9a51e64f rerun pipeline for updated aws parameters 2025-09-24 15:27:31 +03:00
Danel Kungla
41593be44a fix types 2025-09-24 15:14:13 +03:00
Danel Kungla
1275f23bb1 Merge branch 'develop' into feature/MED-129 2025-09-24 15:00:57 +03:00
Danel Kungla
7817ae0448 Merge branch 'develop' into feature/MED-129 2025-09-24 15:00:27 +03:00
a520c04a02 feat(MED-97): clean up 2025-09-24 14:58:07 +03:00
07237dece6 feat(MED-97): clean up 2025-09-24 14:57:52 +03:00
dc35c3d43e Merge pull request #106 from MR-medreport/MED-97
feat(MED-97): migrations fix timestamps
2025-09-24 14:55:43 +03:00
7830b92571 feat(MED-97): migrations fix timestamps 2025-09-24 14:54:53 +03:00
4c003b3cb5 Merge pull request #105 from MR-medreport/MED-97
feat(MED-97): create company benefits tables; company, superadmin view fixes
2025-09-24 12:57:42 +03:00
ddd1728e4e feat(MED-97): allow different card heading sizes 2025-09-24 12:44:47 +03:00
4b44ead3ff feat(MED-97): create company benefits tables 2025-09-24 12:43:54 +03:00
f1eb0e1ebf feat(MED-97): fix missing breadcrumbs, consistent layouts 2025-09-23 20:27:20 +03:00
fb03d548af feat(MED-97): fix occurance->occurrence in components, add missing translations 2025-09-23 20:03:36 +03:00
8af2c8710f feat(MED-97): fix display account name instead of slug 2025-09-23 19:52:49 +03:00
3212b52b02 feat(MED-97): fix /home/[account] and /admin* routes layout consistencies 2025-09-23 19:22:40 +03:00
f0853409c8 fix monitoring dependency missing 2025-09-23 17:30:07 +03:00
e92120654b fix db function 2025-09-23 17:29:50 +03:00
Danel Kungla
2f22b6c88f fix sql 2025-09-23 17:18:49 +03:00
Danel Kungla
40b7a98c57 dont render if env is missing 2025-09-23 16:48:23 +03:00
cd3d2586f7 fix constraint 2025-09-23 16:24:59 +03:00
Danel Kungla
8794bf998c add try catch for openai responses api 2025-09-23 16:08:07 +03:00
edba074b38 fix medipost_actions constraint 2025-09-23 16:05:11 +03:00
Danel Kungla
6492421c0d add prompt id to env 2025-09-23 15:34:41 +03:00
danelkungla
e6ccb85c34 MED-157: openai analyses recomendations
MED-157: openai analyses recomendations
2025-09-23 15:14:24 +03:00
Danel Kungla
df850cf1b2 update skeleton 2025-09-23 15:13:48 +03:00
Danel Kungla
4afc498cd7 feedback fix 2025-09-23 14:33:48 +03:00
Danel Kungla
4962ba8ec2 add real data to ai 2025-09-23 14:27:41 +03:00
4b4a7097ae rerun pipeline for updated aws medipost values 2025-09-23 13:32:10 +03:00
Danel Kungla
258e18e6a6 Merge branch 'develop' into MED-157 2025-09-23 11:03:54 +03:00
danelkungla
e3adae69bd Use eslint config for whole project
Add eslint
2025-09-23 10:58:09 +03:00
Danel Kungla
1fdbaf1bd5 run prettier fix 2025-09-23 10:57:28 +03:00
Danel Kungla
63ede85668 Merge branch 'develop' into add-eslint 2025-09-23 10:56:41 +03:00
Danel Kungla
3dd82902fe log recommendations 2025-09-23 10:50:20 +03:00
857044793d Merge pull request #103 from MR-medreport/develop
develop -> main
2025-09-22 15:56:20 +03:00
Karli
f47a9e75e2 update types matching in test responses 2025-09-22 15:55:15 +03:00
Karli
428a1dfb87 don't upsert if audit log doesn't have medipost_private_message_id, improve logging 2025-09-22 15:31:06 +03:00
Danel Kungla
fee8534fcc Merge branch 'develop' into MED-157 2025-09-20 18:44:10 +03:00
Danel Kungla
643e67e2a1 update type 2025-09-20 18:42:18 +03:00
Danel Kungla
5f0ca6bdae add ai_tables 2025-09-20 18:12:34 +03:00
Danel Kungla
a52a9bec06 add api 2025-09-20 18:12:15 +03:00
Danel Kungla
9ed52dcf02 add readme
delete unrequired configs
2025-09-19 18:07:31 +03:00
Helena
249611886b remove unused import 2025-09-19 17:30:32 +03:00
Helena
961f726520 translations, remove random empty lines, refactor 2025-09-19 17:28:45 +03:00
Danel Kungla
0c2cfe6d18 prettier fix 2025-09-19 17:22:36 +03:00
Danel Kungla
efa94b3322 Merge branch 'develop' into add-eslint 2025-09-19 17:00:44 +03:00
Danel Kungla
c6c0fe0a09 Merge branch 'develop' into add-eslint 2025-09-19 17:00:18 +03:00
Danel Kungla
78221c4267 eslint refactor 2025-09-19 16:59:52 +03:00
Danel Kungla
addd3a5dae send analyses to ai 2025-09-19 16:54:21 +03:00
Helena
c50b75ce9b merge 2025-09-19 16:27:40 +03:00
Helena
b59148630a add cart functionality for tto services 2025-09-19 16:23:19 +03:00
d86495d7f7 Merge pull request #100 from MR-medreport/MED-168-fixes
feat(MED-168): analysis results view improvements / fixes
2025-09-19 15:30:30 +03:00
ffd8cc1b3f feat(MED-168): mark order as completed if more than expected response elements come from post 2025-09-19 15:29:23 +03:00
71a9ff75c4 feat(MED-168): improve styles 2025-09-19 15:29:07 +03:00
afdaebc804 feat(MED-168): clean up types 2025-09-19 15:29:04 +03:00
47bbeeb155 feat(MED-168): remove unused field 2025-09-19 15:29:01 +03:00
091144d942 feat(MED-168): clean up types, show nested elements 2025-09-19 15:28:56 +03:00
e7b484e1d4 feat(MED-168): move analysis result element mapping to shared 2025-09-19 15:28:41 +03:00
81f7a03388 feat(MED-168): disable useless console logs on browserside, too many logs 2025-09-19 15:28:37 +03:00
c37706f6a9 feat(MED-168): separate user analysis results building 2025-09-19 15:28:34 +03:00
08b27b022f feat(MED-168): move toArray to shared utils 2025-09-19 15:28:23 +03:00
ccbaba291e Merge pull request #99 from MR-medreport/develop
develop -> main
2025-09-19 11:38:30 +03:00
Helena
3c272505d6 various minor changes 2025-09-19 10:18:18 +03:00
2bdd54e649 Merge pull request #98 from MR-medreport/MED-168
feat(MED-168): keep medipost response data unique, no need for duplicate rows
2025-09-18 16:59:07 +03:00
54209553ec feat(MED-168): update analysis results display 2025-09-18 16:42:12 +03:00
d99016aa5e feat(MED-168): keep medipost response data unique, no need for duplicate rows 2025-09-18 16:41:05 +03:00
Helena
f0fbca832d use cn 2025-09-18 16:40:25 +03:00
Helena
2f470d3531 revert some whitespace changes for better readability 2025-09-18 10:24:47 +03:00
Helena
71f3aed875 move booking calendar to separate file 2025-09-18 10:17:24 +03:00
Helena
3f3fbad556 mobile improvements 2025-09-18 10:17:07 +03:00
Helena
d85b028bda fix broken import 2025-09-18 09:48:26 +03:00
Helena
db2ccd0f57 log page views 2025-09-18 09:45:09 +03:00
Helena
4bd88f1b4e clean up 2025-09-17 18:23:25 +03:00
Helena
cd55ddf3f8 Merge branch 'develop' of https://github.com/MR-medreport/MRB2B into MED-103 2025-09-17 18:17:01 +03:00
Helena
22f7fa134b MED-103: add booking functionality 2025-09-17 18:11:13 +03:00
Danel Kungla
1b634f9736 add analysis recommendation 2025-09-17 17:35:04 +03:00
Danel Kungla
b96ef47b2d Merge branch 'develop' into MED-157 2025-09-17 11:52:35 +03:00
Danel Kungla
cb32ee5339 Merge branch 'develop' into MED-157 2025-09-17 11:52:04 +03:00
Danel Kungla
f7f0e5e48f add openai package 2025-09-17 11:51:39 +03:00
a243bd4769 improve nested elements null filtering 2025-09-17 11:48:49 +03:00
924e89c714 remove console log 2025-09-17 11:47:51 +03:00
be6e3b6960 Merge pull request #97 from MR-medreport/improvements-1709
feat: improvements for medipost service xml building, analysis results view, add jest
2025-09-17 08:29:14 +00:00
8a7d88d645 feat(MED-161): no need for default value as it's only used in one place 2025-09-17 11:28:32 +03:00
5c4831c5af feat(MED-161): add extra log 2025-09-17 11:19:27 +03:00
4302ddb90e feat(MED-161): add jest for service tests 2025-09-17 11:17:50 +03:00
64acdfcbbb feat(MED-161): add test analysis results page for different cases 2025-09-17 11:17:43 +03:00
bfadf56173 feat(MED-161): update analysis results view 2025-09-17 11:17:36 +03:00
2019c2c1fc feat(MED-161): update sync private message 2025-09-17 11:17:30 +03:00
ecc8c2b982 feat(MED-161): update results sync validations 2025-09-17 11:17:23 +03:00
b729ad9ffa feat(MED-161): add feature flag for delete private messages 2025-09-17 11:17:15 +03:00
4560d2df1d feat(MED-161): use shared types 2025-09-17 11:17:08 +03:00
4ac261fd0d feat(MED-161): move analyses logic to separate package from kit/accounts 2025-09-17 11:16:59 +03:00
e7650e3c32 feat(MED-161): move type to types file 2025-09-17 11:16:50 +03:00
afac628bcf feat(MED-161): move analysis types separately from accounts types 2025-09-17 11:16:42 +03:00
20a497938e feat(MED-161): delete unused loader 2025-09-17 11:16:35 +03:00
7870b8840a feat(MED-161): allow syncing new analyses for existing groups 2025-09-17 11:16:27 +03:00
e5822fd55d feat(MED-161): improve query 2025-09-17 11:16:18 +03:00
c51808d899 feat(MED-161): separate doctor analysis view 2025-09-17 11:16:09 +03:00
46774b286e feat(MED-161): return with type in getAnalysisOrder 2025-09-17 11:16:00 +03:00
7af6aa438e feat(MED-161): store more fields about medipost analysis response 2025-09-17 11:15:55 +03:00
cee37178df feat(MED-161): create analysis-order service 2025-09-17 11:15:44 +03:00
000cad7f05 feat(MED-161): move medipost services to medipost package 2025-09-17 11:15:26 +03:00
0d08592a9a feat(MED-161): move medipost publicmessage logic to separate service 2025-09-17 11:15:16 +03:00
19631fb2db feat(MED-161): delete unused functions 2025-09-17 11:15:08 +03:00
a788e8b587 feat(MED-161): move medipost privatemessage logic to separate service 2025-09-17 11:14:54 +03:00
33a6c92841 feat(MED-161): create medipost common service 2025-09-17 11:14:45 +03:00
a229ab4d31 feat(MED-161): move medipost xml validate to separate service 2025-09-17 11:14:33 +03:00
eda21b8a99 Merge pull request #96 from MR-medreport/analysis-xml-fix-1109
small fixes for dev
2025-09-16 11:32:54 +00:00
16b4589330 fix BO account check in case account id refers to deleted BO account 2025-09-16 14:32:04 +03:00
7e9e1ce3a0 improve styles 2025-09-16 14:31:11 +03:00
Danel Kungla
14252c3efd MED-159: add profile icon to mobile menu 2025-09-16 13:52:24 +03:00
Danel Kungla
cbdcc061d5 fix visual bugs with udate-account-form 2025-09-16 11:21:13 +03:00
Danel Kungla
0b7829be8b weight and height default values to null 2025-09-15 19:03:59 +03:00
danelkungla
03ec6eb670 Merge pull request #95 from MR-medreport/develop
add user consent url
2025-09-12 18:37:19 +03:00
Danel Kungla
74edc6c679 add user consent url 2025-09-12 18:34:45 +03:00
3525a5ccd2 fix analysis package button alignment with short subtitle text 2025-09-12 15:33:13 +03:00
32ce3807e8 Merge pull request #94 from MR-medreport/develop
develop -> main
2025-09-12 11:26:24 +00:00
781a21aef4 Merge pull request #93 from MR-medreport/analysis-xml-fix-1109
fix sending multiple analysis elements for same material group with different <Proov> data
2025-09-12 10:55:04 +00:00
74c176912b fix sending multiple analysis elements for same material group with different <Proov> data 2025-09-12 12:46:46 +03:00
Helena
7c92b787ce Merge branch 'develop' of https://github.com/MR-medreport/MRB2B into MED-103 2025-09-11 10:09:37 +03:00
Helena
99e62b91a5 use analysis order id as valis tellimuse id (#92)
* use order analysis id as valis tellimuse id

* send analysis order id in xml
2025-09-10 16:54:12 +03:00
Helena
b31de91daf Merge branch 'develop' of https://github.com/MR-medreport/MRB2B into develop 2025-09-10 12:40:23 +03:00
Helena
0e7e0b0ed6 fix translation 2025-09-10 12:37:39 +03:00
Danel Kungla
da6acd8756 change design for analysis-package button 2025-09-10 12:21:43 +03:00
90aa6f6fd6 Merge pull request #90 from MR-medreport/develop
develop -> main
2025-09-10 07:13:24 +00:00
745ba874b6 Merge pull request #89 from MR-medreport/updates-for-live-0909
fix package analyses count should include variant count
2025-09-10 07:13:03 +00:00
e324d6c5d1 fix package analyses count should include variant count 2025-09-10 10:11:14 +03:00
be1ba7ef16 Merge pull request #88 from MR-medreport/develop
develop -> main
2025-09-10 04:31:11 +00:00
6b1f93c317 Merge pull request #87 from MR-medreport/updates-for-live-0909
updates for live; auth provider changes, smaller fixes for styles/etc
2025-09-10 04:14:40 +00:00
96f82444a7 show 2fa secret as text also (example: adding to bitwarden) 2025-09-10 07:12:05 +03:00
0328e518b6 improve some texts + styles 2025-09-10 07:11:18 +03:00
0c764f82e5 add missing i18n schema for page 2025-09-10 07:10:06 +03:00
4f99e32009 fix error boundary flashing on signout action 2025-09-10 06:42:39 +03:00
e0adc2fb68 remove temp data with TODO 2025-09-10 06:42:14 +03:00
76433684e7 improve cart mobile styles 2025-09-10 06:34:34 +03:00
8b3e58e833 improve signup container 2025-09-10 06:34:27 +03:00
b8a8eab87c update order details view, translations 2025-09-10 06:34:18 +03:00
fa0bbe64fb update account form for email login 2025-09-10 06:34:05 +03:00
e3cdba6a7c show less update-account fields on email login 2025-09-10 06:33:56 +03:00
312027b9ed avoid too many duplicate requireUserInServerComponent requests for each page+layout 2025-09-10 06:33:44 +03:00
2aad0329f3 update cart discount for prod build, add loading toast 2025-09-10 06:32:55 +03:00
229b3d7c27 fix warnings on cart page refresh 2025-09-10 06:32:48 +03:00
cb11244d79 improve order analyses cards 2025-09-10 06:32:41 +03:00
fa9895637d add env to turn off automatic medipost sending on montonio callback 2025-09-10 06:32:25 +03:00
0aea6b80d4 sent time in medipost xml can be different from order creation time 2025-09-10 06:31:49 +03:00
be33b892f5 only show checks for analysis elements explicitly in current package 2025-09-10 06:31:39 +03:00
7bc89f7c22 no need to show "select-package" page after onboading if it has nothing 2025-09-10 06:31:32 +03:00
95452de88b prefer using providers conf from supabase instead of env 2025-09-10 06:31:23 +03:00
d3d937dbb2 Merge pull request #85 from MR-medreport/develop
develop -> main
2025-09-09 12:50:30 +00:00
9f9508233d Merge pull request #86 from MR-medreport/keycloak-improvements-0909
fix case when variant has no metadata and no package elements are displayed
2025-09-09 12:50:16 +00:00
danelkungla
478eef3681 MED-154: redirect homepage to website in production. support lang param
MED-154: redirect homepage to website in production. support lang param
2025-09-09 15:49:16 +03:00
831e60c3c1 fix case when variant has no metadata and no package elements are displayed 2025-09-09 15:48:02 +03:00
Danel Kungla
86a5931b66 update condition for production redirect 2025-09-09 15:25:31 +03:00
0f16c6552f Merge pull request #84 from MR-medreport/keycloak-improvements-0909
improve namings; improve logging for orders without analysis packages
2025-09-09 11:55:43 +00:00
165d44b13f prepare montonio callback logic to send email for individual analysis order
- skip confusing error log for orders without analysis packages
2025-09-09 14:02:34 +03:00
7d208b41f2 update naming to be clearer 2025-09-09 14:01:19 +03:00
Danel Kungla
ebab0556ba change else to else if 2025-09-09 12:39:00 +03:00
Danel Kungla
514cb3bf7b redirect homepage to website in production. support lang param 2025-09-09 12:36:19 +03:00
0281298b0c Merge pull request #82 from MR-medreport/keycloak-improvements-0909
add missing translations
2025-09-09 09:36:05 +00:00
85c72e777b add missing translations 2025-09-09 12:05:03 +03:00
Danel Kungla
1283094a91 newer pino-pretty@13.1.1 has new strip-json-comments version that conflicts with others and causes error 2025-09-09 11:56:52 +03:00
Helena
ca13e9e30a MED-82: add patient notification emails (#74)
* MED-82: add patient notification emails

* remove console.log

* clean up

* remove extra paragraph from email
2025-09-09 10:37:22 +03:00
7baa1d43a2 Merge pull request #81 from MR-medreport/develop
develop -> main; keycloak, fixes etc
2025-09-09 07:10:01 +00:00
d00449da29 Merge pull request #80 from MR-medreport/keycloak-improvements-0909
some small fixes/improvements for update-account-form, medipost-xml, analysis packages
2025-09-09 07:00:48 +00:00
c0e9cf5e25 fix value with new types 2025-09-09 09:57:01 +03:00
fd94320295 use analysisElementMedusaProductIds from order product selected variant if it exists 2025-09-09 09:56:57 +03:00
596d0e9eee fix missing month for DoB in medipost xml 2025-09-09 09:56:47 +03:00
1d641211b6 update for new type 2025-09-09 09:56:44 +03:00
06154f24bf fix: deduplicate specimen elements in medipost XML generation
- Fix duplicate <Proov> elements when multiple analysis elements use same material type
- Ensure analysis elements reference correct specimen order numbers
- Move XML composition logic to separate service for better separation of concerns
2025-09-09 09:56:39 +03:00
f00899c456 move medipost xml to separate service to be unit tested 2025-09-09 09:56:34 +03:00
c2896d77b0 prefill phone field in update-account form 2025-09-09 09:56:31 +03:00
353e5c3c4b update types 2025-09-09 09:56:23 +03:00
3bdc1cfefc update personal code util 2025-09-09 09:56:19 +03:00
5b91ece1ec sort compare packages modal analyses by title 2025-09-09 09:56:16 +03:00
0e063cd5dc 'medreport.update_account' should also update email 2025-09-09 09:56:12 +03:00
2c63875806 fix typo 2025-09-09 09:56:09 +03:00
0081e8948b move most isikukood.js usage to utils 2025-09-09 09:56:03 +03:00
ccdfd5872b retry pipeline 2025-09-08 13:37:47 +03:00
321c85bc9c Merge pull request #79 from MR-medreport/keycloak-updates-v2
update login/signup/update-account for keycloak
2025-09-08 08:25:19 +00:00
Helena
422b0d5856 Merge branch 'develop' of https://github.com/MR-medreport/MRB2B into MED-103 2025-09-08 09:36:46 +03:00
aa441d4055 update medreport.accounts with keycloak data when supabase auth.user is created 2025-09-08 01:05:02 +03:00
c882a24415 update envs for keycloak 2025-09-08 01:04:37 +03:00
077aaee181 handle keycloak user prefills in update-account form 2025-09-08 01:03:17 +03:00
57a998d215 fix NaN for bmi when divide-by-zero 2025-09-08 01:02:42 +03:00
f01829de96 update keycloak signup / login 2025-09-08 01:02:10 +03:00
7815a1c011 add phone number validation to update account form 2025-09-08 00:59:17 +03:00
96eea95fb9 fix password signup not redirecting to update-account view 2025-09-08 00:58:34 +03:00
6495d1c4a3 fix toTitleCase 2025-09-08 00:58:02 +03:00
a9612ad992 remove useless await 2025-09-08 00:57:38 +03:00
ab2176bc69 fix analyses loading 2025-09-08 00:57:28 +03:00
a89d8d3153 fix whitespace 2025-09-08 00:57:18 +03:00
1b29cb222b prefer pathsConfig urls 2025-09-08 00:56:55 +03:00
7a479787c8 Merge pull request #77 from MR-medreport/develop
develop -> main
2025-09-06 19:59:39 +00:00
dfcfdb8f97 Merge pull request #76 from MR-medreport/improvements-0609
update order xml for live, allow adding discounts in cart
2025-09-06 19:58:59 +00:00
d87d08aaea Merge pull request #75 from MR-medreport/main
main <-> develop
2025-09-06 19:57:59 +00:00
c83694222d allow adding discounts in cart 2025-09-06 22:56:54 +03:00
c08fe26b36 remove comments from order xml 2025-09-05 15:13:14 +03:00
d3202a2cb2 remove comments from order xml 2025-09-05 15:12:58 +03:00
2435e6f113 update medipost order xml for live 2025-09-05 15:09:27 +03:00
54856b0e45 update medipost order xml for live 2025-09-05 15:09:11 +03:00
95e72bb3f8 log out of medusa and reset cart on supabase logout 2025-09-05 14:15:03 +03:00
3d268b6061 retry initial dockerfile 2025-09-05 14:09:02 +03:00
5c6280ec42 retry updated dockerfile 2025-09-05 14:01:22 +03:00
0de9dcf7e3 retry 2025-09-05 13:49:52 +03:00
f3a6fb627c react compiler uses too much memory 2025-09-05 13:39:04 +03:00
c1746c6c20 retry 2025-09-05 13:20:17 +03:00
c6f56f6e11 retry pipeline with updated parameters 2025-09-05 12:54:58 +03:00
a6b246cdf3 improve dockerfile 2025-09-05 12:52:06 +03:00
a705dea9cf retry 2025-09-05 12:19:00 +03:00
8485d2e9a3 retry 2025-09-05 12:11:34 +03:00
c356f69656 retry 2025-09-05 12:03:26 +03:00
cacd23be40 layer cache before envs 2025-09-05 11:53:39 +03:00
f8765dce49 update dockerfile 2025-09-05 11:44:25 +03:00
42bebb6d93 all needed variables in buildtime 2025-09-05 11:39:12 +03:00
354a0c04ee prefer to use env from parameter store 2025-09-05 01:44:05 +03:00
72bb9a33ef Merge branch 'develop' 2025-09-05 01:39:25 +03:00
771c28f8ef rerun codepipeline 2025-09-04 17:07:52 +03:00
Danel Kungla
e9497c3d52 update staging env 2025-09-04 14:26:21 +03:00
70188f297f Merge pull request #73 from MR-medreport/develop
develop -> main
2025-09-04 10:42:33 +00:00
e0940a1600 allow transferCart to fail on register 2025-09-04 13:41:21 +03:00
65eb6c780d allow transferCart to fail on login/register 2025-09-04 13:20:12 +03:00
6e9cde6b95 medusa product can have either analysiselement or analysis originalId 2025-09-04 13:20:09 +03:00
3a062eaa9c hide dashboard recommendations block 2025-09-04 13:20:05 +03:00
c07acb85a2 fix tooltip should wrap long text 2025-09-04 13:20:02 +03:00
1de564b917 rerun codepipeline 2025-09-03 15:15:02 +03:00
a2c080914a Merge branch 'develop' 2025-09-03 13:32:15 +03:00
Danel Kungla
f7514c698e feat: implement booking feature with service and time slot selection 2025-09-03 10:04:00 +03:00
Danel Kungla
94dd00b9ca Add data.sql to .gitignore 2025-08-29 18:05:29 +03:00
830 changed files with 38468 additions and 15394 deletions

8
.env
View File

@@ -13,7 +13,7 @@ NEXT_PUBLIC_THEME_COLOR="#ffffff"
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a" NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
# AUTH # AUTH
NEXT_PUBLIC_AUTH_PASSWORD=true NEXT_PUBLIC_AUTH_PASSWORD=false
NEXT_PUBLIC_AUTH_MAGIC_LINK=false NEXT_PUBLIC_AUTH_MAGIC_LINK=false
NEXT_PUBLIC_CAPTCHA_SITE_KEY= NEXT_PUBLIC_CAPTCHA_SITE_KEY=
@@ -65,3 +65,9 @@ NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY= NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
# Configure Medusa password secret for Keycloak users
MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg==
# False by default
MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false

View File

@@ -3,12 +3,17 @@
# SITE # SITE
NEXT_PUBLIC_SITE_URL=http://localhost:3000 NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_AUTH_PASSWORD=true
# SUPABASE DEVELOPMENT # SUPABASE DEVELOPMENT
## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION ## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION
SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET
# MEDUSA MONTONIO URLS FOR LOCALHOST
# Montonio doesn't allow localhost as notification/callback URL
DEV_MONTONIO_CALLBACK_URL=http://webhook.site:3000
# EMAILS # EMAILS
# CONTACT FORM # CONTACT FORM
@@ -25,7 +30,25 @@ EMAIL_PORT=1025 # or 465 for SSL
EMAIL_TLS=false EMAIL_TLS=false
NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_TLS_REJECT_UNAUTHORIZED=0
# MEDIPOST
MEDIPOST_URL=https://meditest.medisoft.ee:7443/Medipost/MedipostServlet
MEDIPOST_USER=trvurgtst
MEDIPOST_PASSWORD=SRB48HZMV
MEDIPOST_RECIPIENT=trvurgtst
MEDIPOST_MESSAGE_SENDER=trvurgtst
MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=true
MEDIPOST_ENABLE_DELETE_RESPONSE_PRIVATE_MESSAGE_ON_READ=false
#MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet
#MEDIPOST_USER=medreport
#MEDIPOST_PASSWORD=
#MEDIPOST_RECIPIENT=HTI
#MEDIPOST_MESSAGE_SENDER=medreport
#MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false
# MEDUSA # MEDUSA
COMPANY_BENEFITS_PAYMENT_SECRET_KEY=NzcwMzE2NmEtOThiMS0xMWYwLWI4NjYtMDMwZDQzMjFhMjExCg==
MEDUSA_BACKEND_URL=http://localhost:9000 MEDUSA_BACKEND_URL=http://localhost:9000
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000 MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000

View File

@@ -10,6 +10,7 @@ MEDIPOST_PASSWORD=your-medipost-password
MEDIPOST_RECIPIENT=your-medipost-recipient MEDIPOST_RECIPIENT=your-medipost-recipient
CONNECTED_ONLINE_URL=your-connected-online-url CONNECTED_ONLINE_URL=your-connected-online-url
CONNECTED_ONLINE_CONFIRMED_URL=your-connected-confirmed-url
EMAIL_SENDER= EMAIL_SENDER=
EMAIL_USER= # refer to your email provider's documentation EMAIL_USER= # refer to your email provider's documentation
@@ -19,6 +20,7 @@ EMAIL_PORT= # or 465 for SSL
EMAIL_TLS= # or false for SSL (see provider documentation) EMAIL_TLS= # or false for SSL (see provider documentation)
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY= NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
MEDUSA_SECRET_API_KEY=
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo

View File

@@ -6,10 +6,10 @@
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE. ## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
# SUPABASE # SUPABASE
NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co # NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0 # NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
NEXT_PUBLIC_SITE_URL=https://test.medreport.ee # NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
# MONTONIO # # MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead # NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead

View File

@@ -6,10 +6,10 @@
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE. ## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
# SUPABASE # SUPABASE
NEXT_PUBLIC_SUPABASE_URL=https://kaldvociniytdbbcxvqk.supabase.co # NEXT_PUBLIC_SUPABASE_URL=https://klocrucggryikaxzvxgc.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImthbGR2b2Npbml5dGRiYmN4dnFrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTYzNjQ5OTYsImV4cCI6MjA3MTk0MDk5Nn0.eixihH2KGkJZolY9FiQDicJOo2kxvXrSe6gGUCrkLo0 # NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTY5ODQ2MjgsImV4cCI6MjA3MjU2MDYyOH0.2XOQngowcymiSUZO_XEEWAWzco2uRIjwG7TAeRRLIdU
NEXT_PUBLIC_SITE_URL=https://test.medreport.ee # NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
# MONTONIO # # MONTONIO
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead # NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead

1
.npmrc
View File

@@ -3,6 +3,7 @@ dedupe-peer-dependents=true
use-lockfile-v6=true use-lockfile-v6=true
resolution-mode=highest resolution-mode=highest
package-manager-strict=true package-manager-strict=true
ignore-workspace-root-check=true
public-hoist-pattern[]=*i18next* public-hoist-pattern[]=*i18next*
public-hoist-pattern[]=*eslint* public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier* public-hoist-pattern[]=*prettier*

View File

@@ -1,3 +1,5 @@
database.types.ts database.types.ts
playwright-report playwright-report
*.hbs *.hbs
.history
node_modules

View File

@@ -22,12 +22,12 @@ COPY . .
ENV NODE_ENV=production ENV NODE_ENV=production
RUN set -a \ RUN set -a \
&& . .env \ && . .env \
&& . .env.production \ && . .env.production \
&& . .env.staging \ && . .env.staging \
&& set +a \ && set +a \
&& node check-env.js \ && node check-env.js \
&& pnpm build && pnpm build
# --- Stage 2: Runtime --- # --- Stage 2: Runtime ---
@@ -41,13 +41,13 @@ COPY --from=builder /app ./
RUN cp ".env.${APP_ENV}" .env.local RUN cp ".env.${APP_ENV}" .env.local
RUN npm install -g pnpm@9 \ RUN npm install -g pnpm@9 \
&& pnpm install --prod --frozen-lockfile && pnpm install --prod --frozen-lockfile
ENV NODE_ENV=production ENV NODE_ENV=production
# 🔍 Optional: Log key envs for debug # 🔍 Optional: Log key envs for debug
RUN echo "📄 .env contents:" && cat .env.local \ RUN echo "📄 .env contents:" && cat .env.local \
&& echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true && echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true
EXPOSE 3000 EXPOSE 3000

View File

@@ -47,6 +47,18 @@ if you get missing dependency error do `pnpm i --force`
pnpm add <pacakge-name> -w pnpm add <pacakge-name> -w
``` ```
## Eslint and prettier
To clean code run
```bash
npm run lint:fix
```
```bash
npm run prettier:fix
```
## Supabase ## Supabase
Start supabase in docker Start supabase in docker
@@ -86,13 +98,13 @@ To access admin pages follow these steps:
- Register new user - Register new user
- Go to Profile and add Multi-Factor Authentication - Go to Profile and add Multi-Factor Authentication
- Authenticate with mfa (at current time profile page prompts it again) - Authenticate with mfa (at current time profile page prompts it again)
- update your role. look at `supabase/sql/super-admin.sql` - update your `account.application_role` to `super_admin`.
- Sign out and Sign in - Sign out and Sign in
## Company User ## Company User
- With admin account go to `http://localhost:3000/admin/accounts` - With admin account go to `http://localhost:3000/admin/accounts`
- For Create Company Account to work you need to have rows in `medreport.roles` table. For that you can sql in `supabase/sql/super-admin.sql` - For Create Company Account to work you need to have rows in `medreport.roles` table.
## Start email server ## Start email server

44
__mocks__/isikukood.ts Normal file
View File

@@ -0,0 +1,44 @@
// Mock for isikukood library to avoid ES module issues in tests
export enum Gender {
MALE = 'male',
FEMALE = 'female',
}
export default class Isikukood {
private code: string;
constructor(code: string) {
this.code = code;
}
static validate(code: string): boolean {
return true; // Mock always returns true for tests
}
static generate(options?: { gender?: Gender; century?: number }): string {
return '39001010002'; // Mock Estonian ID code
}
isValid(): boolean {
return true;
}
getGender(): Gender {
return Gender.MALE;
}
getBirthDate(): Date {
return new Date('1990-01-01');
}
getAge(): number {
return 30;
}
getCentury(): number {
return 3;
}
}
export { Isikukood };

3
__mocks__/server-only.ts Normal file
View File

@@ -0,0 +1,3 @@
// Mock for server-only to avoid Next.js server component issues in tests
// This module does nothing in tests - it's just a marker for Next.js
export {};

View File

@@ -2,7 +2,6 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() { export async function generateMetadata() {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();

View File

@@ -2,7 +2,6 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() { export async function generateMetadata() {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();

View File

@@ -7,17 +7,14 @@ import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown'; import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
import { featureFlagsConfig, pathsConfig } from '@kit/shared/config';
import { useAuthConfig } from '@kit/shared/hooks';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
import { useSupabase } from '@kit/supabase/hooks/use-supabase'; import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { featureFlagsConfig } from '@kit/shared/config';
import { pathsConfig } from '@kit/shared/config';
const ModeToggle = dynamic(() => const ModeToggle = dynamic(() =>
import('@kit/ui/mode-toggle').then((mod) => ({ import('@kit/ui/mode-toggle').then((mod) => ({
default: mod.ModeToggle, default: mod.ModeToggle,
@@ -60,6 +57,8 @@ export function SiteHeaderAccountSection({
} }
function AuthButtons() { function AuthButtons() {
const { config } = useAuthConfig();
return ( return (
<div className={'animate-in fade-in flex gap-x-2.5 duration-500'}> <div className={'animate-in fade-in flex gap-x-2.5 duration-500'}>
<div className={'hidden md:flex'}> <div className={'hidden md:flex'}>
@@ -68,19 +67,25 @@ function AuthButtons() {
</If> </If>
</div> </div>
<div className={'flex gap-x-2.5'}> {config && (
<Button className={'block'} asChild variant={'ghost'}> <div className={'flex gap-x-2.5'}>
<Link href={pathsConfig.auth.signIn}> {(config.providers.password || config.providers.oAuth.length > 0) && (
<Trans i18nKey={'auth:signIn'} /> <Button className={'block'} asChild variant={'ghost'}>
</Link> <Link href={pathsConfig.auth.signIn}>
</Button> <Trans i18nKey={'auth:signIn'} />
</Link>
</Button>
)}
<Button asChild className="text-xs md:text-sm" variant={'default'}> {config.providers.password && (
<Link href={pathsConfig.auth.signUp}> <Button asChild className="text-xs md:text-sm" variant={'default'}>
<Trans i18nKey={'auth:signUp'} /> <Link href={pathsConfig.auth.signUp}>
</Link> <Trans i18nKey={'auth:signUp'} />
</Button> </Link>
</div> </Button>
)}
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,8 +1,7 @@
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace'; import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
import { Header } from '@kit/ui/marketing';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
import { Header } from '@kit/ui/marketing';
import { SiteHeaderAccountSection } from './site-header-account-section'; import { SiteHeaderAccountSection } from './site-header-account-section';

View File

@@ -8,7 +8,6 @@ import { createCmsClient } from '@kit/cms';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { Post } from '../../blog/_components/post'; import { Post } from '../../blog/_components/post';
interface BlogPageProps { interface BlogPageProps {

View File

@@ -11,14 +11,16 @@ const contactEmail = z
.string({ .string({
error: error:
'Contact email is required. Please use the environment variable CONTACT_EMAIL.', 'Contact email is required. Please use the environment variable CONTACT_EMAIL.',
}).describe(`The email where you want to receive the contact form submissions.`) })
.describe(`The email where you want to receive the contact form submissions.`)
.parse(process.env.CONTACT_EMAIL); .parse(process.env.CONTACT_EMAIL);
const emailFrom = z const emailFrom = z
.string({ .string({
error: error:
'Sender email is required. Please use the environment variable EMAIL_SENDER.', 'Sender email is required. Please use the environment variable EMAIL_SENDER.',
}).describe(`The email sending address.`) })
.describe(`The email sending address.`)
.parse(process.env.EMAIL_SENDER); .parse(process.env.EMAIL_SENDER);
export const sendContactEmail = enhanceAction( export const sendContactEmail = enhanceAction(

View File

@@ -6,7 +6,6 @@ import { ContactForm } from '~/(marketing)/contact/_components/contact-form';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
export async function generateMetadata() { export async function generateMetadata() {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();

View File

@@ -9,7 +9,6 @@ import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();

View File

@@ -2,9 +2,8 @@ import { use } from 'react';
import { SiteFooter } from '~/(marketing)/_components/site-footer'; import { SiteFooter } from '~/(marketing)/_components/site-footer';
import { SiteHeader } from '~/(marketing)/_components/site-header'; import { SiteHeader } from '~/(marketing)/_components/site-header';
import { withI18n } from '~/lib/i18n/with-i18n';
import { loadCurrentUserAccounts } from '~/home/(user)/_lib/server/load-accounts'; import { loadCurrentUserAccounts } from '~/home/(user)/_lib/server/load-accounts';
import { withI18n } from '~/lib/i18n/with-i18n';
function SiteLayout(props: React.PropsWithChildren) { function SiteLayout(props: React.PropsWithChildren) {
const accounts = use(loadCurrentUserAccounts()); const accounts = use(loadCurrentUserAccounts());

View File

@@ -1,8 +1,9 @@
import Link from 'next/link'; import Link from 'next/link';
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
import { ArrowRightIcon } from 'lucide-react'; import { ArrowRightIcon } from 'lucide-react';
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
import { pathsConfig } from '@kit/shared/config';
import { CtaButton, Hero } from '@kit/ui/marketing'; import { CtaButton, Hero } from '@kit/ui/marketing';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
@@ -32,7 +33,7 @@ function MainCallToActionButton() {
return ( return (
<div className={'flex space-x-4'}> <div className={'flex space-x-4'}>
<CtaButton> <CtaButton>
<Link href={'/auth/sign-up'}> <Link href={pathsConfig.auth.signUp}>
<span className={'flex items-center space-x-0.5'}> <span className={'flex items-center space-x-0.5'}>
<span> <span>
<Trans i18nKey={'common:getStarted'} /> <Trans i18nKey={'common:getStarted'} />

View File

@@ -19,6 +19,8 @@ import { Label } from '@kit/ui/label';
import { Spinner } from '@kit/ui/spinner'; import { Spinner } from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { sendCompanyOfferEmail } from '../_lib/server/company-offer-actions';
const CompanyOfferForm = () => { const CompanyOfferForm = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const router = useRouter();
@@ -34,6 +36,16 @@ const CompanyOfferForm = () => {
const onSubmit = async (data: CompanySubmitData) => { const onSubmit = async (data: CompanySubmitData) => {
setIsLoading(true); setIsLoading(true);
try {
await sendCompanyOfferEmail(data, language);
router.push('/company-offer/success');
} catch (err) {
setIsLoading(false);
if (err instanceof Error) {
console.warn('Could not send company offer email: ' + err.message);
}
console.warn('Could not send company offer email: ', err);
}
const formData = new FormData(); const formData = new FormData();
Object.entries(data).forEach(([key, value]) => { Object.entries(data).forEach(([key, value]) => {
if (value !== undefined) formData.append(key, value); if (value !== undefined) formData.append(key, value);

View File

@@ -0,0 +1,25 @@
'use server';
import { renderCompanyOfferEmail } from '@/packages/email-templates/src';
import { sendEmailFromTemplate } from '~/lib/services/mailer.service';
import { CompanySubmitData } from '~/lib/types/company';
export const sendCompanyOfferEmail = async (
data: CompanySubmitData,
language: string,
) => {
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined) formData.append(key, value);
});
sendEmailFromTemplate(
renderCompanyOfferEmail,
{
companyData: data,
language,
},
process.env.CONTACT_EMAIL!,
);
};

View File

@@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import { MedReportLogo } from '@kit/shared/components/med-report-logo'; import { MedReportLogo } from '@kit/shared/components/med-report-logo';
import { withI18n } from '~/lib/i18n/with-i18n';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
import CompanyOfferForm from './_components/company-offer-form'; import CompanyOfferForm from './_components/company-offer-form';
function CompanyOffer() { function CompanyOffer() {

View File

@@ -1,9 +1,8 @@
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
function SiteLayout(props: React.PropsWithChildren) { function SiteLayout(props: React.PropsWithChildren) {
return ( return (
<div className={'flex min-h-[100vh] flex-col justify-center items-center'}> <div className={'flex min-h-[100vh] flex-col items-center justify-center'}>
{props.children} {props.children}
</div> </div>
); );

View File

@@ -0,0 +1,24 @@
import { SIDEBAR_WIDTH_PROPERTY } from '@/packages/ui/src/shadcn/constants';
import { AppLogo } from '@kit/shared/components/app-logo';
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
import type { UserWorkspace } from '../../home/(user)/_lib/server/load-user-workspace';
export function AdminMenuNavigation(props: { workspace: UserWorkspace }) {
const { accounts } = props.workspace;
return (
<div className={'flex w-full flex-1 items-center justify-between gap-3'}>
<div className={`flex items-center ${SIDEBAR_WIDTH_PROPERTY}`}>
<AppLogo href={'/admin'} />
</div>
<div className="flex items-center justify-end gap-3">
<div>
<ProfileAccountDropdownContainer accounts={accounts} />
</div>
</div>
</div>
);
}

View File

@@ -1,73 +1,25 @@
'use client'; import { adminNavigationConfig } from '@kit/shared/config';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
import { LayoutDashboard, Users } from 'lucide-react';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader, SidebarHeader,
SidebarMenu, SidebarNavigation,
SidebarMenuButton,
useSidebar,
} from '@kit/ui/shadcn-sidebar'; } from '@kit/ui/shadcn-sidebar';
import { AppLogo } from '@kit/shared/components/app-logo'; export function AdminSidebar() {
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container'; const collapsible = adminNavigationConfig.sidebarCollapsedStyle;
export function AdminSidebar({
accounts,
}: {
accounts: UserWorkspace['accounts'];
}) {
const path = usePathname();
const { open } = useSidebar();
return ( return (
<Sidebar collapsible="icon"> <Sidebar collapsible={collapsible}>
<SidebarHeader className={'m-2'}> <SidebarHeader className="h-24 justify-center">
<AppLogo href={'/admin'} className="max-w-full" compact={!open} /> <div className="mt-24 flex items-center">
<h5>Superadmin</h5>
</div>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarNavigation config={adminNavigationConfig} />
<SidebarGroupLabel>Super Admin</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuButton isActive={path === '/admin'} asChild>
<Link className={'flex gap-2.5'} href={'/admin'}>
<LayoutDashboard className={'h-4'} />
<span>Dashboard</span>
</Link>
</SidebarMenuButton>
<SidebarMenuButton
isActive={path.includes('/admin/accounts')}
asChild
>
<Link
className={'flex size-full gap-2.5'}
href={'/admin/accounts'}
>
<Users className={'h-4'} />
<span>Accounts</span>
</Link>
</SidebarMenuButton>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter>
<ProfileAccountDropdownContainer accounts={accounts} />
</SidebarFooter>
</Sidebar> </Sidebar>
); );
} }

View File

@@ -2,6 +2,8 @@ import { cache } from 'react';
import { AdminAccountPage } from '@kit/admin/components/admin-account-page'; import { AdminAccountPage } from '@kit/admin/components/admin-account-page';
import { AdminGuard } from '@kit/admin/components/admin-guard'; import { AdminGuard } from '@kit/admin/components/admin-guard';
import { withI18n } from '~/lib/i18n/with-i18n';
import { getAccount } from '~/lib/services/account.service'; import { getAccount } from '~/lib/services/account.service';
interface Params { interface Params {
@@ -26,6 +28,6 @@ async function AccountPage(props: Params) {
return <AdminAccountPage account={account} />; return <AdminAccountPage account={account} />;
} }
export default AdminGuard(AccountPage); export default withI18n(AdminGuard(AccountPage));
const loadAccount = cache(getAccount); const loadAccount = cache(getAccount);

View File

@@ -9,6 +9,8 @@ import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { withI18n } from '~/lib/i18n/with-i18n';
interface SearchParams { interface SearchParams {
page?: string; page?: string;
account_type?: 'all' | 'team' | 'personal'; account_type?: 'all' | 'team' | 'personal';
@@ -30,7 +32,7 @@ async function AccountsPage(props: AdminAccountsPageProps) {
return ( return (
<> <>
<PageHeader description={<AppBreadcrumbs />}> <PageHeader description={<AppBreadcrumbs />} title="Accounts">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<AdminCreateUserDialog> <AdminCreateUserDialog>
<Button data-test="admin-create-user-button"> <Button data-test="admin-create-user-button">
@@ -84,4 +86,4 @@ async function AccountsPage(props: AdminAccountsPageProps) {
); );
} }
export default AdminGuard(AccountsPage); export default withI18n(AdminGuard(AccountsPage));

View File

@@ -5,6 +5,7 @@ import { cookies } from 'next/headers';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { AdminMenuNavigation } from '~/admin/_components/admin-menu-navigation';
import { AdminSidebar } from '~/admin/_components/admin-sidebar'; import { AdminSidebar } from '~/admin/_components/admin-sidebar';
import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation'; import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation';
@@ -21,19 +22,24 @@ export default function AdminLayout(props: React.PropsWithChildren) {
const workspace = use(loadUserWorkspace()); const workspace = use(loadUserWorkspace());
return ( return (
<SidebarProvider defaultOpen={state.open}> <Page style={'header'}>
<Page style={'sidebar'}> <PageNavigation>
<PageNavigation> <AdminMenuNavigation workspace={workspace} />
<AdminSidebar accounts={workspace.accounts} /> </PageNavigation>
</PageNavigation>
<PageMobileNavigation> <PageMobileNavigation>
<AdminMobileNavigation /> <AdminMobileNavigation />
</PageMobileNavigation> </PageMobileNavigation>
{props.children} <SidebarProvider defaultOpen={state.open}>
</Page> <Page style={'sidebar'}>
</SidebarProvider> <PageNavigation>
<AdminSidebar />
</PageNavigation>
{props.children}
</Page>
</SidebarProvider>
</Page>
); );
} }

View File

@@ -2,10 +2,12 @@ import { AdminDashboard } from '@kit/admin/components/admin-dashboard';
import { AdminGuard } from '@kit/admin/components/admin-guard'; import { AdminGuard } from '@kit/admin/components/admin-guard';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { withI18n } from '~/lib/i18n/with-i18n';
function AdminPage() { function AdminPage() {
return ( return (
<> <>
<PageHeader description={`Super Admin`} /> <PageHeader title={`Super Admin`} />
<PageBody> <PageBody>
<AdminDashboard /> <AdminDashboard />
@@ -14,4 +16,4 @@ function AdminPage() {
); );
} }
export default AdminGuard(AdminPage); export default withI18n(AdminGuard(AdminPage));

View File

@@ -0,0 +1,27 @@
import { enhanceRouteHandler } from '@/packages/next/src/routes';
import { createAuthCallbackService } from '@/packages/supabase/src/auth-callback.service';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
export const POST = enhanceRouteHandler(
async () => {
try {
const supabaseClient = getSupabaseServerClient();
const {
data: { user },
} = await supabaseClient.auth.getUser();
const service = createAuthCallbackService(supabaseClient);
if (user && service.isKeycloakUser(user)) {
await service.setupMedusaUserForKeycloak(user);
}
return new Response(null, { status: 200 });
} catch (err) {
console.error('Error on verifying:', { err });
return new Response(null, { status: 500 });
}
},
{
auth: false,
},
);

View File

@@ -1,11 +1,10 @@
import { getPlanTypesMap } from '@kit/billing'; import { getPlanTypesMap } from '@kit/billing';
import { getBillingEventHandlerService } from '@kit/billing-gateway'; import { getBillingEventHandlerService } from '@kit/billing-gateway';
import { enhanceRouteHandler } from '@kit/next/routes'; import { enhanceRouteHandler } from '@kit/next/routes';
import { billingConfig } from '@kit/shared/config';
import { getLogger } from '@kit/shared/logger'; import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { billingConfig } from '@kit/shared/config';
/** /**
* @description Handle the webhooks from Stripe related to checkouts * @description Handle the webhooks from Stripe related to checkouts
*/ */

View File

@@ -2,7 +2,11 @@ import { config } from 'dotenv';
export default function loadEnv() { export default function loadEnv() {
config({ path: `.env` }); config({ path: `.env` });
if (['local', 'test', 'development', 'production'].includes(process.env.NODE_ENV!)) { if (
['local', 'test', 'development', 'production'].includes(
process.env.NODE_ENV!,
)
) {
config({ path: `.env.${process.env.NODE_ENV}` }); config({ path: `.env.${process.env.NODE_ENV}` });
} }
} }

View File

@@ -7,11 +7,19 @@ import { sendEmailFromTemplate } from '~/lib/services/mailer.service';
export default async function sendOpenJobsEmails() { export default async function sendOpenJobsEmails() {
const analysisResponseIds = await getOpenJobAnalysisResponseIds(); const analysisResponseIds = await getOpenJobAnalysisResponseIds();
if (analysisResponseIds.length === 0) {
return;
}
const doctorAccounts = await getDoctorAccounts(); const doctorAccounts = await getDoctorAccounts();
const doctorEmails: string[] = doctorAccounts const doctorEmails = doctorAccounts
.map(({ email }) => email) .map(({ email }) => email)
.filter((email): email is string => !!email); .filter((email): email is string => !!email);
if (doctorEmails.length === 0) {
return [];
}
await sendEmailFromTemplate( await sendEmailFromTemplate(
renderNewJobsAvailableEmail, renderNewJobsAvailableEmail,
{ {
@@ -20,4 +28,6 @@ export default async function sendOpenJobsEmails() {
}, },
doctorEmails, doctorEmails,
); );
return doctorAccounts.filter((email) => !!email).map(({ id }) => id);
} }

View File

@@ -1,9 +1,13 @@
import Medusa from "@medusajs/js-sdk" import { listProductTypes } from '@lib/data/products';
import type { AdminProductCategory } from "@medusajs/types"; import Medusa from '@medusajs/js-sdk';
import { listProductTypes } from "@lib/data/products"; import type { AdminProductCategory } from '@medusajs/types';
import { getAnalysisElements } from "~/lib/services/analysis-element.service";
import { getAnalysisGroups } from "~/lib/services/analysis-group.service"; import {
import { createMedusaSyncFailEntry, createMedusaSyncSuccessEntry } from "~/lib/services/analyses.service"; createMedusaSyncFailEntry,
createMedusaSyncSuccessEntry,
} from '~/lib/services/analyses.service';
import { getAnalysisElements } from '~/lib/services/analysis-element.service';
import { getAnalysisGroups } from '~/lib/services/analysis-group.service';
const SYNLAB_SERVICES_CATEGORY_HANDLE = 'synlab-services'; const SYNLAB_SERVICES_CATEGORY_HANDLE = 'synlab-services';
const SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE = 'synlab-analysis'; const SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE = 'synlab-analysis';
@@ -12,7 +16,8 @@ const BASE_ANALYSIS_PRODUCT_HANDLE = 'analysis-base';
const getAdminSdk = () => { const getAdminSdk = () => {
const medusaBackendUrl = process.env.MEDUSA_BACKEND_PUBLIC_URL!; const medusaBackendUrl = process.env.MEDUSA_BACKEND_PUBLIC_URL!;
const medusaPublishableApiKey = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!; const medusaPublishableApiKey =
process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!;
const key = process.env.MEDUSA_SECRET_API_KEY!; const key = process.env.MEDUSA_SECRET_API_KEY!;
if (!medusaBackendUrl || !medusaPublishableApiKey) { if (!medusaBackendUrl || !medusaPublishableApiKey) {
@@ -23,15 +28,14 @@ const getAdminSdk = () => {
debug: process.env.NODE_ENV === 'development', debug: process.env.NODE_ENV === 'development',
apiKey: key, apiKey: key,
}); });
} };
async function createProductCategories({ async function createProductCategories({ medusa }: { medusa: Medusa }) {
medusa, const { product_categories: existingProductCategories } =
}: { await medusa.admin.productCategory.list();
medusa: Medusa; const parentCategory = existingProductCategories.find(
}) { ({ handle }) => handle === SYNLAB_SERVICES_CATEGORY_HANDLE,
const { product_categories: existingProductCategories } = await medusa.admin.productCategory.list(); );
const parentCategory = existingProductCategories.find(({ handle }) => handle === SYNLAB_SERVICES_CATEGORY_HANDLE);
if (!parentCategory) { if (!parentCategory) {
throw new Error('Parent category not found'); throw new Error('Parent category not found');
@@ -46,8 +50,12 @@ async function createProductCategories({
for (const analysisGroup of analysisGroups) { for (const analysisGroup of analysisGroups) {
console.info(`Processing analysis group '${analysisGroup.name}'`); console.info(`Processing analysis group '${analysisGroup.name}'`);
const isExisting = existingProductCategories.find(({ name }) => name === analysisGroup.name); const isExisting = existingProductCategories.find(
const isNewlyCreated = createdCategories.find(({ name }) => name === analysisGroup.name); ({ name }) => name === analysisGroup.name,
);
const isNewlyCreated = createdCategories.find(
({ name }) => name === analysisGroup.name,
);
if (isExisting || isNewlyCreated) { if (isExisting || isNewlyCreated) {
console.info(`Analysis group '${analysisGroup.name}' already exists`); console.info(`Analysis group '${analysisGroup.name}' already exists`);
continue; continue;
@@ -63,18 +71,19 @@ async function createProductCategories({
analysisGroupId: analysisGroup.id, analysisGroupId: analysisGroup.id,
}, },
}); });
console.info(`Successfully created category, id=${createResponse.product_category.id}`); console.info(
`Successfully created category, id=${createResponse.product_category.id}`,
);
createdCategories.push(createResponse.product_category); createdCategories.push(createResponse.product_category);
} }
} }
async function getChildProductCategories({ async function getChildProductCategories({ medusa }: { medusa: Medusa }) {
medusa, const { product_categories: allCategories } =
}: { await medusa.admin.productCategory.list();
medusa: Medusa; const childCategories = allCategories.filter(
}) { ({ parent_category_id }) => parent_category_id !== null,
const { product_categories: allCategories } = await medusa.admin.productCategory.list(); );
const childCategories = allCategories.filter(({ parent_category_id }) => parent_category_id !== null);
return childCategories; return childCategories;
} }
@@ -93,34 +102,34 @@ async function deleteProductCategories({
/** /**
* In case a reset is needed * In case a reset is needed
*/ */
async function deleteProducts({ async function deleteProducts({ medusa }: { medusa: Medusa }) {
medusa,
}: {
medusa: Medusa;
}) {
const { products: existingProducts } = await medusa.admin.product.list({ const { products: existingProducts } = await medusa.admin.product.list({
fields: 'id,collection_id', fields: 'id,collection_id',
limit: 1000, limit: 1000,
}); });
await Promise.all(existingProducts.filter((a) => !a.collection_id).map(({ id }) => medusa.admin.product.delete(id))); await Promise.all(
existingProducts
.filter((a) => !a.collection_id)
.map(({ id }) => medusa.admin.product.delete(id)),
);
} }
async function getAnalysisPackagesType() { async function getAnalysisPackagesType() {
const { productTypes } = await listProductTypes(); const { productTypes } = await listProductTypes();
const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE); const analysisPackagesType = productTypes.find(
({ metadata }) => metadata?.handle === SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE,
);
if (!analysisPackagesType) { if (!analysisPackagesType) {
throw new Error('Synlab analysis packages type not found'); throw new Error('Synlab analysis packages type not found');
} }
return analysisPackagesType; return analysisPackagesType;
} }
async function getProductDefaultFields({ async function getProductDefaultFields({ medusa }: { medusa: Medusa }) {
medusa, const baseProductsResponse = await medusa.admin.product.list({
}: { handle: BASE_ANALYSIS_PRODUCT_HANDLE,
medusa: Medusa; });
}) {
const baseProductsResponse = await medusa.admin.product.list({ handle: BASE_ANALYSIS_PRODUCT_HANDLE })
const baseProduct = baseProductsResponse.products[0]; const baseProduct = baseProductsResponse.products[0];
if (!baseProduct) { if (!baseProduct) {
throw new Error('Base product not found'); throw new Error('Base product not found');
@@ -142,39 +151,36 @@ async function getProductDefaultFields({
defaultSalesChannels, defaultSalesChannels,
defaultProductOption, defaultProductOption,
defaultProductVariant, defaultProductVariant,
} };
} }
async function createProducts({ async function createProducts({ medusa }: { medusa: Medusa }) {
medusa, const { product_categories: allCategories } =
}: { await medusa.admin.productCategory.list();
medusa: Medusa;
}) {
const { product_categories: allCategories } = await medusa.admin.productCategory.list();
const [ const [
{ products: existingProducts }, { products: existingProducts },
analysisElements, analysisElements,
analysisPackagesType, analysisPackagesType,
{ { defaultSalesChannels, defaultProductOption, defaultProductVariant },
defaultSalesChannels,
defaultProductOption,
defaultProductVariant,
}
] = await Promise.all([ ] = await Promise.all([
medusa.admin.product.list({ medusa.admin.product.list({
category_id: allCategories.map(({ id }) => id), category_id: allCategories.map(({ id }) => id),
}), }),
getAnalysisElements({}), getAnalysisElements({ getAll: true }),
getAnalysisPackagesType(), getAnalysisPackagesType(),
getProductDefaultFields({ medusa }), getProductDefaultFields({ medusa }),
]) ]);
for (const analysisElement of analysisElements) { for (const analysisElement of analysisElements) {
const { analysis_id_original: originalId } = analysisElement; const { analysis_id_original: originalId } = analysisElement;
const isExisting = existingProducts.find(({ metadata }) => metadata?.analysisIdOriginal === originalId); const isExisting = existingProducts.find(
({ metadata }) => metadata?.analysisIdOriginal === originalId,
);
if (isExisting) { if (isExisting) {
console.info(`Analysis element '${analysisElement.analysis_name_lab}' already exists`); console.info(
`Analysis element '${analysisElement.analysis_name_lab}' already exists`,
);
continue; continue;
} }
const { analysis_name_lab: name } = analysisElement; const { analysis_name_lab: name } = analysisElement;
@@ -183,7 +189,10 @@ async function createProducts({
continue; continue;
} }
const category = allCategories.find(({ metadata }) => metadata?.analysisGroupId === analysisElement.parent_analysis_group_id); const category = allCategories.find(
({ metadata }) =>
metadata?.analysisGroupId === analysisElement.parent_analysis_group_id,
);
if (!category) { if (!category) {
console.error(`Category not found for analysis element '${name}'`); console.error(`Category not found for analysis element '${name}'`);
continue; continue;
@@ -227,9 +236,9 @@ export default async function syncAnalysisGroupsStore() {
// await deleteProductCategories({ medusa, categories }); // await deleteProductCategories({ medusa, categories });
// await deleteProducts({ medusa }); // await deleteProducts({ medusa });
// return; // return;
await createProducts({ medusa }); await createProducts({ medusa });
await createMedusaSyncSuccessEntry(); await createMedusaSyncSuccessEntry();
} catch (e) { } catch (e) {
await createMedusaSyncFailEntry(JSON.stringify(e)); await createMedusaSyncFailEntry(JSON.stringify(e));

View File

@@ -1,19 +1,31 @@
import axios from 'axios'; import axios from 'axios';
import { XMLParser } from 'fast-xml-parser'; import { XMLParser } from 'fast-xml-parser';
import fs from 'fs'; import fs from 'fs';
import { createAnalysisGroup, getAnalysisGroups } from '~/lib/services/analysis-group.service';
import { IMedipostPublicMessageDataParsed } from '~/lib/services/medipost.types';
import { createAnalysis, createNoDataReceivedEntry, createNoNewDataReceivedEntry, createSyncFailEntry, createSyncSuccessEntry } from '~/lib/services/analyses.service';
import { getLastCheckedDate } from '~/lib/services/sync-entries.service';
import { createAnalysisElement } from '~/lib/services/analysis-element.service';
import { createCodes } from '~/lib/services/codes.service';
import { getLatestPublicMessageListItem } from '~/lib/services/medipost.service';
import type { ICode } from '~/lib/types/code';
function toArray<T>(input?: T | T[] | null): T[] { import { toArray } from '@kit/shared/utils';
if (!input) return [];
return Array.isArray(input) ? input : [input]; import {
} createAnalysis,
createNoDataReceivedEntry,
createNoNewDataReceivedEntry,
createSyncFailEntry,
createSyncSuccessEntry,
getAnalyses,
} from '~/lib/services/analyses.service';
import {
AnalysisElement,
createAnalysisElement,
getAnalysisElements,
} from '~/lib/services/analysis-element.service';
import {
createAnalysisGroup,
getAnalysisGroups,
} from '~/lib/services/analysis-group.service';
import { createCodes } from '~/lib/services/codes.service';
import { IMedipostPublicMessageDataParsed } from '~/lib/services/medipost/medipost.types';
import { getLatestPublicMessageListItem } from '~/lib/services/medipost/medipostPublicMessage.service';
import { getLastCheckedDate } from '~/lib/services/sync-entries.service';
import type { ICode } from '~/lib/types/code';
const WRITE_XML_TO_FILE = false as boolean; const WRITE_XML_TO_FILE = false as boolean;
@@ -29,7 +41,7 @@ export default async function syncAnalysisGroups() {
try { try {
console.info('Getting latest public message id'); console.info('Getting latest public message id');
const lastCheckedDate = await getLastCheckedDate(); // const lastCheckedDate = await getLastCheckedDate(); never used?
const latestMessage = await getLatestPublicMessageListItem(); const latestMessage = await getLatestPublicMessageListItem();
if (!latestMessage) { if (!latestMessage) {
@@ -56,7 +68,8 @@ export default async function syncAnalysisGroups() {
} }
const parser = new XMLParser({ ignoreAttributes: false }); const parser = new XMLParser({ ignoreAttributes: false });
const parsed: IMedipostPublicMessageDataParsed = parser.parse(publicMessageData); const parsed: IMedipostPublicMessageDataParsed =
parser.parse(publicMessageData);
if (parsed.ANSWER?.CODE && parsed.ANSWER?.CODE !== 0) { if (parsed.ANSWER?.CODE && parsed.ANSWER?.CODE !== 0) {
throw new Error( throw new Error(
@@ -81,43 +94,71 @@ export default async function syncAnalysisGroups() {
const codes: ICode[] = []; const codes: ICode[] = [];
for (const analysisGroup of analysisGroups) { for (const analysisGroup of analysisGroups) {
const existingAnalysisGroup = existingAnalysisGroups?.find(({ original_id }) => original_id === analysisGroup.UuringuGruppId); const existingAnalysisGroup = existingAnalysisGroups?.find(
if (existingAnalysisGroup) { ({ original_id }) => original_id === analysisGroup.UuringuGruppId,
console.info(`Analysis group '${analysisGroup.UuringuGruppNimi}' already exists`);
continue;
}
// SAVE ANALYSIS GROUP
const analysisGroupId = await createAnalysisGroup({
id: analysisGroup.UuringuGruppId,
name: analysisGroup.UuringuGruppNimi,
order: analysisGroup.UuringuGruppJarjekord,
});
const analysisGroupCodes = toArray(analysisGroup.Kood);
codes.push(
...analysisGroupCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: analysisGroupId,
analysis_element_id: null,
analysis_id: null,
})),
); );
let groupExistingAnalysisElements: AnalysisElement[] = [];
let analysisGroupId: number;
if (existingAnalysisGroup) {
console.info(
`Analysis group '${analysisGroup.UuringuGruppNimi}' already exists, only creating new analysis elements`,
);
groupExistingAnalysisElements = await getAnalysisElements({
analysisGroupId: existingAnalysisGroup.id,
});
analysisGroupId = existingAnalysisGroup.id;
} else {
analysisGroupId = await createAnalysisGroup({
id: analysisGroup.UuringuGruppId,
name: analysisGroup.UuringuGruppNimi,
order: analysisGroup.UuringuGruppJarjekord,
});
const analysisGroupCodes = toArray(analysisGroup.Kood);
codes.push(
...analysisGroupCodes.map((kood) => ({
hk_code: kood.HkKood,
hk_code_multiplier: kood.HkKoodiKordaja,
coefficient: kood.Koefitsient,
price: kood.Hind,
analysis_group_id: analysisGroupId,
analysis_element_id: null,
analysis_id: null,
})),
);
}
const analysisGroupItems = toArray(analysisGroup.Uuring); const analysisGroupItems = toArray(analysisGroup.Uuring);
for (const item of analysisGroupItems) { for (const item of analysisGroupItems) {
const analysisElement = item.UuringuElement; const analysisElement = item.UuringuElement!;
const isExistingAnalysisElement = groupExistingAnalysisElements.find(
({ analysis_id_original }) =>
analysis_id_original === analysisElement.UuringId,
);
if (isExistingAnalysisElement) {
console.info(
`Analysis element '${analysisElement.UuringNimi}' already exists`,
);
continue;
}
const insertedAnalysisElementId = await createAnalysisElement({ const insertedAnalysisElementId = await createAnalysisElement({
analysisElement, analysisElement: analysisElement!,
analysisGroupId, analysisGroupId,
materialGroups: toArray(item.MaterjalideGrupp), materialGroups: toArray(item.MaterjalideGrupp),
}); });
if (Array.isArray(analysisElement.UuringuElement)) {
for (const nestedAnalysisElement of analysisElement.UuringuElement) {
await createAnalysisElement({
analysisElement: nestedAnalysisElement,
analysisGroupId,
materialGroups: toArray(item.MaterjalideGrupp),
});
}
}
if (analysisElement.Kood) { if (analysisElement.Kood) {
const analysisElementCodes = toArray(analysisElement.Kood); const analysisElementCodes = toArray(analysisElement.Kood);
codes.push( codes.push(
@@ -135,8 +176,24 @@ export default async function syncAnalysisGroups() {
const analyses = analysisElement.UuringuElement; const analyses = analysisElement.UuringuElement;
if (analyses?.length) { if (analyses?.length) {
const existingAnalyses = await getAnalyses({
originalIds: analyses.map(({ UuringId }) => UuringId),
});
for (const analysis of analyses) { for (const analysis of analyses) {
const insertedAnalysisId = await createAnalysis(analysis, analysisGroupId); const isExistingAnalysis = existingAnalyses.find(
({ analysis_id_original }) =>
analysis_id_original === analysis.UuringId,
);
if (isExistingAnalysis) {
console.info(`Analysis '${analysis.UuringNimi}' already exists`);
continue;
}
const insertedAnalysisId = await createAnalysis(
analysis,
analysisGroupId,
);
if (analysis.Kood) { if (analysis.Kood) {
const analysisCodes = toArray(analysis.Kood); const analysisCodes = toArray(analysis.Kood);
@@ -165,11 +222,17 @@ export default async function syncAnalysisGroups() {
await createSyncSuccessEntry(); await createSyncSuccessEntry();
} catch (e) { } catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e); const errorMessage = e instanceof Error ? e.message : String(e);
await createSyncFailEntry(JSON.stringify({ await createSyncFailEntry(
message: errorMessage, JSON.stringify(
stack: e instanceof Error ? e.stack : undefined, {
name: e instanceof Error ? e.name : 'Unknown', message: errorMessage,
}, null, 2)); stack: e instanceof Error ? e.stack : undefined,
name: e instanceof Error ? e.name : 'Unknown',
},
null,
2,
),
);
console.error('Sync failed:', e); console.error('Sync failed:', e);
throw new Error( throw new Error(
`Failed to sync public message data, error: ${errorMessage}`, `Failed to sync public message data, error: ${errorMessage}`,

View File

@@ -1,4 +1,4 @@
import { readPrivateMessageResponse } from "~/lib/services/medipost.service"; import MedipostPrivateMessageSync from '~/lib/services/medipost/medipostPrivateMessageSync.service';
type ProcessedMessage = { type ProcessedMessage = {
messageId: string; messageId: string;
@@ -6,45 +6,46 @@ type ProcessedMessage = {
hasPartialAnalysisResponse: boolean; hasPartialAnalysisResponse: boolean;
hasFullAnalysisResponse: boolean; hasFullAnalysisResponse: boolean;
medusaOrderId: string | undefined; medusaOrderId: string | undefined;
analysisOrderId: number | undefined;
}; };
type GroupedResults = { type GroupedResults = {
processed: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[]; processed: Pick<ProcessedMessage, 'messageId' | 'analysisOrderId'>[];
waitingForResults: Pick<ProcessedMessage, 'messageId' | 'medusaOrderId'>[]; waitingForResults: Pick<ProcessedMessage, 'messageId' | 'analysisOrderId'>[];
}; };
export default async function syncAnalysisResults() { export default async function syncAnalysisResults() {
console.info("Syncing analysis results"); console.info('Syncing analysis results');
const sync = new MedipostPrivateMessageSync();
let processedMessages: ProcessedMessage[] = [];
const processedMessages: ProcessedMessage[] = [];
const excludedMessageIds: string[] = []; const excludedMessageIds: string[] = [];
while (true) { while (true) {
const result = await readPrivateMessageResponse({ excludedMessageIds }); const result = await sync.handleNextPrivateMessage({ excludedMessageIds });
if (result.messageId) {
processedMessages.push(result as ProcessedMessage);
}
if (!result.messageId) { const { messageId } = result;
console.info("No more messages to process"); if (!messageId) {
console.info('No more messages to process');
break; break;
} }
if (!excludedMessageIds.includes(result.messageId)) { processedMessages.push(result as ProcessedMessage);
excludedMessageIds.push(result.messageId); if (!excludedMessageIds.includes(messageId)) {
excludedMessageIds.push(messageId);
} else { } else {
break; break;
} }
} }
const groupedResults = processedMessages.reduce((acc, result) => { const groupedResults = processedMessages.reduce((acc, result) => {
if (result.medusaOrderId) { if (result.analysisOrderId) {
if (result.hasAnalysisResponse) { if (result.hasAnalysisResponse) {
if (!acc.processed) { if (!acc.processed) {
acc.processed = []; acc.processed = [];
} }
acc.processed.push({ acc.processed.push({
messageId: result.messageId, messageId: result.messageId,
medusaOrderId: result.medusaOrderId, analysisOrderId: result.analysisOrderId,
}); });
} else { } else {
if (!acc.waitingForResults) { if (!acc.waitingForResults) {
@@ -52,11 +53,13 @@ export default async function syncAnalysisResults() {
} }
acc.waitingForResults.push({ acc.waitingForResults.push({
messageId: result.messageId, messageId: result.messageId,
medusaOrderId: result.medusaOrderId, analysisOrderId: result.analysisOrderId,
}); });
} }
} }
return acc; return acc;
}, {} as GroupedResults); }, {} as GroupedResults);
console.info(`Processed ${processedMessages.length} messages, results: ${JSON.stringify(groupedResults)}`); console.info(
`Processed ${processedMessages.length} messages, results: ${JSON.stringify(groupedResults)}`,
);
} }

View File

@@ -2,10 +2,45 @@ import axios from 'axios';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { ISearchLoadResponse } from '~/lib/types/connected-online'; import { logSyncResult } from '~/lib/services/audit.service';
import { SyncStatus } from '~/lib/types/audit';
import type {
ISearchLoadResponse,
P_JobTitleTranslation,
} from '~/lib/types/connected-online';
function createTranslationMap(translations: P_JobTitleTranslation[]) {
const result: Map<
number,
Map<number, { textEN: string; textRU: string; textET: string }>
> = new Map();
for (const translation of translations) {
const { ClinicID, TextET, TextEN, TextRU, SyncID } = translation;
if (!result.has(ClinicID)) {
result.set(ClinicID, new Map());
}
result.get(ClinicID)!.set(SyncID, {
textET: TextET,
textEN: TextEN,
textRU: TextRU,
});
}
return result;
}
function getSpokenLanguages(spokenLanguages?: string) {
if (!spokenLanguages || !spokenLanguages.length) return [];
return spokenLanguages.split(',');
}
export default async function syncConnectedOnline() { export default async function syncConnectedOnline() {
const isProd = process.env.NODE_ENV === 'production'; const isProd = !['test', 'localhost'].some((pathString) =>
process.env.NEXT_PUBLIC_SITE_URL?.includes(pathString),
);
const baseUrl = process.env.CONNECTED_ONLINE_URL; const baseUrl = process.env.CONNECTED_ONLINE_URL;
@@ -16,14 +51,19 @@ export default async function syncConnectedOnline() {
const supabase = getSupabaseServerAdminClient(); const supabase = getSupabaseServerAdminClient();
try { try {
const response = await axios.post<{ d: string }>(`${baseUrl}/Search_Load`, { const searchLoadResponse = await axios.post<{ d: string }>(
headers: { `${baseUrl}/Search_Load`,
'Content-Type': 'application/json; charset=utf-8', {
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
param: "{'Value':'|et|-1'}", // get all available services in Estonian
}, },
param: "{'Value':'|et|-1'}", // get all available services in Estonian );
});
const responseData: ISearchLoadResponse = JSON.parse(response.data.d); const responseData: ISearchLoadResponse = JSON.parse(
searchLoadResponse.data.d,
);
if (responseData?.ErrorCode !== 0) { if (responseData?.ErrorCode !== 0) {
throw new Error('Failed to get Connected Online data'); throw new Error('Failed to get Connected Online data');
@@ -41,20 +81,23 @@ export default async function syncConnectedOnline() {
}); });
} }
let clinics;
let services;
// Filter out "Dentas Demo OÜ" in prod or only sync "Dentas Demo OÜ" in any other environment // Filter out "Dentas Demo OÜ" in prod or only sync "Dentas Demo OÜ" in any other environment
if (isProd) { const isDemoClinic = (clinicId: number) =>
clinics = responseData.Data.T_Lic.filter((clinic) => clinic.ID !== 2); isProd ? clinicId !== 2 : clinicId === 2;
services = responseData.Data.T_Service.filter( const clinics = responseData.Data.T_Lic.filter(({ ID }) =>
(service) => service.ClinicID !== 2, isDemoClinic(ID),
); );
} else { const services = responseData.Data.T_Service.filter(({ ClinicID }) =>
clinics = responseData.Data.T_Lic.filter((clinic) => clinic.ID === 2); isDemoClinic(ClinicID),
services = responseData.Data.T_Service.filter( );
(service) => service.ClinicID === 2, const serviceProviders = responseData.Data.T_Doctor.filter(({ ClinicID }) =>
); isDemoClinic(ClinicID),
} );
const jobTitleTranslations = createTranslationMap(
responseData.Data.P_JobTitleTranslations.filter(({ ClinicID }) =>
isDemoClinic(ClinicID),
),
);
const mappedClinics = clinics.map((clinic) => { const mappedClinics = clinics.map((clinic) => {
return { return {
@@ -64,6 +107,8 @@ export default async function syncConnectedOnline() {
name: clinic.Name, name: clinic.Name,
personal_code_required: !!clinic.PersonalCodeRequired, personal_code_required: !!clinic.PersonalCodeRequired,
phone_number: clinic.Phone || null, phone_number: clinic.Phone || null,
key: clinic.Key,
address: clinic.Address,
}; };
}); });
@@ -71,7 +116,7 @@ export default async function syncConnectedOnline() {
return { return {
id: service.ID, id: service.ID,
clinic_id: service.ClinicID, clinic_id: service.ClinicID,
sync_id: service.SyncID, sync_id: Number(service.SyncID),
name: service.Name, name: service.Name,
description: service.Description || null, description: service.Description || null,
price: service.Price, price: service.Price,
@@ -87,45 +132,133 @@ export default async function syncConnectedOnline() {
}; };
}); });
const mappedServiceProviders = serviceProviders.map((serviceProvider) => {
const jobTitleTranslation = serviceProvider.JobTitleID
? jobTitleTranslations
.get(serviceProvider.ClinicID)
?.get(serviceProvider.JobTitleID)
: null;
return {
id: serviceProvider.ID,
prefix: serviceProvider.Prefix,
name: serviceProvider.Name,
spoken_languages: getSpokenLanguages(serviceProvider.SpokenLanguages),
job_title_et: jobTitleTranslation?.textET,
job_title_en: jobTitleTranslation?.textEN,
job_title_ru: jobTitleTranslation?.textRU,
job_title_id: serviceProvider.JobTitleID,
is_deleted: !!serviceProvider.Deleted,
clinic_id: serviceProvider.ClinicID,
};
});
const { error: providersError } = await supabase const { error: providersError } = await supabase
.schema('medreport') .schema('medreport')
.from('connected_online_providers') .from('connected_online_providers')
.upsert(mappedClinics); .upsert(mappedClinics);
if (providersError) {
return logSyncResult({
operation: 'CONNECTED_ONLINE_SYNC',
comment:
'Error saving connected online providers: ' +
JSON.stringify(providersError),
status: SyncStatus.Fail,
changed_by_role: 'service_role',
});
}
const { error: servicesError } = await supabase const { error: servicesError } = await supabase
.schema('medreport') .schema('medreport')
.from('connected_online_services') .from('connected_online_services')
.upsert(mappedServices, { onConflict: 'id', ignoreDuplicates: false }); .upsert(mappedServices, {
onConflict: 'id',
ignoreDuplicates: false,
});
if (providersError || servicesError) { if (servicesError) {
return supabase return logSyncResult({
.schema('audit') operation: 'CONNECTED_ONLINE_SYNC',
.from('sync_entries') comment:
.insert({ 'Error saving connected online services: ' +
operation: 'CONNECTED_ONLINE_SYNC', JSON.stringify(servicesError),
comment: providersError status: SyncStatus.Fail,
? 'Error saving providers: ' + JSON.stringify(providersError) changed_by_role: 'service_role',
: 'Error saving services: ' + JSON.stringify(servicesError), });
status: 'FAIL',
changed_by_role: 'service_role',
});
} }
await supabase.schema('audit').from('sync_entries').insert({ const { error: serviceProvidersError } = await supabase
.schema('medreport')
.from('connected_online_service_providers')
.upsert(mappedServiceProviders, {
onConflict: 'id',
ignoreDuplicates: false,
});
if (serviceProvidersError) {
return logSyncResult({
operation: 'CONNECTED_ONLINE_SYNC',
comment:
'Error saving service providers: ' +
JSON.stringify(serviceProvidersError),
status: SyncStatus.Fail,
changed_by_role: 'service_role',
});
}
for (const mappedClinic of mappedClinics) {
const defaultLoadResponse = await axios.post<{ d: string }>(
`${baseUrl}/Default_Load`,
{
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
param: `{'Value':'${mappedClinic.key}|et'}`,
},
);
const defaultLoadResponseData = JSON.parse(defaultLoadResponse.data.d);
if (defaultLoadResponseData?.ErrorCode !== 0) {
throw new Error('Failed to get Connected Online location data');
}
const clinicLocations: {
SyncID: number;
Address: string;
Name: string;
}[] = defaultLoadResponseData.Data.T_SelectableLocation;
if (clinicLocations?.length) {
const mappedLocations = clinicLocations.map(
({ SyncID, Address, Name }) => ({
address: Address,
clinic_id: mappedClinic.id,
sync_id: SyncID,
name: Name,
}),
);
await supabase
.schema('medreport')
.from('connected_online_locations')
.insert(mappedLocations)
.throwOnError();
}
}
await logSyncResult({
operation: 'CONNECTED_ONLINE_SYNC', operation: 'CONNECTED_ONLINE_SYNC',
status: 'SUCCESS', status: SyncStatus.Success,
changed_by_role: 'service_role', changed_by_role: 'service_role',
}); });
} catch (e) { } catch (e) {
await supabase await logSyncResult({
.schema('audit') operation: 'CONNECTED_ONLINE_SYNC',
.from('sync_entries') status: SyncStatus.Fail,
.insert({ comment: JSON.stringify(e),
operation: 'CONNECTED_ONLINE_SYNC', changed_by_role: 'service_role',
status: 'FAIL', });
comment: JSON.stringify(e),
changed_by_role: 'service_role',
});
throw new Error( throw new Error(
`Failed to sync Connected Online data, error: ${JSON.stringify(e)}`, `Failed to sync Connected Online data, error: ${JSON.stringify(e)}`,
); );

View File

@@ -1,4 +1,4 @@
import { NextRequest } from "next/server"; import { NextRequest } from 'next/server';
export default function validateApiKey(request: NextRequest) { export default function validateApiKey(request: NextRequest) {
const envApiKey = process.env.JOBS_API_TOKEN; const envApiKey = process.env.JOBS_API_TOKEN;

View File

@@ -1,40 +1,60 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from 'next/server';
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key"; import { getLogger } from '@/packages/shared/src/logger';
import { getOrderedAnalysisElementsIds, sendOrderToMedipost } from "~/lib/services/medipost.service"; import { retrieveOrder } from '@lib/data/orders';
import { retrieveOrder } from "@lib/data/orders";
import { getMedipostDispatchTries } from "~/lib/services/audit.service"; import { getMedipostDispatchTries } from '~/lib/services/audit.service';
import { sendOrderToMedipost } from '~/lib/services/medipost/medipostPrivateMessage.service';
import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service';
import loadEnv from '../handler/load-env';
import validateApiKey from '../handler/validate-api-key';
export const POST = async (request: NextRequest) => { export const POST = async (request: NextRequest) => {
const logger = await getLogger();
const ctx = {
api: '/job/medipost-retry-dispatch',
};
loadEnv(); loadEnv();
const { medusaOrderId } = await request.json(); const { medusaOrderId } = await request.json();
try { try {
validateApiKey(request); validateApiKey(request);
} catch (e) { } catch {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }
const tries = await getMedipostDispatchTries(medusaOrderId); const tries = await getMedipostDispatchTries(medusaOrderId);
if (tries >= 3) { if (tries >= 3) {
return NextResponse.json({ return NextResponse.json(
message: 'Order has been retried too many times', {
}, { status: 400 }); message: 'Order has been retried too many times',
},
{ status: 400 },
);
} }
try { try {
const medusaOrder = await retrieveOrder(medusaOrderId); const medusaOrder = await retrieveOrder(medusaOrderId);
const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ medusaOrder }); const orderedAnalysisElements = await getOrderedAnalysisIds({
medusaOrder,
});
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
console.info("Successfully sent order to medipost"); logger.info(ctx, 'Successfully sent order to medipost');
return NextResponse.json({ return NextResponse.json(
message: 'Successfully sent order to medipost', {
}, { status: 200 }); message: 'Successfully sent order to medipost',
} catch (e) { },
console.error("Error sending order to medipost", e); { status: 200 },
return NextResponse.json({ );
message: 'Failed to send order to medipost', } catch (error) {
}, { status: 500 }); logger.error({ ...ctx, error }, 'Error sending order to medipost');
return NextResponse.json(
{
message: 'Failed to send order to medipost',
},
{ status: 500 },
);
} }
}; };

View File

@@ -4,6 +4,7 @@ import {
NotificationAction, NotificationAction,
createNotificationLog, createNotificationLog,
} from '~/lib/services/audit/notificationEntries.service'; } from '~/lib/services/audit/notificationEntries.service';
import loadEnv from '../handler/load-env'; import loadEnv from '../handler/load-env';
import sendOpenJobsEmails from '../handler/send-open-jobs-emails'; import sendOpenJobsEmails from '../handler/send-open-jobs-emails';
import validateApiKey from '../handler/validate-api-key'; import validateApiKey from '../handler/validate-api-key';
@@ -13,18 +14,20 @@ export const POST = async (request: NextRequest) => {
try { try {
validateApiKey(request); validateApiKey(request);
} catch (e) { } catch {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }
try { try {
await sendOpenJobsEmails(); const doctors = await sendOpenJobsEmails();
const doctorIds = doctors?.join(', ') ?? '-';
console.info( console.info(
'Successfully sent out open job notification emails to doctors.', `Successfully sent out open job notification emails to doctorIds: ${doctorIds}`,
); );
await createNotificationLog({ await createNotificationLog({
action: NotificationAction.NEW_JOBS_ALERT, action: NotificationAction.DOCTOR_NEW_JOBS,
status: 'SUCCESS', status: 'SUCCESS',
comment: `doctors that received email: ${doctorIds}`,
}); });
return NextResponse.json( return NextResponse.json(
{ {
@@ -33,15 +36,15 @@ export const POST = async (request: NextRequest) => {
}, },
{ status: 200 }, { status: 200 },
); );
} catch (e: any) { } catch (e) {
console.error( console.error(
'Error sending out open job notification emails to doctors.', 'Error sending out open job notification emails to doctors.',
e, e,
); );
await createNotificationLog({ await createNotificationLog({
action: NotificationAction.NEW_JOBS_ALERT, action: NotificationAction.DOCTOR_NEW_JOBS,
status: 'FAIL', status: 'FAIL',
comment: e?.message, comment: e instanceof Error ? e.message : 'Unknown error',
}); });
return NextResponse.json( return NextResponse.json(
{ {

View File

@@ -1,27 +1,34 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from 'next/server';
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key"; import loadEnv from '../handler/load-env';
import syncAnalysisGroupsStore from "../handler/sync-analysis-groups-store"; import syncAnalysisGroupsStore from '../handler/sync-analysis-groups-store';
import validateApiKey from '../handler/validate-api-key';
export const POST = async (request: NextRequest) => { export const POST = async (request: NextRequest) => {
loadEnv(); loadEnv();
try { try {
validateApiKey(request); validateApiKey(request);
} catch (e) { } catch {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }
try { try {
await syncAnalysisGroupsStore(); await syncAnalysisGroupsStore();
console.info("Successfully synced analysis groups store"); console.info('Successfully synced analysis groups store');
return NextResponse.json({ return NextResponse.json(
message: 'Successfully synced analysis groups store', {
}, { status: 200 }); message: 'Successfully synced analysis groups store',
},
{ status: 200 },
);
} catch (e) { } catch (e) {
console.error("Error syncing analysis groups store", e); console.error('Error syncing analysis groups store', e);
return NextResponse.json({ return NextResponse.json(
message: 'Failed to sync analysis groups store', {
}, { status: 500 }); message: 'Failed to sync analysis groups store',
},
{ status: 500 },
);
} }
}; };

View File

@@ -1,27 +1,34 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from 'next/server';
import syncAnalysisGroups from "../handler/sync-analysis-groups";
import loadEnv from "../handler/load-env"; import loadEnv from '../handler/load-env';
import validateApiKey from "../handler/validate-api-key"; import syncAnalysisGroups from '../handler/sync-analysis-groups';
import validateApiKey from '../handler/validate-api-key';
export const POST = async (request: NextRequest) => { export const POST = async (request: NextRequest) => {
loadEnv(); loadEnv();
try { try {
validateApiKey(request); validateApiKey(request);
} catch (e) { } catch {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }
try { try {
await syncAnalysisGroups(); await syncAnalysisGroups();
console.info("Successfully synced analysis groups"); console.info('Successfully synced analysis groups');
return NextResponse.json({ return NextResponse.json(
message: 'Successfully synced analysis groups', {
}, { status: 200 }); message: 'Successfully synced analysis groups',
},
{ status: 200 },
);
} catch (e) { } catch (e) {
console.error("Error syncing analysis groups", e); console.error('Error syncing analysis groups', e);
return NextResponse.json({ return NextResponse.json(
message: 'Failed to sync analysis groups', {
}, { status: 500 }); message: 'Failed to sync analysis groups',
},
{ status: 500 },
);
} }
}; };

View File

@@ -1,27 +1,34 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from 'next/server';
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key"; import loadEnv from '../handler/load-env';
import syncAnalysisResults from "../handler/sync-analysis-results"; import syncAnalysisResults from '../handler/sync-analysis-results';
import validateApiKey from '../handler/validate-api-key';
export const POST = async (request: NextRequest) => { export const POST = async (request: NextRequest) => {
loadEnv(); loadEnv();
try { try {
validateApiKey(request); validateApiKey(request);
} catch (e) { } catch {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }
try { try {
await syncAnalysisResults(); await syncAnalysisResults();
console.info("Successfully synced analysis results"); console.info('Successfully synced analysis results');
return NextResponse.json({ return NextResponse.json(
message: 'Successfully synced analysis results', {
}, { status: 200 }); message: 'Successfully synced analysis results',
},
{ status: 200 },
);
} catch (e) { } catch (e) {
console.error("Error syncing analysis results", e); console.error('Error syncing analysis results', e);
return NextResponse.json({ return NextResponse.json(
message: 'Failed to sync analysis results', {
}, { status: 500 }); message: 'Failed to sync analysis results',
},
{ status: 500 },
);
} }
}; };

View File

@@ -1,27 +1,34 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from 'next/server';
import loadEnv from "../handler/load-env";
import validateApiKey from "../handler/validate-api-key"; import loadEnv from '../handler/load-env';
import syncConnectedOnline from "../handler/sync-connected-online"; import syncConnectedOnline from '../handler/sync-connected-online';
import validateApiKey from '../handler/validate-api-key';
export const POST = async (request: NextRequest) => { export const POST = async (request: NextRequest) => {
loadEnv(); loadEnv();
try { try {
validateApiKey(request); validateApiKey(request);
} catch (e) { } catch {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }
try { try {
await syncConnectedOnline(); await syncConnectedOnline();
console.info("Successfully synced connected-online"); console.info('Successfully synced connected-online');
return NextResponse.json({ return NextResponse.json(
message: 'Successfully synced connected-online', {
}, { status: 200 }); message: 'Successfully synced connected-online',
},
{ status: 200 },
);
} catch (e) { } catch (e) {
console.error("Error syncing connected-online", e); console.error('Error syncing connected-online', e);
return NextResponse.json({ return NextResponse.json(
message: 'Failed to sync connected-online', {
}, { status: 500 }); message: 'Failed to sync connected-online',
},
{ status: 500 },
);
} }
}; };

View File

@@ -1,32 +1,48 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from 'next/server';
import { getAnalysisOrdersAdmin } from "~/lib/services/order.service";
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service"; import { retrieveOrder } from '@lib/data';
import { retrieveOrder } from "@lib/data";
import { getAccountAdmin } from "~/lib/services/account.service"; import { getAccountAdmin } from '~/lib/services/account.service';
import { getOrderedAnalysisElementsIds } from "~/lib/services/medipost.service"; import {
import loadEnv from "../handler/load-env"; composeOrderTestResponseXML,
import validateApiKey from "../handler/validate-api-key"; sendPrivateMessageTestResponse,
} from '~/lib/services/medipost/medipostTest.service';
import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service';
import { getAnalysisOrdersAdmin } from '~/lib/services/order.service';
import loadEnv from '../handler/load-env';
import validateApiKey from '../handler/validate-api-key';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
loadEnv(); loadEnv();
try { try {
validateApiKey(request); validateApiKey(request);
} catch (e) { } catch {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
} }
const analysisOrders = await getAnalysisOrdersAdmin({ orderStatus: 'PROCESSING' }); const analysisOrders = await getAnalysisOrdersAdmin({
orderStatus: 'PROCESSING',
});
console.error(`Sending test responses for ${analysisOrders.length} analysis orders`); console.error(
`Sending test responses for ${analysisOrders.length} analysis orders`,
);
for (const medreportOrder of analysisOrders) { for (const medreportOrder of analysisOrders) {
const medusaOrderId = medreportOrder.medusa_order_id; const medusaOrderId = medreportOrder.medusa_order_id;
const medusaOrder = await retrieveOrder(medusaOrderId) const medusaOrder = await retrieveOrder(medusaOrderId);
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); const account = await getAccountAdmin({
const orderedAnalysisElementsIds = await getOrderedAnalysisElementsIds({ medusaOrder }); primaryOwnerUserId: medreportOrder.user_id,
});
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({
medusaOrder,
});
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`); console.info(
`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`,
);
const idsToSend = orderedAnalysisElementsIds; const idsToSend = orderedAnalysisElementsIds;
const messageXml = await composeOrderTestResponseXML({ const messageXml = await composeOrderTestResponseXML({
person: { person: {
@@ -35,16 +51,20 @@ export async function POST(request: NextRequest) {
lastName: account.last_name ?? '', lastName: account.last_name ?? '',
phone: account.phone ?? '', phone: account.phone ?? '',
}, },
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId), orderedAnalysisElementsIds: idsToSend
orderedAnalysesIds: [], .map(({ analysisElementId }) => analysisElementId)
orderId: medusaOrderId, .filter(Boolean) as number[],
orderedAnalysesIds: idsToSend
.map(({ analysisId }) => analysisId)
.filter(Boolean) as number[],
orderId: medreportOrder.id,
orderCreatedAt: new Date(medreportOrder.created_at), orderCreatedAt: new Date(medreportOrder.created_at),
}); });
try { try {
await sendPrivateMessageTestResponse({ messageXml }); await sendPrivateMessageTestResponse({ messageXml });
} catch (error) { } catch (error) {
console.error("Error sending private message test response: ", error); console.error('Error sending private message test response: ', error);
} }
} }

View File

@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { z } from 'zod'; import { z } from 'zod';
@@ -94,4 +95,4 @@ export const POST = enhanceRouteHandler(
{ {
auth: false, auth: false,
}, },
); );

View File

@@ -1,9 +1,15 @@
import { NextResponse } from "next/server"; import { NextResponse } from 'next/server';
import { getOrder } from "~/lib/services/order.service";
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service"; import { retrieveOrder } from '@lib/data';
import { retrieveOrder } from "@lib/data";
import { getAccountAdmin } from "~/lib/services/account.service"; import { getAccountAdmin } from '~/lib/services/account.service';
import { createMedipostActionLog, getOrderedAnalysisElementsIds } from "~/lib/services/medipost.service"; import { upsertMedipostActionLog } from '~/lib/services/medipost/medipostMessageBase.service';
import {
composeOrderTestResponseXML,
sendPrivateMessageTestResponse,
} from '~/lib/services/medipost/medipostTest.service';
import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service';
import { getAnalysisOrder } from '~/lib/services/order.service';
export async function POST(request: Request) { export async function POST(request: Request) {
// const isDev = process.env.NODE_ENV === 'development'; // const isDev = process.env.NODE_ENV === 'development';
@@ -11,16 +17,21 @@ export async function POST(request: Request) {
// return NextResponse.json({ error: 'This endpoint is only available in development mode' }, { status: 403 }); // return NextResponse.json({ error: 'This endpoint is only available in development mode' }, { status: 403 });
// } // }
const { medusaOrderId, maxItems = null } = await request.json(); const { medusaOrderId } = await request.json();
const medusaOrder = await retrieveOrder(medusaOrderId) const medusaOrder = await retrieveOrder(medusaOrderId);
const medreportOrder = await getOrder({ medusaOrderId }); const analysisOrder = await getAnalysisOrder({ medusaOrderId });
const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); const account = await getAccountAdmin({
const orderedAnalysisElementsIds = await getOrderedAnalysisElementsIds({ medusaOrder }); primaryOwnerUserId: analysisOrder.user_id,
});
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({
medusaOrder,
});
console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} (${maxItems ?? 'all'}) ordered analysis elements`); console.info(
const idsToSend = typeof maxItems === 'number' ? orderedAnalysisElementsIds.slice(0, maxItems) : orderedAnalysisElementsIds; `Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`,
);
const messageXml = await composeOrderTestResponseXML({ const messageXml = await composeOrderTestResponseXML({
person: { person: {
idCode: account.personal_code!, idCode: account.personal_code!,
@@ -28,21 +39,26 @@ export async function POST(request: Request) {
lastName: account.last_name ?? '', lastName: account.last_name ?? '',
phone: account.phone ?? '', phone: account.phone ?? '',
}, },
orderedAnalysisElementsIds: idsToSend.map(({ analysisElementId }) => analysisElementId), orderedAnalysisElementsIds: orderedAnalysisElementsIds
orderedAnalysesIds: [], .map(({ analysisElementId }) => analysisElementId)
orderId: medusaOrderId, .filter(Boolean) as number[],
orderCreatedAt: new Date(medreportOrder.created_at), orderedAnalysesIds: orderedAnalysisElementsIds
.map(({ analysisId }) => analysisId)
.filter(Boolean) as number[],
orderId: analysisOrder.id,
orderCreatedAt: new Date(analysisOrder.created_at),
}); });
try { try {
await createMedipostActionLog({ await upsertMedipostActionLog({
action: 'send_fake_analysis_results_to_medipost', action: 'send_fake_analysis_results_to_medipost',
xml: messageXml, xml: messageXml,
medusaOrderId, medusaOrderId,
medipostPrivateMessageId: `fake-response-${Date.now()}`,
}); });
await sendPrivateMessageTestResponse({ messageXml }); await sendPrivateMessageTestResponse({ messageXml });
} catch (error) { } catch (error) {
console.error("Error sending private message test response: ", error); console.error('Error sending private message test response: ', error);
} }
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });

View File

@@ -3,15 +3,13 @@ import Link from 'next/link';
import type { AuthError } from '@supabase/supabase-js'; import type { AuthError } from '@supabase/supabase-js';
import { ResendAuthLinkForm } from '@kit/auth/resend-email-link'; import { ResendAuthLinkForm } from '@kit/auth/resend-email-link';
import { pathsConfig } from '@kit/shared/config';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { pathsConfig } from '@kit/shared/config';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
interface AuthCallbackErrorPageProps { interface AuthCallbackErrorPageProps {
searchParams: Promise<{ searchParams: Promise<{
error: string; error: string;

View File

@@ -1,5 +1,4 @@
import { AuthLayoutShell } from '@kit/auth/shared'; import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) { function AuthLayout({ children }: React.PropsWithChildren) {

View File

@@ -1,19 +1,68 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { createAuthCallbackService } from '@kit/supabase/auth'; import { createAccountsApi } from '@/packages/features/accounts/src/server/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { pathsConfig } from '@kit/shared/config'; import { pathsConfig } from '@kit/shared/config';
import {
createAuthCallbackService,
getErrorURLParameters,
} from '@kit/supabase/auth';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const ERROR_PATH = '/auth/callback/error';
const redirectOnError = (searchParams?: string) => {
return redirect(`${ERROR_PATH}${searchParams ? `?${searchParams}` : ''}`);
};
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const error = searchParams.get('error');
if (error) {
const { searchParams } = getErrorURLParameters({ error });
return redirectOnError(searchParams);
}
const authCode = searchParams.get('code');
if (!authCode) {
return redirectOnError();
}
let redirectPath = searchParams.get('next') || pathsConfig.app.home;
// if we have an invite token, we redirect to the join team page
// instead of the default next url. This is because the user is trying
// to join a team and we want to make sure they are redirected to the
// correct page.
const inviteToken = searchParams.get('invite_token');
if (inviteToken) {
const urlParams = new URLSearchParams({
invite_token: inviteToken,
email: searchParams.get('email') ?? '',
});
redirectPath = `${pathsConfig.app.joinTeam}?${urlParams.toString()}`;
}
const service = createAuthCallbackService(getSupabaseServerClient()); const service = createAuthCallbackService(getSupabaseServerClient());
const oauthResult = await service.exchangeCodeForSession(authCode);
const { nextPath } = await service.exchangeCodeForSession(request, { if (oauthResult.requiresMultiFactorAuthentication) {
joinTeamPath: pathsConfig.app.joinTeam, redirect(pathsConfig.auth.verifyMfa);
redirectPath: pathsConfig.app.home, }
});
return redirect(nextPath); if (!('isSuccess' in oauthResult)) {
return redirectOnError(oauthResult.searchParams);
}
const api = createAccountsApi(getSupabaseServerClient());
const account = await api.getPersonalAccountByUserId(oauthResult.user.id);
if (!account.email || !account.name || !account.last_name) {
return redirect(pathsConfig.auth.updateAccount);
}
return redirect(redirectPath);
} }

View File

@@ -1,5 +1,4 @@
import { AuthLayoutShell } from '@kit/auth/shared'; import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) { function AuthLayout({ children }: React.PropsWithChildren) {

View File

@@ -1,11 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { pathsConfig } from '@kit/shared/config';
import { createAuthCallbackService } from '@kit/supabase/auth'; import { createAuthCallbackService } from '@kit/supabase/auth';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { pathsConfig } from '@kit/shared/config';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const service = createAuthCallbackService(getSupabaseServerClient()); const service = createAuthCallbackService(getSupabaseServerClient());

View File

@@ -2,11 +2,11 @@
import React from 'react'; import React from 'react';
import { pathsConfig } from '@kit/shared/config';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data'; import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { SuccessNotification } from '@kit/notifications/components'; import { SuccessNotification } from '@kit/notifications/components';
import { pathsConfig } from '@kit/shared/config';
const MembershipConfirmationNotification: React.FC<{ const MembershipConfirmationNotification: React.FC<{
userId: string; userId: string;
@@ -25,7 +25,7 @@ const MembershipConfirmationNotification: React.FC<{
descriptionKey="account:membershipConfirmation:successDescription" descriptionKey="account:membershipConfirmation:successDescription"
buttonProps={{ buttonProps={{
buttonTitleKey: 'account:membershipConfirmation:successButton', buttonTitleKey: 'account:membershipConfirmation:successButton',
href: pathsConfig.app.home, href: pathsConfig.app.selectPackage,
}} }}
/> />
); );

View File

@@ -1,6 +1,3 @@
import { withI18n } from '~/lib/i18n/with-i18n';
async function SiteLayout(props: React.PropsWithChildren) { async function SiteLayout(props: React.PropsWithChildren) {
return ( return (
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}> <div className={'flex min-h-[100vh] flex-col items-center justify-center'}>

View File

@@ -1,5 +1,4 @@
import { AuthLayoutShell } from '@kit/auth/shared'; import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) { function AuthLayout({ children }: React.PropsWithChildren) {

View File

@@ -1,16 +1,14 @@
import Link from 'next/link'; import Link from 'next/link';
import { PasswordResetRequestContainer } from '@kit/auth/password-reset'; import { PasswordResetRequestContainer } from '@kit/auth/password-reset';
import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { pathsConfig } from '@kit/shared/config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();

View File

@@ -0,0 +1,56 @@
import Link from 'next/link';
import { Providers, SignInMethodsContainer } from '@kit/auth/sign-in';
import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
export default function PasswordOption({
inviteToken,
returnPath,
providers,
}: {
inviteToken?: string;
returnPath?: string;
providers: Providers;
}) {
const signUpPath =
pathsConfig.auth.signUp +
(inviteToken ? `?invite_token=${inviteToken}` : '');
const paths = {
callback: pathsConfig.auth.callback,
returnPath: returnPath ?? pathsConfig.app.home,
joinTeam: pathsConfig.app.joinTeam,
updateAccount: pathsConfig.auth.updateAccount,
};
return (
<>
<div className={'flex flex-col items-center gap-1'}>
<Heading level={4} className={'tracking-tight'}>
<Trans i18nKey={'auth:signInHeading'} />
</Heading>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'auth:signInSubheading'} />
</p>
</div>
<SignInMethodsContainer
inviteToken={inviteToken}
paths={paths}
providers={providers}
/>
<div className={'flex justify-center'}>
<Button asChild variant={'link'} size={'sm'}>
<Link href={signUpPath} prefetch={true}>
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
</Link>
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,38 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Loading from '@/app/home/loading';
import { getSupabaseBrowserClient } from '@/packages/supabase/src/clients/browser-client';
export function SignInPageClientRedirect() {
const router = useRouter();
useEffect(() => {
async function signIn() {
const { data, error } =
await getSupabaseBrowserClient().auth.signInWithOAuth({
provider: 'keycloak',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
queryParams: {
prompt: 'login',
},
},
});
if (error) {
console.error('OAuth error', error);
router.push('/');
} else if (data.url) {
router.push(data.url);
}
}
signIn();
}, [router]);
return <Loading />;
}

View File

@@ -1,5 +1,4 @@
import { AuthLayoutShell } from '@kit/auth/shared'; import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) { function AuthLayout({ children }: React.PropsWithChildren) {

View File

@@ -1,15 +1,11 @@
import Link from 'next/link'; import { getServerAuthConfig, pathsConfig } from '@kit/shared/config';
import { SignInMethodsContainer } from '@kit/auth/sign-in';
import { authConfig, pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import PasswordOption from './components/PasswordOption';
import { SignInPageClientRedirect } from './components/SignInPageClientRedirect';
interface SignInPageProps { interface SignInPageProps {
searchParams: Promise<{ searchParams: Promise<{
invite_token?: string; invite_token?: string;
@@ -26,47 +22,26 @@ export const generateMetadata = async () => {
}; };
async function SignInPage({ searchParams }: SignInPageProps) { async function SignInPage({ searchParams }: SignInPageProps) {
const { invite_token: inviteToken, next = pathsConfig.app.home } = const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } =
await searchParams; await searchParams;
const signUpPath = const authConfig = await getServerAuthConfig();
pathsConfig.auth.signUp +
(inviteToken ? `?invite_token=${inviteToken}` : '');
const paths = { if (authConfig.providers.password) {
callback: pathsConfig.auth.callback, return (
returnPath: next ?? pathsConfig.app.home, <PasswordOption
joinTeam: pathsConfig.app.joinTeam,
updateAccount: pathsConfig.auth.updateAccount,
};
return (
<>
<div className={'flex flex-col items-center gap-1'}>
<Heading level={4} className={'tracking-tight'}>
<Trans i18nKey={'auth:signInHeading'} />
</Heading>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'auth:signInSubheading'} />
</p>
</div>
<SignInMethodsContainer
inviteToken={inviteToken} inviteToken={inviteToken}
paths={paths} returnPath={returnPath}
providers={authConfig.providers} providers={authConfig.providers}
/> />
);
}
<div className={'flex justify-center'}> if (authConfig.providers.oAuth.includes('keycloak')) {
<Button asChild variant={'link'} size={'sm'}> return <SignInPageClientRedirect />;
<Link href={signUpPath} prefetch={true}> }
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
</Link> return null;
</Button>
</div>
</>
);
} }
export default withI18n(SignInPage); export default withI18n(SignInPage);

View File

@@ -1,5 +1,4 @@
import { AuthLayoutShell } from '@kit/auth/shared'; import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) { function AuthLayout({ children }: React.PropsWithChildren) {

View File

@@ -1,13 +1,13 @@
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation';
import { SignUpMethodsContainer } from '@kit/auth/sign-up'; import { SignUpMethodsContainer } from '@kit/auth/sign-up';
import { authConfig, pathsConfig } from '@kit/shared/config'; import { getServerAuthConfig, pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
export const generateMetadata = async () => { export const generateMetadata = async () => {
@@ -37,6 +37,12 @@ async function SignUpPage({ searchParams }: Props) {
pathsConfig.auth.signIn + pathsConfig.auth.signIn +
(inviteToken ? `?invite_token=${inviteToken}` : ''); (inviteToken ? `?invite_token=${inviteToken}` : '');
const authConfig = await getServerAuthConfig();
if (!authConfig.providers.password) {
return redirect('/');
}
return ( return (
<> <>
<div className={'flex flex-col items-center gap-1'}> <div className={'flex flex-col items-center gap-1'}>
@@ -50,8 +56,7 @@ async function SignUpPage({ searchParams }: Props) {
</div> </div>
<SignUpMethodsContainer <SignUpMethodsContainer
providers={authConfig.providers} authConfig={authConfig}
displayTermsCheckbox={authConfig.displayTermsCheckbox}
inviteToken={inviteToken} inviteToken={inviteToken}
paths={paths} paths={paths}
/> />

View File

@@ -1,12 +1,14 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { User } from '@supabase/supabase-js'; import { pathsConfig } from '@/packages/shared/src/config';
import { ExternalLink } from '@/public/assets/external-link'; import { ExternalLink } from '@/public/assets/external-link';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Checkbox } from '@kit/ui/checkbox'; import { Checkbox } from '@kit/ui/checkbox';
@@ -19,42 +21,97 @@ import {
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { UpdateAccountSchema } from '../_lib/schemas/update-account.schema'; import { UpdateAccountSchemaClient } from '../_lib/schemas/update-account.schema';
import { onUpdateAccount } from '../_lib/server/update-account'; import { onUpdateAccount } from '../_lib/server/update-account';
export function UpdateAccountForm({ user }: { user: User }) { type UpdateAccountFormValues = z.infer<
ReturnType<typeof UpdateAccountSchemaClient>
>;
export function UpdateAccountForm({
defaultValues,
isEmailUser,
}: {
defaultValues: UpdateAccountFormValues;
isEmailUser: boolean;
}) {
const router = useRouter();
const { t } = useTranslation('account');
const form = useForm({ const form = useForm({
resolver: zodResolver(UpdateAccountSchema), resolver: zodResolver(UpdateAccountSchemaClient({ isEmailUser })),
mode: 'onChange', mode: 'onChange',
defaultValues: { defaultValues,
firstName: '',
lastName: '',
personalCode: '',
email: user.email,
phone: '',
city: '',
weight: 0,
height: 0,
userConsent: false,
},
}); });
const { firstName, lastName, personalCode, email, userConsent } =
defaultValues;
const defaultValues_weight =
'weight' in defaultValues ? defaultValues.weight : null;
const defaultValues_height =
'height' in defaultValues ? defaultValues.height : null;
const hasFirstName = !!firstName;
const hasLastName = !!lastName;
const hasPersonalCode = !!personalCode;
const hasEmail = !!email;
const onUpdateAccountOptions = async (values: UpdateAccountFormValues) => {
const loading = toast.loading(t('updateAccount.updateAccountLoading'));
try {
const response = await onUpdateAccount({
firstName: values.firstName || firstName,
lastName: values.lastName || lastName,
personalCode: values.personalCode || personalCode,
email: values.email || email,
phone: values.phone,
weight: ((('weight' in values && values.weight) ??
defaultValues_weight) ||
null) as number,
height: ((('height' in values && values.height) ??
defaultValues_height) ||
null) as number,
userConsent: values.userConsent ?? userConsent,
city: values.city,
});
if (!response) {
throw new Error('Failed to update account');
}
toast.dismiss(loading);
toast.success(t('updateAccount.updateAccountSuccess'));
if (response.hasUnseenMembershipConfirmation) {
router.push(pathsConfig.auth.membershipConfirmation);
} else {
router.push(pathsConfig.app.selectPackage);
}
} catch (error) {
console.info('promiseresult error', error);
toast.error(t('updateAccount.updateAccountError'));
toast.dismiss(loading);
}
};
return ( return (
<Form {...form}> <Form {...form}>
<form <form
className="flex flex-col gap-6 px-6 pt-10 text-left" className="flex flex-col gap-6 px-6 pt-10 text-left"
onSubmit={form.handleSubmit(onUpdateAccount)} onSubmit={form.handleSubmit(onUpdateAccountOptions)}
> >
<FormField <FormField
name="firstName" name="firstName"
disabled={hasFirstName && !isEmailUser}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans i18nKey={'common:formField:firstName'} /> <Trans i18nKey={'common:formField:firstName'} />
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} autoFocus={!hasFirstName} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -63,13 +120,14 @@ export function UpdateAccountForm({ user }: { user: User }) {
<FormField <FormField
name="lastName" name="lastName"
disabled={hasLastName && !isEmailUser}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans i18nKey={'common:formField:lastName'} /> <Trans i18nKey={'common:formField:lastName'} />
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} autoFocus={hasFirstName && !hasLastName} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -78,6 +136,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
<FormField <FormField
name="personalCode" name="personalCode"
disabled={hasPersonalCode && !isEmailUser}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
@@ -93,13 +152,14 @@ export function UpdateAccountForm({ user }: { user: User }) {
<FormField <FormField
name="email" name="email"
disabled={hasEmail}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans i18nKey={'common:formField:email'} /> <Trans i18nKey={'common:formField:email'} />
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} disabled /> <Input {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -121,72 +181,74 @@ export function UpdateAccountForm({ user }: { user: User }) {
)} )}
/> />
<FormField <>
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:city'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-between gap-4">
<FormField <FormField
name="weight" name="city"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans i18nKey={'common:formField:weight'} /> <Trans i18nKey={'common:formField:city'} />
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input {...field} />
type="number"
placeholder="kg"
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value === '' ? null : Number(e.target.value),
)
}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <div className="flex flex-row justify-between gap-4">
name="height" <FormField
render={({ field }) => ( name="weight"
<FormItem> render={({ field }) => (
<FormLabel> <FormItem className="flex-1 basis-0">
<Trans i18nKey={'common:formField:height'} /> <FormLabel>
</FormLabel> <Trans i18nKey={'common:formField:weight'} />
<FormControl> </FormLabel>
<Input <FormControl>
placeholder="cm" <Input
type="number" type="number"
{...field} placeholder="kg"
value={field.value ?? ''} {...field}
onChange={(e) => value={field.value ?? ''}
field.onChange( onChange={(e) =>
e.target.value === '' ? null : Number(e.target.value), field.onChange(
) e.target.value === '' ? null : Number(e.target.value),
} )
/> }
</FormControl> />
<FormMessage /> </FormControl>
</FormItem> <FormMessage />
)} </FormItem>
/> )}
</div> />
<FormField
name="height"
render={({ field }) => (
<FormItem className="flex-1 basis-0">
<FormLabel>
<Trans i18nKey={'common:formField:height'} />
</FormLabel>
<FormControl>
<Input
placeholder="cm"
type="number"
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value === '' ? null : Number(e.target.value),
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
<FormField <FormField
name="userConsent" name="userConsent"
@@ -205,7 +267,7 @@ export function UpdateAccountForm({ user }: { user: User }) {
</div> </div>
<Link <Link
href={''} href={t('account:updateAccount:userConsentUrlPath')}
className="flex flex-row items-center gap-2 text-sm hover:underline" className="flex flex-row items-center gap-2 text-sm hover:underline"
target="_blank" target="_blank"
> >

View File

@@ -1,6 +1,8 @@
import Isikukood from 'isikukood';
import parsePhoneNumber from 'libphonenumber-js/min';
import { z } from 'zod'; import { z } from 'zod';
export const UpdateAccountSchema = z.object({ const updateAccountSchema = {
firstName: z firstName: z
.string({ .string({
error: 'First name is required', error: 'First name is required',
@@ -10,20 +12,46 @@ export const UpdateAccountSchema = z.object({
.string({ .string({
error: 'Last name is required', error: 'Last name is required',
}) })
.nonempty(), .nonempty({
personalCode: z error: 'common:formFieldError.stringNonEmpty',
.string({ }),
error: 'Personal code is required', personalCode: z.string().refine(
}) (val) => {
.nonempty(), try {
return new Isikukood(val).validate();
} catch {
return false;
}
},
{
message: 'common:formFieldError.invalidPersonalCode',
},
),
email: z.string().email({ email: z.string().email({
message: 'Email is required', message: 'Email is required',
}), }),
phone: z phone: z
.string({ .string({
error: 'Phone number is required', error: 'error:invalidPhone',
}) })
.nonempty(), .nonempty()
.refine(
(phone) => {
try {
const phoneNumber = parsePhoneNumber(phone);
return (
!!phoneNumber &&
phoneNumber.isValid() &&
phoneNumber.country === 'EE'
);
} catch {
return false;
}
},
{
message: 'common:formFieldError.invalidPhoneNumber',
},
),
city: z.string().optional(), city: z.string().optional(),
weight: z weight: z
.number({ .number({
@@ -45,4 +73,39 @@ export const UpdateAccountSchema = z.object({
userConsent: z.boolean().refine((val) => val === true, { userConsent: z.boolean().refine((val) => val === true, {
message: 'Must be true', message: 'Must be true',
}), }),
} as const;
export const UpdateAccountSchemaServer = z.object({
firstName: updateAccountSchema.firstName,
lastName: updateAccountSchema.lastName,
personalCode: updateAccountSchema.personalCode,
email: updateAccountSchema.email,
phone: updateAccountSchema.phone,
city: updateAccountSchema.city,
weight: updateAccountSchema.weight.nullable(),
height: updateAccountSchema.height.nullable(),
userConsent: updateAccountSchema.userConsent,
}); });
export const UpdateAccountSchemaClient = ({
isEmailUser,
}: {
isEmailUser: boolean;
}) =>
z.object({
firstName: updateAccountSchema.firstName,
lastName: updateAccountSchema.lastName,
personalCode: updateAccountSchema.personalCode,
email: updateAccountSchema.email,
phone: updateAccountSchema.phone,
...(isEmailUser
? {
city: z.string().optional(),
weight: z.number().optional(),
height: z.number().optional(),
}
: {
city: updateAccountSchema.city,
weight: updateAccountSchema.weight,
height: updateAccountSchema.height,
}),
userConsent: updateAccountSchema.userConsent,
});

View File

@@ -1,17 +1,12 @@
'use server'; 'use server';
import { redirect } from 'next/navigation';
import { updateCustomer } from '@lib/data/customer'; import { updateCustomer } from '@lib/data/customer';
import { AccountSubmitData, createAuthApi } from '@kit/auth/api'; import { AccountSubmitData, createAuthApi } from '@kit/auth/api';
import { enhanceAction } from '@kit/next/actions'; import { enhanceAction } from '@kit/next/actions';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { pathsConfig } from '@kit/shared/config'; import { UpdateAccountSchemaServer } from '../schemas/update-account.schema';
import { UpdateAccountSchema } from '../schemas/update-account.schema';
export const onUpdateAccount = enhanceAction( export const onUpdateAccount = enhanceAction(
async (params: AccountSubmitData) => { async (params: AccountSubmitData) => {
@@ -20,7 +15,6 @@ export const onUpdateAccount = enhanceAction(
try { try {
await api.updateAccount(params); await api.updateAccount(params);
console.log('SUCCESS', pathsConfig.auth.updateAccountSuccess);
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error) { if (err instanceof Error) {
console.warn('On update account error: ' + err.message); console.warn('On update account error: ' + err.message);
@@ -28,22 +22,23 @@ export const onUpdateAccount = enhanceAction(
console.warn('On update account error: ', err); console.warn('On update account error: ', err);
} }
await updateCustomer({ try {
first_name: params.firstName, await updateCustomer({
last_name: params.lastName, first_name: params.firstName,
phone: params.phone, last_name: params.lastName,
}); phone: params.phone,
});
} catch (e) {
console.error('Failed to update Medusa customer', e);
}
const hasUnseenMembershipConfirmation = const hasUnseenMembershipConfirmation =
await api.hasUnseenMembershipConfirmation(); await api.hasUnseenMembershipConfirmation();
return {
if (hasUnseenMembershipConfirmation) { hasUnseenMembershipConfirmation,
redirect(pathsConfig.auth.membershipConfirmation); };
} else {
redirect(pathsConfig.app.selectPackage);
}
}, },
{ {
schema: UpdateAccountSchema, schema: UpdateAccountSchemaServer,
}, },
); );

View File

@@ -1,6 +1,5 @@
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
async function SiteLayout(props: React.PropsWithChildren) { async function SiteLayout(props: React.PropsWithChildren) {
return ( return (
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}> <div className={'flex min-h-[100vh] flex-col items-center justify-center'}>

View File

@@ -1,7 +1,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
import { signOutAction } from '@/lib/actions/sign-out'; import { signOutAction } from '@/lib/actions/sign-out';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { BackButton } from '@kit/shared/components/back-button'; import { BackButton } from '@kit/shared/components/back-button';
import { MedReportLogo } from '@kit/shared/components/med-report-logo'; import { MedReportLogo } from '@kit/shared/components/med-report-logo';
@@ -9,23 +9,40 @@ import { pathsConfig } from '@kit/shared/config';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import { toTitleCase } from '~/lib/utils';
import { UpdateAccountForm } from './_components/update-account-form'; import { UpdateAccountForm } from './_components/update-account-form';
async function UpdateAccount() { async function UpdateAccount() {
const client = getSupabaseServerClient(); const { account, user } = await loadCurrentUserAccount();
const { const isKeycloakUser = user?.app_metadata?.provider === 'keycloak';
data: { user }, const isEmailUser = user?.app_metadata?.provider === 'email';
} = await client.auth.getUser();
if (!user) { if (!user) {
redirect(pathsConfig.auth.signIn); redirect(pathsConfig.auth.signIn);
} }
const defaultValues = {
firstName: account?.name ? toTitleCase(account.name) : '',
lastName: account?.last_name ? toTitleCase(account.last_name) : '',
personalCode: account?.personal_code ?? '',
email: (() => {
if (isKeycloakUser) {
return account?.email ?? '';
}
return account?.email ?? user?.email ?? '';
})(),
phone: account?.phone ?? '+372',
city: account?.city ?? '',
weight: account?.accountParams?.weight ?? null,
height: account?.accountParams?.height ?? null,
userConsent: account?.has_consent_personal_data ?? false,
};
return ( return (
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border"> <div className="border-border xs:w-3/5 flex w-9/10 flex-row overflow-hidden rounded-3xl border md:w-[988px]">
<div className="relative flex min-w-md flex-col px-12 pt-7 pb-22 text-center md:w-1/2"> <div className="xs:px-6 relative flex w-full flex-col pt-7 pb-22 text-center md:w-1/2 md:px-12">
<BackButton onBack={signOutAction} /> <BackButton onBack={signOutAction} />
<MedReportLogo /> <MedReportLogo />
<h1 className="pt-8"> <h1 className="pt-8">
@@ -34,9 +51,12 @@ async function UpdateAccount() {
<p className="text-muted-foreground pt-1 text-sm"> <p className="text-muted-foreground pt-1 text-sm">
<Trans i18nKey={'account:updateAccount:description'} /> <Trans i18nKey={'account:updateAccount:description'} />
</p> </p>
<UpdateAccountForm user={user} /> <UpdateAccountForm
defaultValues={defaultValues}
isEmailUser={isEmailUser}
/>
</div> </div>
<div className="hidden w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat md:block"></div> <div className="hidden w-1/2 bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat md:block"></div>
</div> </div>
); );
} }

View File

@@ -1,5 +1,4 @@
import { AuthLayoutShell } from '@kit/auth/shared'; import { AuthLayoutShell } from '@kit/auth/shared';
import { AppLogo } from '@kit/shared/components/app-logo'; import { AppLogo } from '@kit/shared/components/app-logo';
function AuthLayout({ children }: React.PropsWithChildren) { function AuthLayout({ children }: React.PropsWithChildren) {

View File

@@ -1,11 +1,10 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { MultiFactorChallengeContainer } from '@kit/auth/mfa'; import { MultiFactorChallengeContainer } from '@kit/auth/mfa';
import { pathsConfig } from '@kit/shared/config';
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa'; import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { pathsConfig } from '@kit/shared/config';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
@@ -41,15 +40,11 @@ async function VerifyPage(props: Props) {
} }
const nextPath = (await props.searchParams).next; const nextPath = (await props.searchParams).next;
const redirectPath = !!nextPath && nextPath.length > 0 ? nextPath : pathsConfig.app.home; const redirectPath =
!!nextPath && nextPath.length > 0 ? nextPath : pathsConfig.app.home;
return ( return (
<MultiFactorChallengeContainer <MultiFactorChallengeContainer userId={user.id} paths={{ redirectPath }} />
userId={user.id}
paths={{
redirectPath,
}}
/>
); );
} }

View File

@@ -0,0 +1,161 @@
'use client';
import React, { ReactElement, ReactNode, useMemo, useState } from 'react';
import { format } from 'date-fns';
import { Info } from 'lucide-react';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { AnalysisElement } from '~/lib/services/analysis-element.service';
import { NestedAnalysisElement } from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema';
import AnalysisLevelBar, {
AnalysisLevelBarSkeleton,
AnalysisResultLevel,
} from './analysis-level-bar';
export type AnalysisResultForDisplay = {
norm_status?: number | null;
response_value?: number | null;
unit?: string | null;
norm_lower_included?: boolean | null;
norm_upper_included?: boolean | null;
norm_lower?: number | null;
norm_upper?: number | null;
response_time?: string | null;
nestedElements?: NestedAnalysisElement[];
};
const AnalysisDoctor = ({
analysisElement,
results,
startIcon,
endIcon,
isCancelled,
}: {
analysisElement: Pick<AnalysisElement, 'analysis_name_lab'>;
results?: AnalysisResultForDisplay;
isCancelled?: boolean;
startIcon?: ReactElement | null;
endIcon?: ReactNode | null;
}) => {
const name = analysisElement.analysis_name_lab || '';
const status = results?.norm_status;
const value = results?.response_value || 0;
const unit = results?.unit || '';
const normLower = results?.norm_lower ?? null;
const normUpper = results?.norm_upper ?? null;
const [showTooltip, setShowTooltip] = useState(false);
const analysisResultLevel = useMemo(() => {
if (!results || status === null || status === undefined) {
return null;
}
switch (status) {
case 1:
return AnalysisResultLevel.WARNING;
case 2:
return AnalysisResultLevel.CRITICAL;
case 0:
default:
return AnalysisResultLevel.NORMAL;
}
}, [results, status]);
const normRangeText = useMemo(() => {
if (normLower === null && normUpper === null) {
return null;
}
return `${normLower ?? '...'} - ${normUpper ?? '...'}`;
}, [normLower, normUpper]);
const nestedElements = results?.nestedElements ?? null;
const hasNestedElements =
Array.isArray(nestedElements) && nestedElements.length > 0;
const isAnalysisLevelBarHidden = isCancelled || !results || hasNestedElements;
return (
<div className="border-border rounded-lg border px-5">
<div className="flex flex-col items-center justify-between gap-2 py-3 sm:h-[65px] sm:flex-row sm:gap-0">
<div className="flex items-center gap-2 font-semibold">
{startIcon || <div className="w-4" />}
{name}
{results?.response_time && (
<div
className="group/tooltip relative"
onClick={(e) => {
e.stopPropagation();
setShowTooltip(!showTooltip);
}}
onMouseLeave={() => setShowTooltip(false)}
>
<Info className="hover" />{' '}
<div
className={cn(
'absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 rounded border bg-white p-4 text-sm whitespace-nowrap group-hover/tooltip:block',
{ block: showTooltip },
)}
>
<Trans i18nKey="analysis-results:analysisDate" />
{': '}
{format(new Date(results.response_time), 'dd.MM.yyyy HH:mm')}
</div>
</div>
)}
</div>
{isAnalysisLevelBarHidden ? null : (
<>
<div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">{value}</div>
<div className="text-muted-foreground text-sm">{unit}</div>
</div>
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
{normRangeText}
<div>
<Trans i18nKey="analysis-results:results.range.normal" />
</div>
</div>
<AnalysisLevelBar
results={results}
level={analysisResultLevel}
normRangeText={normRangeText}
/>
{endIcon || <div className="mx-2 w-4" />}
</>
)}
{(() => {
// If parent has nested elements, don't show anything
if (hasNestedElements) {
return null;
}
// If we're showing the level bar, don't show waiting
if (!isAnalysisLevelBarHidden) {
return null;
}
// If cancelled, don't show waiting
if (isCancelled) {
return null;
}
// Otherwise, show waiting for results
return (
<>
<div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">
<Trans i18nKey="analysis-results:waitingForResults" />
</div>
</div>
<div className="mx-8 w-[60px]"></div>
<AnalysisLevelBarSkeleton />
</>
);
})()}
</div>
</div>
);
};
export default AnalysisDoctor;

View File

@@ -0,0 +1,27 @@
'use server';
import React from 'react';
import { Spinner } from '@kit/ui/makerkit/spinner';
import { Trans } from '@kit/ui/makerkit/trans';
import { Progress } from '@kit/ui/shadcn/progress';
import { withI18n } from '~/lib/i18n/with-i18n';
const AnalysisFallback = ({
progress,
progressTextKey,
}: {
progress: number;
progressTextKey: string;
}) => {
return (
<div className="flex flex-col items-center justify-center gap-4 py-10">
<Trans i18nKey={progressTextKey} />
<Spinner />
<Progress value={progress} />
</div>
);
};
export default withI18n(AnalysisFallback);

View File

@@ -0,0 +1,177 @@
import React, { useState } from 'react';
import { giveFeedbackAction } from '@/packages/features/doctor/src/lib/server/actions/doctor-server-actions';
import {
DoctorFeedback,
Order,
Patient,
} from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema';
import {
DoctorAnalysisFeedbackForm,
doctorAnalysisFeedbackFormSchema,
} from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis.schema';
import ConfirmationModal from '@/packages/shared/src/components/confirmation-modal';
import { useUser } from '@/packages/supabase/src/hooks/use-user';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { Spinner } from '@kit/ui/makerkit/spinner';
import { Trans } from '@kit/ui/makerkit/trans';
import { Button } from '@kit/ui/shadcn/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@kit/ui/shadcn/form';
import { toast } from '@kit/ui/shadcn/sonner';
import { Textarea } from '@kit/ui/shadcn/textarea';
const AnalysisFeedback = ({
feedback,
patient,
order,
aiDoctorFeedback,
timestamp,
recommendations,
isRecommendationsEdited,
}: {
feedback?: DoctorFeedback;
patient: Patient;
order: Order;
aiDoctorFeedback?: string;
timestamp?: string;
recommendations: string[];
isRecommendationsEdited: boolean;
}) => {
const [isDraftSubmitting, setIsDraftSubmitting] = useState(false);
const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false);
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const { data: user } = useUser();
const queryClient = useQueryClient();
const form = useForm({
resolver: zodResolver(doctorAnalysisFeedbackFormSchema),
reValidateMode: 'onChange',
defaultValues: {
feedbackValue: feedback?.value ?? aiDoctorFeedback ?? '',
userId: patient.userId,
},
});
const isReadOnly =
!!feedback?.doctor_user_id && feedback?.doctor_user_id !== user?.id;
const handleDraftSubmit = async (e: React.FormEvent) => {
setIsDraftSubmitting(true);
e.preventDefault();
form.formState.errors.feedbackValue = undefined;
const formData = form.getValues();
await onSubmit(formData, 'DRAFT');
setIsDraftSubmitting(false);
};
const handleCompleteSubmit = form.handleSubmit(async () => {
setIsConfirmOpen(true);
});
const onSubmit = async (
data: DoctorAnalysisFeedbackForm,
status: 'DRAFT' | 'COMPLETED',
) => {
setIsConfirmOpen(false);
setIsSubmittingFeedback(true);
const result = await giveFeedbackAction({
...data,
analysisOrderId: order.analysisOrderId,
status,
patientId: patient.userId,
timestamp,
recommendations,
isRecommendationsEdited,
});
if (!result.success) {
return toast.error(<Trans i18nKey="common:genericServerError" />);
}
setIsSubmittingFeedback(false);
queryClient.invalidateQueries({
predicate: (query) => query.queryKey.includes('doctor-jobs'),
});
return toast.success(<Trans i18nKey={'doctor:updateFeedbackSuccess'} />);
};
const confirmComplete = form.handleSubmit(async (data) => {
await onSubmit(data, 'COMPLETED');
});
return (
<>
{!isReadOnly && (
<Form {...form}>
<form className="space-y-4 lg:w-1/2">
<FormField
control={form.control}
name="feedbackValue"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
className="min-h-[200px]"
{...field}
disabled={isDraftSubmitting || isSubmittingFeedback}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="xs:flex block justify-end gap-2 space-y-2">
<Button
type="button"
variant="outline"
onClick={handleDraftSubmit}
disabled={
isReadOnly || isDraftSubmitting || form.formState.isSubmitting
}
className="xs:w-auto w-full text-xs"
>
<Trans i18nKey="common:saveAsDraft" />
</Button>
<Button
type="button"
onClick={handleCompleteSubmit}
disabled={
isReadOnly || isDraftSubmitting || form.formState.isSubmitting
}
className="xs:w-1/4 w-full"
>
{isDraftSubmitting || form.formState.isSubmitting ? (
<Spinner />
) : (
<Trans i18nKey="common:save" />
)}
</Button>
</div>
</form>
</Form>
)}
<ConfirmationModal
isOpen={isConfirmOpen}
onClose={() => setIsConfirmOpen(false)}
onConfirm={confirmComplete}
titleKey="doctor:confirmFeedbackModal.title"
descriptionKey="doctor:confirmFeedbackModal.description"
/>
</>
);
};
export default AnalysisFeedback;

View File

@@ -0,0 +1,220 @@
import { useMemo } from 'react';
import { ArrowDown } from 'lucide-react';
import { cn } from '@kit/ui/utils';
import { AnalysisResultForDisplay } from './analysis-doctor';
export enum AnalysisResultLevel {
NORMAL = 'NORMAL',
WARNING = 'WARNING',
CRITICAL = 'CRITICAL',
}
const Level = ({
isActive = false,
color,
isFirst = false,
isLast = false,
arrowLocation,
normRangeText,
}: {
isActive?: boolean;
color: 'destructive' | 'success' | 'warning' | 'gray-200';
isFirst?: boolean;
isLast?: boolean;
arrowLocation?: number;
normRangeText?: string | null;
}) => {
return (
<div
className={cn(`bg-${color} relative h-3 flex-1`, {
'opacity-60': !isActive,
'rounded-l-lg': isFirst,
'rounded-r-lg': isLast,
})}
>
{isActive && (
<div
className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]"
{...(arrowLocation
? {
style: {
left: `${arrowLocation}%`,
...(arrowLocation > 92.5 && { left: '92.5%' }),
...(arrowLocation < 7.5 && { left: '7.5%' }),
},
}
: {})}
>
<ArrowDown strokeWidth={2} />
</div>
)}
{color === 'success' && typeof normRangeText === 'string' && (
<p
className={cn(
'text-muted-foreground absolute bottom-[-18px] left-3/8 text-xs font-bold whitespace-nowrap',
{
'opacity-60': isActive,
},
)}
>
{normRangeText}
</p>
)}
</div>
);
};
export const AnalysisLevelBarSkeleton = () => {
return (
<div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
<Level color="gray-200" isFirst isLast />
</div>
);
};
const AnalysisLevelBar = ({
level,
results,
normRangeText,
}: {
level: AnalysisResultLevel | null;
results: AnalysisResultForDisplay;
normRangeText: string | null;
}) => {
const {
norm_lower: lower,
norm_upper: upper,
response_value: value,
} = results;
// Calculate arrow position based on value within normal range
const arrowLocation = useMemo(() => {
// If no response value, center the arrow
if (value === null || value === undefined) {
return 50;
}
// If no normal ranges defined, center the arrow
if (lower === null && upper === null) {
return 50;
}
// If only upper bound exists
if (lower === null && upper !== null) {
if (value <= upper!) {
return Math.min(75, (value / upper!) * 75); // Show in left 75% of normal range
}
return 100; // Beyond upper bound
}
// If only lower bound exists
if (upper === null && lower !== null) {
if (value >= lower!) {
// Value is in normal range (above lower bound)
// Position proportionally in the normal range section
const normalizedPosition = Math.min(
(value - lower!) / (lower! * 0.5),
1,
); // Use 50% of lower as scale
return normalizedPosition * 100;
}
// Value is below lower bound - position in the "below normal" section
const belowPosition = Math.max(0, Math.min(1, value / lower!));
return belowPosition * 100;
}
// Both bounds exist
if (lower !== null && upper !== null) {
if (value < lower!) {
return 0; // Below normal range
}
if (value > upper!) {
return 100; // Above normal range
}
// Within normal range
return ((value - lower!) / (upper! - lower!)) * 100;
}
return 50; // Fallback
}, [value, upper, lower]);
// Determine level states based on normStatus
const isNormal = level === AnalysisResultLevel.NORMAL;
const isWarning = level === AnalysisResultLevel.WARNING;
const isCritical = level === AnalysisResultLevel.CRITICAL;
const isPending = level === null;
// Show appropriate levels based on available norm bounds
const hasLowerBound = lower !== null;
// Calculate level configuration (must be called before any returns)
const [first, second, third] = useMemo(() => {
const [warning, normal, critical] = [
{
isActive: isWarning,
color: 'warning',
...(isWarning ? { arrowLocation } : {}),
},
{
isActive: isNormal,
color: 'success',
normRangeText,
...(isNormal ? { arrowLocation } : {}),
},
{
isActive: isCritical,
color: 'destructive',
isLast: true,
...(isCritical ? { arrowLocation } : {}),
},
] as const;
if (!hasLowerBound) {
return [{ ...normal, isFirst: true }, warning, critical] as const;
}
return [
{ ...warning, isFirst: true },
normal,
{ ...critical, isLast: true },
] as const;
}, [
arrowLocation,
normRangeText,
isNormal,
isWarning,
isCritical,
hasLowerBound,
]);
// If pending results, show gray bar
if (isPending) {
return (
<div className="w-60% mt-4 flex h-3 max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
<Level color="gray-200" isFirst isLast />
</div>
);
}
return (
<div
className={cn(
'flex h-3 gap-1',
'mt-4 sm:mt-0',
'w-[60%] sm:w-[35%]',
'min-w-[50vw] sm:min-w-auto',
'max-w-[360px]',
)}
>
<Level {...first} />
<Level {...second} />
<Level {...third} />
</div>
);
};
export default AnalysisLevelBar;

View File

@@ -1,13 +1,10 @@
'use client'; 'use client';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { capitalize } from 'lodash'; import { capitalize } from 'lodash';
import { useForm } from 'react-hook-form';
import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions';
import { import {
getDOBWithAgeStringFromPersonalCode, getDOBWithAgeStringFromPersonalCode,
getResultSetName, getResultSetName,
@@ -18,48 +15,50 @@ import {
Order, Order,
Patient, Patient,
} from '@kit/doctor/schema/doctor-analysis-detail-view.schema'; } from '@kit/doctor/schema/doctor-analysis-detail-view.schema';
import { import { useCurrentLocaleLanguageNames } from '@kit/shared/hooks';
DoctorAnalysisFeedbackForm,
doctorAnalysisFeedbackFormSchema,
} from '@kit/doctor/schema/doctor-analysis.schema';
import ConfirmationModal from '@kit/shared/components/confirmation-modal';
import {
useCurrentLocaleLanguageNames
} from '@kit/shared/hooks';
import { getFullName } from '@kit/shared/utils'; import { getFullName } from '@kit/shared/utils';
import { useUser } from '@kit/supabase/hooks/use-user'; import { useUser } from '@kit/supabase/hooks/use-user';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@kit/ui/form';
import { toast } from '@kit/ui/sonner';
import { Textarea } from '@kit/ui/textarea';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { bmiFromMetric } from '~/lib/utils'; import { bmiFromMetric } from '~/lib/utils';
import AnalysisFeedback from './analysis-feedback';
import DoctorAnalysisWrapper from './doctor-analysis-wrapper'; import DoctorAnalysisWrapper from './doctor-analysis-wrapper';
import DoctorJobSelect from './doctor-job-select'; import DoctorJobSelect from './doctor-job-select';
import DoctorRecommendedAnalyses from './doctor-recommended-analyses';
export default function AnalysisView({ export default function AnalysisView({
patient, patient,
order, order,
analyses, analyses,
feedback, feedback,
aiDoctorFeedback,
recommendations,
availableAnalyses,
timestamp,
}: { }: {
patient: Patient; patient: Patient;
order: Order; order: Order;
analyses: AnalysisResponse[]; analyses: AnalysisResponse[];
feedback?: DoctorFeedback; feedback?: DoctorFeedback;
aiDoctorFeedback?: string;
recommendations?: string[];
availableAnalyses?: string[];
timestamp?: string;
}) { }) {
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [isDraftSubmitting, setIsDraftSubmitting] = useState(false);
const { data: user } = useUser(); const { data: user } = useUser();
const queryClient = useQueryClient();
const [recommendedAnalyses, setRecommendedAnalyses] = useState<string[]>(
recommendations ?? [],
);
const isRecommendationsEdited = useMemo(() => {
if (recommendedAnalyses.length !== recommendations?.length) return true;
const sa = new Set(recommendedAnalyses),
sb = new Set(recommendations);
if (sa.size !== sb.size) return true;
for (const v of sa) if (!sb.has(v)) return true;
return false;
}, [recommendations, recommendedAnalyses]);
const languageNames = useCurrentLocaleLanguageNames(); const languageNames = useCurrentLocaleLanguageNames();
@@ -70,66 +69,11 @@ export default function AnalysisView({
); );
const isCurrentDoctorJob = const isCurrentDoctorJob =
!!feedback?.doctor_user_id && feedback?.doctor_user_id === user?.id; !!feedback?.doctor_user_id && feedback?.doctor_user_id === user?.id;
const isReadOnly =
!isInProgress ||
(!!feedback?.doctor_user_id && feedback?.doctor_user_id !== user?.id);
const form = useForm({
resolver: zodResolver(doctorAnalysisFeedbackFormSchema),
reValidateMode: 'onChange',
defaultValues: {
feedbackValue: feedback?.value ?? '',
userId: patient.userId,
},
});
const queryClient = useQueryClient();
if (!patient || !order || !analyses) { if (!patient || !order || !analyses) {
return null; return null;
} }
const onSubmit = async (
data: DoctorAnalysisFeedbackForm,
status: 'DRAFT' | 'COMPLETED',
) => {
const result = await giveFeedbackAction({
...data,
analysisOrderId: order.analysisOrderId,
status,
});
if (!result.success) {
return toast.error(<Trans i18nKey="common:genericServerError" />);
}
queryClient.invalidateQueries({
predicate: (query) => query.queryKey.includes('doctor-jobs'),
});
toast.success(<Trans i18nKey={'doctor:updateFeedbackSuccess'} />);
return setIsConfirmOpen(false);
};
const handleDraftSubmit = async (e: React.FormEvent) => {
setIsDraftSubmitting(true);
e.preventDefault();
form.formState.errors.feedbackValue = undefined;
const formData = form.getValues();
await onSubmit(formData, 'DRAFT');
setIsDraftSubmitting(false);
};
const handleCompleteSubmit = form.handleSubmit(async () => {
setIsConfirmOpen(true);
});
const confirmComplete = form.handleSubmit(async (data) => {
await onSubmit(data, 'COMPLETED');
});
return ( return (
<> <>
<div className="xs:flex xs:justify-between"> <div className="xs:flex xs:justify-between">
@@ -231,59 +175,30 @@ export default function AnalysisView({
); );
})} })}
</div> </div>
<h3> {order.isPackage && (
<Trans i18nKey="doctor:feedback" /> <>
</h3> <h3>
<p>{feedback?.value ?? '-'}</p> <Trans i18nKey="doctor:feedback" />
{!isReadOnly && ( </h3>
<Form {...form}> <p>{feedback?.value ?? '-'}</p>
<form className="space-y-4 lg:w-1/2"> <div className="flex flex-col gap-4 lg:flex-row">
<FormField <AnalysisFeedback
control={form.control} order={order}
name="feedbackValue" patient={patient}
render={({ field }) => ( feedback={feedback}
<FormItem> aiDoctorFeedback={aiDoctorFeedback}
<FormControl> timestamp={timestamp}
<Textarea {...field} disabled={isReadOnly} /> recommendations={recommendedAnalyses}
</FormControl> isRecommendationsEdited={isRecommendationsEdited}
<FormMessage />
</FormItem>
)}
/> />
<DoctorRecommendedAnalyses
<div className="xs:flex block justify-end gap-2 space-y-2"> recommendedAnalyses={recommendedAnalyses}
<Button availableAnalyses={availableAnalyses}
type="button" setRecommendedAnalyses={setRecommendedAnalyses}
variant="outline" />
onClick={handleDraftSubmit} </div>
disabled={ </>
isReadOnly || isDraftSubmitting || form.formState.isSubmitting
}
className="xs:w-1/4 w-full"
>
<Trans i18nKey="common:saveAsDraft" />
</Button>
<Button
type="button"
onClick={handleCompleteSubmit}
disabled={
isReadOnly || isDraftSubmitting || form.formState.isSubmitting
}
className="xs:w-1/4 w-full"
>
<Trans i18nKey="common:save" />
</Button>
</div>
</form>
</Form>
)} )}
<ConfirmationModal
isOpen={isConfirmOpen}
onClose={() => setIsConfirmOpen(false)}
onConfirm={confirmComplete}
titleKey="doctor:confirmFeedbackModal.title"
descriptionKey="doctor:confirmFeedbackModal.description"
/>
</> </>
); );
} }

View File

@@ -1,5 +1,7 @@
'use client'; 'use client';
import React from 'react';
import { CaretDownIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons'; import { CaretDownIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -13,7 +15,7 @@ import {
} from '@kit/ui/collapsible'; } from '@kit/ui/collapsible';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import Analysis from '~/home/(user)/(dashboard)/analysis-results/_components/analysis'; import AnalysisDoctor from './analysis-doctor';
export default function DoctorAnalysisWrapper({ export default function DoctorAnalysisWrapper({
analysisData, analysisData,
@@ -23,55 +25,25 @@ export default function DoctorAnalysisWrapper({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Collapsible className="w-full" key={analysisData.id}> <>
<CollapsibleTrigger <Collapsible className="w-full" key={analysisData.id}>
disabled={!analysisData.latestPreviousAnalysis} <CollapsibleTrigger
asChild disabled={!analysisData.latestPreviousAnalysis}
> asChild
<div className="[&[data-state=open]_.caret-icon]:rotate-180"> >
<Analysis <div className="[&[data-state=open]_.caret-icon]:rotate-180">
startIcon={ <AnalysisDoctor
analysisData.latestPreviousAnalysis && ( startIcon={
<CaretDownIcon className="caret-icon transition-transform duration-200" /> analysisData.latestPreviousAnalysis && (
) <CaretDownIcon className="caret-icon transition-transform duration-200" />
} )
endIcon={ }
analysisData.comment && (
<>
<div className="xs:flex hidden">
<InfoTooltip
content={analysisData.comment}
icon={
<QuestionMarkCircledIcon className="mx-2 text-blue-800" />
}
/>
</div>
<p className="xs:hidden">
<strong>
<Trans i18nKey="doctor:labComment" />:
</strong>{' '}
{analysisData.comment}
</p>
</>
)
}
analysisElement={{
analysis_name_lab: analysisData.analysis_name,
}}
results={analysisData}
/>
</div>
</CollapsibleTrigger>
{analysisData.latestPreviousAnalysis && (
<CollapsibleContent>
<div className="my-1 flex flex-col">
<Analysis
endIcon={ endIcon={
analysisData.latestPreviousAnalysis.comment && ( analysisData.comment && (
<> <>
<div className="xs:flex hidden"> <div className="xs:flex hidden">
<InfoTooltip <InfoTooltip
content={analysisData.latestPreviousAnalysis.comment} content={analysisData.comment}
icon={ icon={
<QuestionMarkCircledIcon className="mx-2 text-blue-800" /> <QuestionMarkCircledIcon className="mx-2 text-blue-800" />
} }
@@ -79,25 +51,103 @@ export default function DoctorAnalysisWrapper({
</div> </div>
<p className="xs:hidden"> <p className="xs:hidden">
<strong> <strong>
<Trans i18nKey="doctor:labComment" />:{' '} <Trans i18nKey="doctor:labComment" />:
</strong> </strong>{' '}
{analysisData.latestPreviousAnalysis.comment} {analysisData.comment}
</p> </p>
</> </>
) )
} }
analysisElement={{ analysisElement={{
analysis_name_lab: t('doctor:previousResults', { analysis_name_lab: analysisData.analysis_name,
date: formatDate(
analysisData.latestPreviousAnalysis.response_time,
),
}),
}} }}
results={analysisData.latestPreviousAnalysis} results={analysisData}
/> />
</div> </div>
</CollapsibleContent> </CollapsibleTrigger>
)} {analysisData.latestPreviousAnalysis && (
</Collapsible> <CollapsibleContent>
<div className="my-1 flex flex-col gap-2">
<AnalysisDoctor
endIcon={
analysisData.latestPreviousAnalysis.comment && (
<>
<div className="xs:flex hidden">
<InfoTooltip
content={analysisData.latestPreviousAnalysis.comment}
icon={
<QuestionMarkCircledIcon className="mx-2 text-blue-800" />
}
/>
</div>
<p className="xs:hidden">
<strong>
<Trans i18nKey="doctor:labComment" />:{' '}
</strong>
{analysisData.latestPreviousAnalysis.comment}
</p>
</>
)
}
analysisElement={{
analysis_name_lab: t('doctor:previousResults', {
date: formatDate(
analysisData.latestPreviousAnalysis.response_time!,
),
}),
}}
results={analysisData.latestPreviousAnalysis}
/>
{analysisData.latestPreviousAnalysis.nestedElements?.map(
(nestedElement, nestedIndex) => (
<div
key={`prev-nested-${nestedElement.analysisElementOriginalId}-${nestedIndex}`}
className="ml-8"
>
<AnalysisDoctor
analysisElement={{
analysis_name_lab: nestedElement.analysisNameLab ?? '',
}}
results={{
norm_status: nestedElement.normStatus,
response_value: nestedElement.responseValue,
unit: nestedElement.unit,
norm_lower: nestedElement.normLower,
norm_upper: nestedElement.normUpper,
norm_lower_included: nestedElement.normLowerIncluded,
norm_upper_included: nestedElement.normUpperIncluded,
response_time: nestedElement.responseTime,
}}
/>
</div>
),
)}
</div>
</CollapsibleContent>
)}
</Collapsible>
{analysisData.nestedElements?.map((nestedElement, nestedIndex) => (
<div
key={`nested-${nestedElement.analysisElementOriginalId}-${nestedIndex}`}
className="ml-8"
>
<AnalysisDoctor
analysisElement={{
analysis_name_lab: nestedElement.analysisNameLab ?? '',
}}
results={{
norm_status: nestedElement.normStatus,
response_value: nestedElement.responseValue,
unit: nestedElement.unit,
norm_lower: nestedElement.normLower,
norm_upper: nestedElement.normUpper,
norm_lower_included: nestedElement.normLowerIncluded,
norm_upper_included: nestedElement.normUpperIncluded,
response_time: nestedElement.responseTime,
}}
/>
</div>
))}
</>
); );
} }

View File

@@ -6,9 +6,10 @@ import {
getUserDoneResponsesAction, getUserDoneResponsesAction,
getUserInProgressResponsesAction, getUserInProgressResponsesAction,
} from '@kit/doctor/actions/table-data-fetching-actions'; } from '@kit/doctor/actions/table-data-fetching-actions';
import ResultsTableWrapper from './results-table-wrapper'; import ResultsTableWrapper from './results-table-wrapper';
export default function Dashboard() { export default function DoctorDashboard() {
return ( return (
<> <>
<ResultsTableWrapper <ResultsTableWrapper

View File

@@ -1,8 +1,7 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isDoctor } from '@kit/doctor/lib/server/utils/is-doctor'; import { isDoctor } from '@kit/doctor/lib/server/utils/is-doctor';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
type LayoutOrPageComponent<Params> = React.ComponentType<Params>; type LayoutOrPageComponent<Params> = React.ComponentType<Params>;

View File

@@ -0,0 +1,53 @@
'use client';
import React, { Dispatch, SetStateAction } from 'react';
import { Trans } from '@kit/ui/makerkit/trans';
import { Button } from '@kit/ui/shadcn/button';
const DoctorRecommendedAnalyses = ({
recommendedAnalyses,
availableAnalyses,
setRecommendedAnalyses,
}: {
recommendedAnalyses?: string[];
availableAnalyses?: string[];
setRecommendedAnalyses: Dispatch<SetStateAction<string[]>>;
}) => {
if (availableAnalyses?.length === 0) {
return null;
}
return (
<div className="w-1/3">
<h5>
<Trans i18nKey="doctor:recommendedAnalyses" />
</h5>
<div className="mt-4 flex flex-wrap gap-2">
{availableAnalyses?.map((analysis, index) => {
return (
<Button
size="sm"
key={`${index}-analysis-feedback-list`}
variant={
recommendedAnalyses?.includes(analysis) ? 'default' : 'outline'
}
type="button"
onClick={() =>
setRecommendedAnalyses((prev: string[]) =>
prev.includes(analysis)
? prev.filter((x) => x !== analysis)
: [...prev, analysis],
)
}
>
{analysis}
</Button>
);
})}
</div>
</div>
);
};
export default DoctorRecommendedAnalyses;

View File

@@ -34,7 +34,7 @@ export function DoctorSidebar({
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
<SidebarHeader className={'m-2'}> <SidebarHeader className={'m-2'}>
<AppLogo <AppLogo
href={pathsConfig.app.doctor} href={pathsConfig.app.home}
className="max-w-full" className="max-w-full"
compact={!open} compact={!open}
/> />

View File

@@ -0,0 +1,90 @@
'use server';
import React from 'react';
import { AnalysisResponses } from '@/app/home/(user)/_components/ai/types';
import { OrderAnalysisCard } from '@/app/home/(user)/_components/order-analyses-cards';
import { loadLifeStyle } from '@/app/home/(user)/_lib/server/load-life-style';
import { loadRecommendations } from '@/app/home/(user)/_lib/server/load-recommendations';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import { AnalysisResultDetails } from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema';
import { withI18n } from '~/lib/i18n/with-i18n';
import {
loadDoctorFeedback,
prepareFeedback,
} from '../_lib/server/load-doctor-feedback';
import AnalysisView from './analysis-view';
async function NewAnalysisRecommendationsLoader({
analysisResultDetails,
account,
analysisResponses,
currentAIResponseTimestamp,
analyses,
patient,
}: {
currentAIResponseTimestamp: string;
account: AccountWithParams | null;
analysisResponses: AnalysisResponses;
analysisResultDetails: AnalysisResultDetails;
analyses: OrderAnalysisCard[];
patient: AccountWithParams | null;
}) {
if (!analysisResultDetails.order.isPackage) {
return (
<AnalysisView
patient={analysisResultDetails.patient}
order={analysisResultDetails.order}
analyses={analysisResultDetails.analysisResponse}
feedback={analysisResultDetails.doctorFeedback}
/>
);
}
const [lifeStyle, recommendations, aiFeedback] = await Promise.all([
loadLifeStyle({
account: patient,
analysisResponses,
isDoctorView: true,
aiResponseTimestamp: currentAIResponseTimestamp,
}),
loadRecommendations({
account: patient,
analysisResponses,
aiResponseTimestamp: currentAIResponseTimestamp,
isDoctorView: true,
analyses,
}),
loadDoctorFeedback(
analysisResultDetails.patient,
analysisResultDetails.analysisResponse,
currentAIResponseTimestamp,
),
]);
const feedback = prepareFeedback({
aiResponse: aiFeedback,
recommendations,
lifeStyleSummary: lifeStyle.response.summary,
patientName: analysisResultDetails.patient.firstName,
doctorName: `${account?.name} ${account?.last_name}`,
aiResponseTimestamp: currentAIResponseTimestamp,
});
return (
<AnalysisView
patient={analysisResultDetails.patient}
order={analysisResultDetails.order}
analyses={analysisResultDetails.analysisResponse}
feedback={analysisResultDetails.doctorFeedback}
aiDoctorFeedback={feedback}
availableAnalyses={analyses.map((analysis) => analysis.title)}
recommendations={recommendations}
timestamp={currentAIResponseTimestamp}
/>
);
}
export default withI18n(NewAnalysisRecommendationsLoader);

View File

@@ -0,0 +1,55 @@
'use server';
import React, { Suspense } from 'react';
import { loadAnalyses } from '@/app/home/(user)/_lib/server/load-analyses';
import {
loadCurrentUserAccount,
loadUserAccount,
} from '@/app/home/(user)/_lib/server/load-user-account';
import { AnalysisResultDetails } from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema';
import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { withI18n } from '~/lib/i18n/with-i18n';
import { getLatestResponseTime } from '~/lib/utils';
import AnalysisFallback from './analysis-fallback';
import NewAnalysisRecommendationsLoader from './new-analysis-recommendations-loader';
async function PrepareAIParameters({
analysisResultDetails,
}: {
analysisResultDetails: AnalysisResultDetails;
}) {
const { analyses } = await loadAnalyses();
const { account: doctorAccount } = await loadCurrentUserAccount();
const patientAccount = await loadUserAccount(
analysisResultDetails.patient.userId,
);
const client = getSupabaseServerClient();
const userAnalysesApi = createUserAnalysesApi(client);
const analysisResponses = await userAnalysesApi.getAllUserAnalysisResponses(
patientAccount.id,
);
const currentAIResponseTimestamp = getLatestResponseTime(analysisResponses);
return (
<Suspense
fallback={
<AnalysisFallback progress={66} progressTextKey="doctor:loadFeedback" />
}
>
<NewAnalysisRecommendationsLoader
account={doctorAccount}
currentAIResponseTimestamp={currentAIResponseTimestamp}
analysisResponses={analysisResponses}
analysisResultDetails={analysisResultDetails}
analyses={analyses}
patient={patientAccount}
/>
</Suspense>
);
}
export default withI18n(PrepareAIParameters);

View File

@@ -5,6 +5,7 @@ import { useTransition } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { capitalize } from 'lodash';
import { Eye } from 'lucide-react'; import { Eye } from 'lucide-react';
import { getResultSetName } from '@kit/doctor/lib/helpers'; import { getResultSetName } from '@kit/doctor/lib/helpers';
@@ -26,7 +27,6 @@ import {
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import DoctorJobSelect from './doctor-job-select'; import DoctorJobSelect from './doctor-job-select';
import { capitalize } from 'lodash';
export default function ResultsTable({ export default function ResultsTable({
results = [], results = [],
@@ -189,7 +189,9 @@ export default function ResultsTable({
</TableCell> </TableCell>
<TableCell> <TableCell>
{capitalize( {capitalize(
languageNames.of(result?.patient?.preferred_locale ?? 'et'), languageNames.of(
result?.patient?.preferred_locale ?? 'et',
),
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -0,0 +1,98 @@
import { cache } from 'react';
import { PROMPT_NAME } from '@/app/home/(user)/_components/ai/types';
import { generateDoctorFeedback } from '@/app/home/(user)/_lib/server/ai-actions';
import {
AnalysisResponse,
Patient,
} from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema';
import { getLogger } from '@/packages/shared/src/logger';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
export const loadDoctorFeedback = cache(doctorFeedbackLoader);
const PLACEHOLDER = {
ANALYSES: 'SOOVITATUD_ANALYYSID_PLACEHOLDER',
LIFE_STYLE_SUMMARY: 'ELUSTIILI_KOKKUVOTTE_PLACEHOLDER',
PATIENT_NAME: 'PATSIENDI_NIMI_PLACEHOLDER',
DOCTOR_NAME: 'ARSTI_NIMI_PLACEHOLDER',
ANALYSES_DATE: 'ANALYYSI_KUUPAEV_PLACEHOLDER',
};
export const prepareFeedback = ({
aiResponse,
recommendations,
lifeStyleSummary,
patientName,
doctorName,
aiResponseTimestamp,
}: {
aiResponse: string;
recommendations?: string[];
lifeStyleSummary: string | null;
patientName: string;
doctorName: string;
aiResponseTimestamp: string;
}) => {
const recommendationsList = recommendations
? recommendations.map((analysis) => `${analysis}`).join('\n')
: '';
const feedback = aiResponse
.replace(PLACEHOLDER.ANALYSES, recommendationsList)
.replace(PLACEHOLDER.LIFE_STYLE_SUMMARY, lifeStyleSummary ?? '')
.replace(PLACEHOLDER.PATIENT_NAME, patientName)
.replace(PLACEHOLDER.DOCTOR_NAME, doctorName)
.replace(
PLACEHOLDER.ANALYSES_DATE,
new Date(aiResponseTimestamp).toLocaleString(),
);
return feedback;
};
async function doctorFeedbackLoader(
patient: Patient | null,
analysisResponses: AnalysisResponse[],
aiResponseTimestamp: string,
): Promise<string> {
const logger = await getLogger();
if (!patient?.personalCode) {
return '';
}
const supabaseClient = getSupabaseServerClient();
logger.info(
{
aiResponseTimestamp,
patientId: patient.userId,
},
'Attempting to receive existing doctor feedback',
);
const { data, error } = await supabaseClient
.schema('medreport')
.from('ai_responses')
.select('*')
.eq('account_id', patient.userId)
.eq('prompt_name', PROMPT_NAME.FEEDBACK)
.eq('latest_data_change', aiResponseTimestamp)
.limit(1)
.maybeSingle();
logger.info({ data: !!data }, 'Existing doctor feedback');
if (error) {
console.error('Error fetching AI response from DB: ', error);
return '';
}
if (data?.response) {
return data.response as string;
} else {
return await generateDoctorFeedback({
patient,
analysisResponses,
aiResponseTimestamp,
});
}
}

View File

@@ -1,15 +1,17 @@
import { cache } from 'react'; import { Suspense, cache } from 'react';
import { getAnalysisResultsForDoctor } from '@kit/doctor/services/doctor-analysis.service'; import { getAnalysisResultsForDoctor } from '@kit/doctor/services/doctor-analysis.service';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { withI18n } from '~/lib/i18n/with-i18n';
import { import {
DoctorPageViewAction, DoctorPageViewAction,
createDoctorPageViewLog, createDoctorPageViewLog,
} from '~/lib/services/audit/doctorPageView.service'; } from '~/lib/services/audit/doctorPageView.service';
import AnalysisView from '../../_components/analysis-view'; import AnalysisFallback from '../../_components/analysis-fallback';
import { DoctorGuard } from '../../_components/doctor-guard'; import { DoctorGuard } from '../../_components/doctor-guard';
import PrepareAiParameters from '../../_components/prepare-ai-parameters';
async function AnalysisPage({ async function AnalysisPage({
params, params,
@@ -36,19 +38,21 @@ async function AnalysisPage({
return ( return (
<> <>
<PageHeader /> <PageHeader />
<PageBody> <PageBody className="px-12">
<AnalysisView <Suspense
patient={analysisResultDetails.patient} fallback={
order={analysisResultDetails.order} <AnalysisFallback
analyses={analysisResultDetails.analysisResponse} progress={33}
feedback={analysisResultDetails.doctorFeedback} progressTextKey="doctor:loadParameters"
/> />
}
>
<PrepareAiParameters analysisResultDetails={analysisResultDetails} />
</Suspense>
</PageBody> </PageBody>
</> </>
); );
} }
export default DoctorGuard(AnalysisPage); export default DoctorGuard(withI18n(AnalysisPage));
const loadResult = cache(getAnalysisResultsForDoctor); const loadResult = cache(getAnalysisResultsForDoctor);

View File

@@ -17,7 +17,7 @@ async function CompletedJobsPage() {
return ( return (
<> <>
<PageHeader /> <PageHeader />
<PageBody> <PageBody className="px-12">
<ResultsTableWrapper <ResultsTableWrapper
titleKey="doctor:completedReviews" titleKey="doctor:completedReviews"
action={getUserDoneResponsesAction} action={getUserDoneResponsesAction}

View File

@@ -5,7 +5,6 @@ import { cookies } from 'next/headers';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
import { loadUserWorkspace } from '../home/(user)/_lib/server/load-user-workspace'; import { loadUserWorkspace } from '../home/(user)/_lib/server/load-user-workspace';
import { DoctorSidebar } from './_components/doctor-sidebar'; import { DoctorSidebar } from './_components/doctor-sidebar';
import { DoctorMobileNavigation } from './_components/mobile-navigation'; import { DoctorMobileNavigation } from './_components/mobile-navigation';

View File

@@ -17,7 +17,7 @@ async function MyReviewsPage() {
return ( return (
<> <>
<PageHeader /> <PageHeader />
<PageBody> <PageBody className="px-12">
<ResultsTableWrapper <ResultsTableWrapper
titleKey="doctor:myReviews" titleKey="doctor:myReviews"
action={getUserInProgressResponsesAction} action={getUserInProgressResponsesAction}

View File

@@ -17,7 +17,7 @@ async function OpenJobsPage() {
return ( return (
<> <>
<PageHeader /> <PageHeader />
<PageBody> <PageBody className="px-12">
<ResultsTableWrapper <ResultsTableWrapper
titleKey="doctor:openReviews" titleKey="doctor:openReviews"
action={getOpenResponsesAction} action={getOpenResponsesAction}

View File

@@ -5,7 +5,7 @@ import {
createDoctorPageViewLog, createDoctorPageViewLog,
} from '~/lib/services/audit/doctorPageView.service'; } from '~/lib/services/audit/doctorPageView.service';
import Dashboard from './_components/doctor-dashboard'; import DoctorDashboard from './_components/doctor-dashboard';
import { DoctorGuard } from './_components/doctor-guard'; import { DoctorGuard } from './_components/doctor-guard';
async function DoctorPage() { async function DoctorPage() {
@@ -16,8 +16,8 @@ async function DoctorPage() {
return ( return (
<> <>
<PageHeader /> <PageHeader />
<PageBody> <PageBody className="px-12">
<Dashboard /> <DoctorDashboard />
</PageBody> </PageBody>
</> </>
); );

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, MessageCircle } from 'lucide-react'; import { ArrowLeft, MessageCircle } from 'lucide-react';
@@ -20,6 +21,22 @@ const ErrorPage = ({
}) => { }) => {
useCaptureException(error); useCaptureException(error);
// Ignore next.js internal transient navigation errors that occur during auth state changes
const isTransientNavigationError =
error?.message?.includes('Error in input stream') ||
error?.message?.includes('AbortError') ||
(error?.name === 'ChunkLoadError');
useEffect(() => {
if (isTransientNavigationError && typeof window !== 'undefined') {
window.location.href = '/';
}
}, [isTransientNavigationError]);
if (isTransientNavigationError) {
return <div />;
}
return ( return (
<div className={'flex h-screen flex-1 flex-col'}> <div className={'flex h-screen flex-1 flex-col'}>
<SiteHeader /> <SiteHeader />

View File

@@ -1,16 +1,17 @@
'use client'; 'use client';
import { useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, MessageCircle } from 'lucide-react'; import { ArrowLeft, MessageCircle } from 'lucide-react';
import { useCaptureException } from '@kit/monitoring/hooks'; import { useCaptureException } from '@kit/monitoring/hooks';
import { RootProviders } from '@kit/shared/components/root-providers';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { SiteHeader } from '~/(marketing)/_components/site-header'; import { SiteHeader } from '~/(marketing)/_components/site-header';
import { RootProviders } from '@kit/shared/components/root-providers';
const GlobalErrorPage = ({ const GlobalErrorPage = ({
error, error,
@@ -20,6 +21,22 @@ const GlobalErrorPage = ({
reset: () => void; reset: () => void;
}) => { }) => {
useCaptureException(error); useCaptureException(error);
// Ignore next.js internal transient navigation errors that occur during auth state changes
const isTransientNavigationError =
error?.message?.includes('Error in input stream') ||
error?.message?.includes('AbortError') ||
(error?.name === 'ChunkLoadError');
useEffect(() => {
if (isTransientNavigationError && typeof window !== 'undefined') {
window.location.href = '/';
}
}, [isTransientNavigationError]);
if (isTransientNavigationError) {
return <div />;
}
return ( return (
<html> <html>

View File

@@ -1,10 +1,17 @@
import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation';
import { createNotificationsApi } from '@/packages/features/notifications/src/server/api';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip'; import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip';
import { pathsConfig } from '@kit/shared/config'; import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { formatDateAndTime } from '@kit/shared/utils';
import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account'; import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account';
import { loadUserAnalysis } from '~/home/(user)/_lib/server/load-user-analysis'; import { loadUserAnalysis } from '~/home/(user)/_lib/server/load-user-analysis';
@@ -22,14 +29,17 @@ export default async function AnalysisResultsPage({
id: string; id: string;
}>; }>;
}) { }) {
const account = await loadCurrentUserAccount(); const supabaseClient = getSupabaseServerClient();
const { id: analysisOrderId } = await params;
const notificationsApi = createNotificationsApi(supabaseClient);
const { id: analysisResponseId } = await params; const [{ account }, analysisResponse] = await Promise.all([
loadCurrentUserAccount(),
loadUserAnalysis(Number(analysisOrderId)),
]);
const analysisResponse = await loadUserAnalysis(Number(analysisResponseId)); if (!account?.id) {
return redirect('/');
if (!account?.id || !analysisResponse) {
return null;
} }
await createPageViewLog({ await createPageViewLog({
@@ -37,46 +47,74 @@ export default async function AnalysisResultsPage({
action: PageViewAction.VIEW_ANALYSIS_RESULTS, action: PageViewAction.VIEW_ANALYSIS_RESULTS,
}); });
await notificationsApi.dismissNotification(
`/home/analysis-results/${analysisOrderId}`,
'link',
);
if (!analysisResponse) {
return (
<>
<PageHeader
title={<Trans i18nKey="analysis-results:pageTitle" />}
description={<Trans i18nKey="analysis-results:descriptionEmpty" />}
/>
<PageBody className="gap-4"></PageBody>
</>
);
}
const orderedAnalysisElements = analysisResponse.orderedAnalysisElements;
const hasOrderedAnalysisElements = orderedAnalysisElements.length > 0;
const isPartialStatus =
analysisResponse.order.status === 'PARTIAL_ANALYSIS_RESPONSE';
return ( return (
<> <>
<PageHeader /> <PageHeader
<PageBody className="gap-4"> title={<Trans i18nKey="analysis-results:pageTitle" />}
<div className="mt-8 flex flex-col justify-between gap-4 sm:flex-row sm:items-center sm:gap-0"> description={
<div> hasOrderedAnalysisElements ? (
<h4> isPartialStatus ? (
<Trans i18nKey="analysis-results:pageTitle" /> <Trans i18nKey="analysis-results:descriptionPartial" />
</h4> ) : (
<p className="text-muted-foreground text-sm"> <Trans i18nKey="analysis-results:description" />
{analysisResponse?.elements && )
analysisResponse.elements?.length > 0 ? ( ) : (
<Trans i18nKey="analysis-results:description" /> <Trans i18nKey="analysis-results:descriptionEmpty" />
) : ( )
<Trans i18nKey="analysis-results:descriptionEmpty" /> }
)} >
</p> <div>
</div>
<Button asChild> <Button asChild>
<Link href={pathsConfig.app.orderAnalysisPackage}> <Link href={pathsConfig.app.orderAnalysisPackage}>
<Trans i18nKey="analysis-results:orderNewAnalysis" /> <Trans i18nKey="analysis-results:orderNewAnalysis" />
</Link> </Link>
</Button> </Button>
</div> </div>
</PageHeader>
<PageBody className="gap-4 pt-4">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<h4> <h5 className="break-all">
<Trans <Trans
i18nKey="analysis-results:orderTitle" i18nKey="analysis-results:orderTitle"
values={{ orderNumber: analysisResponse.order.medusa_order_id }} values={{ orderNumber: analysisResponse.order.medusaOrderId }}
/>
</h4>
<h5>
<Trans
i18nKey={`orders:status.${analysisResponse.order.status}`}
/>
<ButtonTooltip
content={`${analysisResponse.order.created_at ? new Date(analysisResponse?.order?.created_at).toLocaleString() : ''}`}
className="ml-6"
/> />
</h5> </h5>
<h6>
<Trans i18nKey={`orders:status.${analysisResponse.order.status}`} />
<ButtonTooltip
content={
<Trans
i18nKey="analysis-results:orderCreatedAt"
values={{
createdAt: formatDateAndTime(analysisResponse.order.createdAt)
}}
/>
}
className="ml-6"
/>
</h6>
</div> </div>
{analysisResponse?.summary?.value && ( {analysisResponse?.summary?.value && (
<div> <div>
@@ -87,13 +125,20 @@ export default async function AnalysisResultsPage({
</div> </div>
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{analysisResponse.elements ? ( {orderedAnalysisElements ? (
analysisResponse.elements.map((element, index) => ( orderedAnalysisElements.map((element) => (
<Analysis <React.Fragment key={element.analysisIdOriginal}>
key={index} <Analysis element={element} />
analysisElement={{ analysis_name_lab: element.analysis_name }} {element.results?.nestedElements?.map(
results={element} (nestedElement, nestedIndex) => (
/> <Analysis
key={`nested-${nestedElement.analysisElementOriginalId}-${nestedIndex}`}
nestedElement={nestedElement}
isNestedElement
/>
),
)}
</React.Fragment>
)) ))
) : ( ) : (
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">

View File

@@ -1,17 +1,15 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { AnalysisResultDetailsElementResults } from '@/packages/features/user-analyses/src/types/analysis-results';
import { AnalysisResultLevel } from '@/packages/features/user-analyses/src/types/analysis-results';
import { ArrowDown } from 'lucide-react'; import { ArrowDown } from 'lucide-react';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { AnalysisResultForDisplay } from './analysis';
export enum AnalysisResultLevel { type AnalysisResultLevelBarResults = Pick<
VERY_LOW = 0, AnalysisResultDetailsElementResults,
LOW = 1, 'normLower' | 'normUpper' | 'responseValue'
NORMAL = 2, >;
HIGH = 3,
VERY_HIGH = 4,
}
const Level = ({ const Level = ({
isActive = false, isActive = false,
@@ -19,17 +17,19 @@ const Level = ({
isFirst = false, isFirst = false,
isLast = false, isLast = false,
arrowLocation, arrowLocation,
normRangeText,
}: { }: {
isActive?: boolean; isActive?: boolean;
color: 'destructive' | 'success' | 'warning' | 'gray-200'; color: 'destructive' | 'success' | 'warning' | 'gray-200';
isFirst?: boolean; isFirst?: boolean;
isLast?: boolean; isLast?: boolean;
arrowLocation?: number; arrowLocation?: number;
normRangeText?: string | null;
}) => { }) => {
return ( return (
<div <div
className={cn(`bg-${color} relative h-3 flex-1`, { className={cn(`bg-${color} relative h-3 flex-1`, {
'opacity-20': !isActive, 'opacity-60': !isActive,
'rounded-l-lg': isFirst, 'rounded-l-lg': isFirst,
'rounded-r-lg': isLast, 'rounded-r-lg': isLast,
})} })}
@@ -37,96 +37,171 @@ const Level = ({
{isActive && ( {isActive && (
<div <div
className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]" className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]"
style={{ left: `${arrowLocation}%` }} {...(arrowLocation
? {
style: {
left: `${arrowLocation}%`,
...(arrowLocation > 92.5 && { left: '92.5%' }),
...(arrowLocation < 7.5 && { left: '7.5%' }),
},
}
: {})}
> >
<ArrowDown strokeWidth={2} /> <ArrowDown strokeWidth={2} />
</div> </div>
)} )}
</div>
);
};
export const AnalysisLevelBarSkeleton = () => { {color === 'success' && typeof normRangeText === 'string' && (
return ( <p
<div className="mt-4 flex h-3 w-[35%] max-w-[360px] gap-1 sm:mt-0"> className={cn(
<Level color="gray-200" /> 'text-muted-foreground absolute bottom-[-18px] left-3/8 text-xs font-bold whitespace-nowrap',
{
'opacity-60': isActive,
},
)}
>
{normRangeText}
</p>
)}
</div> </div>
); );
}; };
const AnalysisLevelBar = ({ const AnalysisLevelBar = ({
normLowerIncluded = true,
normUpperIncluded = true,
level, level,
results, results: { normLower: lower, normUpper: upper, responseValue: value },
normRangeText,
}: { }: {
normLowerIncluded?: boolean;
normUpperIncluded?: boolean;
level: AnalysisResultLevel; level: AnalysisResultLevel;
results: AnalysisResultForDisplay; results: AnalysisResultLevelBarResults;
normRangeText: string | null;
}) => { }) => {
// Calculate arrow position based on value within normal range
const { norm_lower: lower, norm_upper: upper, response_value: value } = results;
const arrowLocation = useMemo(() => { const arrowLocation = useMemo(() => {
if (value < lower!) { // If no response value, center the arrow
return 0; if (value === null || value === undefined) {
}
if (normLowerIncluded || normUpperIncluded) {
return 50; return 50;
} }
const calculated = ((value - lower!) / (upper! - lower!)) * 100; // If no normal ranges defined, center the arrow
if (lower === null && upper === null) {
if (calculated > 100) { return 50;
return 100;
} }
return calculated; // If only upper bound exists
if (lower === null && upper !== null) {
if (value <= upper) {
return Math.min(75, (value / upper) * 75); // Show in left 75% of normal range
}
return 100; // Beyond upper bound
}
// If only lower bound exists
if (upper === null && lower !== null) {
if (value >= lower) {
// Value is in normal range (above lower bound)
// Position proportionally in the normal range section
const normalizedPosition = Math.min((value - lower) / (lower * 0.5), 1); // Use 50% of lower as scale
return normalizedPosition * 100;
}
// Value is below lower bound - position in the "below normal" section
const belowPosition = Math.max(0, Math.min(1, value / lower));
return belowPosition * 100;
}
// Both bounds exist
if (lower !== null && upper !== null) {
if (value < lower) {
return 0; // Below normal range
}
if (value > upper) {
return 100; // Above normal range
}
// Within normal range
return ((value - lower) / (upper - lower)) * 100;
}
return 50; // Fallback
}, [value, upper, lower]); }, [value, upper, lower]);
const [isVeryLow, isLow, isHigh, isVeryHigh] = useMemo(() => [ // Determine level states based on normStatus
level === AnalysisResultLevel.VERY_LOW, const isNormal = level === AnalysisResultLevel.NORMAL;
level === AnalysisResultLevel.LOW, const isWarning = level === AnalysisResultLevel.WARNING;
level === AnalysisResultLevel.HIGH, const isCritical = level === AnalysisResultLevel.CRITICAL;
level === AnalysisResultLevel.VERY_HIGH, const isPending = level === null;
], [level, value, upper, lower]);
const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh; // If pending results, show gray bar
if (isPending) {
return (
<div className="w-60% mt-4 flex h-3 max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
<Level color="gray-200" isFirst isLast />
</div>
);
}
// Show appropriate levels based on available norm bounds
const hasLowerBound = lower !== null;
const hasUpperBound = upper !== null;
// Determine which section the value falls into
const isValueBelowLower = hasLowerBound && value !== null && value < lower!;
const isValueAboveUpper = hasUpperBound && value !== null && value > upper!;
const isValueInNormalRange = !isValueBelowLower && !isValueAboveUpper;
const [first, second, third] = useMemo(() => {
const [warning, normal, critical] = [
{
isActive: isWarning,
color: 'warning',
...(isWarning ? { arrowLocation } : {}),
},
{
isActive: isNormal,
color: 'success',
normRangeText,
...(isNormal ? { arrowLocation } : {}),
},
{
isActive: isCritical,
color: 'destructive',
isLast: true,
...(isCritical ? { arrowLocation } : {}),
},
] as const;
if (!hasLowerBound) {
return [{ ...normal, isFirst: true }, warning, critical] as const;
}
return [
{ ...warning, isFirst: true },
normal,
{ ...critical, isLast: true },
] as const;
}, [
isValueBelowLower,
isValueAboveUpper,
isValueInNormalRange,
arrowLocation,
normRangeText,
isNormal,
isWarning,
isCritical,
]);
return ( return (
<div className="mt-4 flex h-3 w-[35%] max-w-[360px] gap-1 sm:mt-0"> <div
{normLowerIncluded && ( className={cn(
<> 'flex h-3 gap-1',
<Level 'mt-4 sm:mt-0',
isActive={isVeryLow} 'w-[60%] sm:w-[35%]',
color="destructive" 'min-w-[50vw] sm:min-w-auto',
isFirst 'max-w-[360px]',
/>
<Level isActive={isLow} color="warning" />
</>
)}
<Level
isFirst={!normLowerIncluded}
isLast={!normUpperIncluded}
{...(hasAbnormalLevel ? { color: "warning", isActive: false } : { color: "success", isActive: true })}
arrowLocation={arrowLocation}
/>
{normUpperIncluded && (
<>
<Level
isActive={isHigh}
color="warning"
/>
<Level
isActive={isVeryHigh}
color="destructive"
isLast
/>
</>
)} )}
>
<Level {...first} />
<Level {...second} />
<Level {...third} />
</div> </div>
); );
}; };

View File

@@ -1,93 +1,142 @@
'use client'; 'use client';
import React, { ReactElement, ReactNode, useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts'; import type {
AnalysisResultDetailsElement,
AnalysisResultsDetailsElementNested,
} from '@/packages/features/user-analyses/src/types/analysis-results';
import { AnalysisResultLevel } from '@/packages/features/user-analyses/src/types/analysis-results';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { AnalysisElement } from '~/lib/services/analysis-element.service'; import AnalysisLevelBar from './analysis-level-bar';
import AnalysisLevelBar, {
AnalysisLevelBarSkeleton,
AnalysisResultLevel,
} from './analysis-level-bar';
export type AnalysisResultForDisplay = Pick<
UserAnalysisElement,
| 'norm_status'
| 'response_value'
| 'unit'
| 'norm_lower_included'
| 'norm_upper_included'
| 'norm_lower'
| 'norm_upper'
| 'response_time'
>;
export enum AnalysisStatus {
NORMAL = 0,
MEDIUM = 1,
HIGH = 2,
}
const Analysis = ({ const Analysis = ({
analysisElement, element: elementOriginal,
results, nestedElement,
startIcon, isNestedElement = false,
endIcon,
isCancelled,
}: { }: {
analysisElement: Pick<AnalysisElement, 'analysis_name_lab'>; element?: AnalysisResultDetailsElement;
results?: AnalysisResultForDisplay; nestedElement?: AnalysisResultsDetailsElementNested;
isCancelled?: boolean; isNestedElement?: boolean;
startIcon?: ReactElement | null;
endIcon?: ReactNode | null;
}) => { }) => {
const name = analysisElement.analysis_name_lab || ''; const { t } = useTranslation();
const status = results?.norm_status || AnalysisStatus.NORMAL;
const value = results?.response_value || 0;
const unit = results?.unit || '';
const normLowerIncluded = results?.norm_lower_included || false;
const normUpperIncluded = results?.norm_upper_included || false;
const normLower = results?.norm_lower || 0;
const normUpper = results?.norm_upper || 0;
const [showTooltip, setShowTooltip] = useState(false); const element = (() => {
const analysisResultLevel = useMemo(() => { if (isNestedElement) {
return nestedElement!;
}
return elementOriginal!;
})();
const results: AnalysisResultDetailsElement['results'] = useMemo(() => {
if (isNestedElement) {
const nestedElement = element as AnalysisResultsDetailsElementNested;
return {
analysisElementOriginalId: nestedElement.analysisElementOriginalId,
normLower: nestedElement.normLower,
normUpper: nestedElement.normUpper,
normStatus: nestedElement.normStatus,
responseTime: nestedElement.responseTime,
responseValue: nestedElement.responseValue,
responseValueIsNegative: nestedElement.responseValueIsNegative,
responseValueIsWithinNorm: nestedElement.responseValueIsWithinNorm,
normLowerIncluded: nestedElement.normLowerIncluded,
normUpperIncluded: nestedElement.normUpperIncluded,
unit: nestedElement.unit,
status: nestedElement.status,
nestedElements: [],
};
}
return (element as AnalysisResultDetailsElement).results;
}, [element, isNestedElement]);
const name = element.analysisName || '';
const hasIsWithinNorm = results?.responseValueIsWithinNorm !== null;
const hasIsNegative = results?.responseValueIsNegative !== null;
const value = (() => {
if (!results) { if (!results) {
return null; return null;
} }
const isUnderNorm = value < normLower; const {
if (isUnderNorm) { responseValue,
switch (status) { responseValueIsNegative,
case AnalysisStatus.MEDIUM: responseValueIsWithinNorm,
return AnalysisResultLevel.LOW; } = results;
default: if (responseValue === null || responseValue === undefined) {
return AnalysisResultLevel.VERY_LOW; if (hasIsNegative) {
if (responseValueIsNegative) {
return t('analysis-results:results.value.negative');
}
return t('analysis-results:results.value.positive');
} }
if (hasIsWithinNorm) {
if (responseValueIsWithinNorm) {
return t('analysis-results:results.value.isWithinNorm');
}
return t('analysis-results:results.value.isNotWithinNorm');
}
return null;
} }
switch (status) {
case AnalysisStatus.MEDIUM: return responseValue;
return AnalysisResultLevel.HIGH; })();
case AnalysisStatus.HIGH: const unit = results?.unit || '';
return AnalysisResultLevel.VERY_HIGH; const normLower = results?.normLower ?? null;
const normUpper = results?.normUpper ?? null;
const normStatus = results?.normStatus ?? null;
const [showTooltip, setShowTooltip] = useState(false);
const analysisResultLevel = useMemo(() => {
if (normStatus === null) {
return null;
}
switch (normStatus) {
case 1:
return AnalysisResultLevel.WARNING;
case 2:
return AnalysisResultLevel.CRITICAL;
case 0:
default: default:
return AnalysisResultLevel.NORMAL; return AnalysisResultLevel.NORMAL;
} }
}, [results, value, normLower]); }, [normStatus]);
const isCancelled = Number(results?.status) === 5;
const nestedElements = results?.nestedElements ?? null;
const hasNestedElements =
Array.isArray(nestedElements) && nestedElements.length > 0;
const normRangeText = (() => {
if (normLower === null && normUpper === null) {
return null;
}
return `${normLower ?? '...'} - ${normUpper ?? '...'}`;
})();
const hasTextualResponse = hasIsNegative || hasIsWithinNorm;
return ( return (
<div className="border-border rounded-lg border px-5"> <div
<div className="flex flex-col items-center justify-between gap-2 py-3 sm:h-[65px] sm:flex-row sm:gap-0"> className={cn('border-border rounded-lg border px-5', {
<div className="flex items-center gap-2 font-semibold"> 'ml-8': isNestedElement,
{startIcon || <div className="w-4" />} })}
>
<div className="flex flex-col items-center justify-between gap-2 pt-3 pb-6 sm:h-[65px] sm:flex-row sm:gap-0 sm:py-3">
<div
className={cn('flex items-center gap-2 font-semibold', {
'font-bold': isNestedElement,
})}
>
{name} {name}
{results?.response_time && ( {results?.responseTime && (
<div <div
className="group/tooltip relative" className="group/tooltip relative"
onClick={(e) => { onClick={(e) => {
@@ -105,42 +154,55 @@ const Analysis = ({
> >
<Trans i18nKey="analysis-results:analysisDate" /> <Trans i18nKey="analysis-results:analysisDate" />
{': '} {': '}
{format(new Date(results.response_time), 'dd.MM.yyyy HH:mm')} {format(new Date(results.responseTime), 'dd.MM.yyyy HH:mm')}
</div> </div>
</div> </div>
)} )}
</div> </div>
{results ? (
{isCancelled && (
<div className="text-sm font-semibold text-red-600">
<Trans i18nKey="analysis-results:cancelled" />
</div>
)}
{isCancelled || !results || hasNestedElements ? null : (
<> <>
<div className="flex items-center gap-3 sm:ml-auto"> <div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">{value}</div> <div
className={cn('font-semibold', {
'text-yellow-600':
hasTextualResponse &&
analysisResultLevel === AnalysisResultLevel.WARNING,
'text-red-600':
hasTextualResponse &&
analysisResultLevel === AnalysisResultLevel.CRITICAL,
'text-green-600':
hasTextualResponse &&
analysisResultLevel === AnalysisResultLevel.NORMAL,
})}
>
{value}
</div>
<div className="text-muted-foreground text-sm">{unit}</div> <div className="text-muted-foreground text-sm">{unit}</div>
</div> </div>
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0"> {!hasTextualResponse && (
{normLower} - {normUpper} <>
<div> <div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
<Trans i18nKey="analysis-results:results.range.normal" /> {normRangeText}
</div> <div>
</div> <Trans i18nKey="analysis-results:results.range.normal" />
<AnalysisLevelBar </div>
results={results} </div>
normLowerIncluded={normLowerIncluded} <AnalysisLevelBar
normUpperIncluded={normUpperIncluded} results={results}
level={analysisResultLevel!} level={analysisResultLevel!}
/> normRangeText={normRangeText}
{endIcon || <div className="mx-2 w-4" />} />
</>
)}
</> </>
) : (isCancelled ? null : ( )}
<>
<div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">
<Trans i18nKey="analysis-results:waitingForResults" />
</div>
</div>
<div className="mx-8 w-[60px]"></div>
<AnalysisLevelBarSkeleton />
</>
))}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,108 @@
'use client';
import React, { useState } from 'react';
import Modal from '@modules/common/components/modal';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Button } from '@kit/ui/shadcn/button';
import { Trans } from '@kit/ui/trans';
import Analysis from '../_components/analysis';
import { analysisResponses } from './test-responses';
export default function AnalysisResultsPage() {
const [openBlocks, setOpenBlocks] = useState<number[]>([]);
return (
<>
<PageHeader />
<PageBody className="gap-4">
<div className="mt-8 flex flex-col justify-between gap-4 sm:flex-row sm:items-center sm:gap-0">
<div>
<h2>Analüüsi tulemused demo</h2>
</div>
</div>
<div className="flex flex-col gap-2">
{analysisResponses.map(({ id, orderedAnalysisElements }, index) => {
const isOpen = openBlocks.includes(id);
const closeModal = () =>
setOpenBlocks(openBlocks.filter((block) => block !== id));
return (
<div key={index} className="flex flex-col gap-2 py-4">
<div className="flex flex-col gap-2 pb-4">
<h3>AnalysisOrderId: {id}</h3>
<div className="flex flex-col gap-2">
<h5>OrderedAnalysisElements</h5>
<Button
onClick={() => {
if (isOpen) {
closeModal();
} else {
setOpenBlocks([...openBlocks, id]);
}
}}
className="w-fit"
color={isOpen ? 'orange' : 'grey'}
>
{isOpen ? 'Close' : 'Open'}
</Button>
{isOpen && (
<Modal isOpen={isOpen} close={closeModal} size="large">
<div className="overflow-y-auto">
<p>NormiStaatus</p>
<ul>
<li>
0 - testi väärtus jääb normaalväärtuste piirkonda
või on määramata,
</li>
<li>
1 - testi väärtus jääb hoiatava (tähelepanu
suunava) märkega piirkonda,
</li>
<li>
2 - testi väärtus on normaalväärtuste piirkonnast
väljas või kõrgendatud tähelepanu nõudvas
piirkonnas.
</li>
</ul>
<p>UuringOlek</p>
<ul>
<li>1 - Järjekorras,</li>
<li>2 - Ootel,</li>
<li>3 - Töös,</li>
<li>4 - Lõpetatud,</li>
<li>5 - Tagasi lükatud,</li>
<li>6 - Tühistatud,</li>
</ul>
<pre className="bg-muted rounded-md p-4 text-sm">
{JSON.stringify(orderedAnalysisElements, null, 2)}
</pre>
</div>
</Modal>
)}
</div>
{orderedAnalysisElements ? (
orderedAnalysisElements.map((element, index) => (
<Analysis key={index} element={element} />
))
) : (
<div className="text-muted-foreground text-sm">
<Trans i18nKey="analysis-results:noAnalysisElements" />
</div>
)}
</div>
<hr />
</div>
);
})}
</div>
</PageBody>
</>
);
}

View File

@@ -0,0 +1,906 @@
import type { AnalysisResultDetailsMapped } from '@/packages/features/user-analyses/src/types/analysis-results';
type AnalysisTestResponse = Omit<
AnalysisResultDetailsMapped,
'order' | 'orderedAnalysisElementIds' | 'summary' | 'elements'
>;
const empty1: AnalysisTestResponse = {
id: 1,
orderedAnalysisElements: [],
};
const big1: AnalysisTestResponse = {
id: 2,
orderedAnalysisElements: [
{
analysisIdOriginal: '1744-2',
isWaitingForResults: false,
analysisName: 'ALAT',
results: {
nestedElements: [],
unit: 'U/l',
normLower: null,
normUpper: 45,
normStatus: 2,
responseTime: '2024-02-29T10:42:25+00:00',
responseValue: 84,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '1744-2',
},
},
{
analysisIdOriginal: '1920-8',
isWaitingForResults: false,
analysisName: 'ASAT',
results: {
nestedElements: [],
unit: 'U/l',
normLower: 15,
normUpper: 45,
normStatus: 0,
responseTime: '2024-02-29T10:20:55+00:00',
responseValue: 45,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '1920-8',
},
},
{
analysisIdOriginal: '1988-5',
isWaitingForResults: false,
analysisName: 'CRP',
results: {
nestedElements: [],
unit: 'mg/l',
normLower: null,
normUpper: 5,
normStatus: 0,
responseTime: '2024-02-29T10:18:49+00:00',
responseValue: 0.79,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '1988-5',
},
},
{
analysisIdOriginal: '57747-8',
isWaitingForResults: false,
analysisName: 'Erütrotsüüdid',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: 5,
normStatus: 0,
responseTime: '2024-02-29T10:13:01+00:00',
responseValue: null,
responseValueIsNegative: true,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '57747-8',
},
},
{
analysisIdOriginal: '2276-4',
isWaitingForResults: false,
analysisName: 'Ferritiin',
results: {
nestedElements: [],
unit: 'µg/l',
normLower: 28,
normUpper: 370,
normStatus: 0,
responseTime: '2024-02-29T10:46:54+00:00',
responseValue: 204.1,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '2276-4',
},
},
{
analysisIdOriginal: '14771-0',
isWaitingForResults: false,
analysisName: 'Glükoos',
results: {
nestedElements: [],
unit: 'mmol/l',
normLower: 4.1,
normUpper: 6,
normStatus: 0,
responseTime: '2024-02-29T10:06:24+00:00',
responseValue: 5.4,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '14771-0',
},
},
{
analysisIdOriginal: '59156-0',
isWaitingForResults: false,
analysisName: 'Glükoos',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: 2,
normStatus: 2,
responseTime: '2024-02-29T10:13:01+00:00',
responseValue: null,
responseValueIsNegative: null,
responseValueIsWithinNorm: false,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '59156-0',
},
},
{
analysisIdOriginal: '59156-0',
isWaitingForResults: false,
analysisName: 'Glükoos',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: 2,
normStatus: 0,
responseTime: '2024-02-29T10:13:01+00:00',
responseValue: null,
responseValueIsNegative: null,
responseValueIsWithinNorm: true,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '59156-0',
},
},
{
analysisIdOriginal: '13955-0',
isWaitingForResults: false,
analysisName: 'HCV Ab',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: null,
normStatus: 0,
responseTime: '2024-02-29T13:44:48+00:00',
responseValue: null,
responseValueIsNegative: true,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '13955-0',
},
},
{
analysisIdOriginal: '14646-4',
isWaitingForResults: false,
analysisName: 'HDL kolesterool',
results: {
nestedElements: [],
unit: 'mmol/l',
normLower: 1,
normUpper: null,
normStatus: 1,
responseTime: '2024-02-29T10:20:55+00:00',
responseValue: 0.8,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '14646-4',
},
},
{
analysisIdOriginal: '2000-8',
isWaitingForResults: false,
analysisName: 'Kaltsium',
results: {
nestedElements: [],
unit: 'mmol/l',
normLower: 2.1,
normUpper: 2.55,
normStatus: 0,
responseTime: '2024-02-29T10:12:10+00:00',
responseValue: 2.49,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '2000-8',
},
},
{
analysisIdOriginal: '59158-6',
isWaitingForResults: false,
analysisName: 'Ketokehad',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: 0.5,
normStatus: 0,
responseTime: '2024-02-29T10:13:01+00:00',
responseValue: null,
responseValueIsNegative: true,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '59158-6',
},
},
{
analysisIdOriginal: '14647-2',
isWaitingForResults: false,
analysisName: 'Kolesterool',
results: {
nestedElements: [],
unit: 'mmol/l',
normLower: null,
normUpper: 5,
normStatus: 1,
responseTime: '2024-02-29T10:20:34+00:00',
responseValue: 5.7,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '14647-2',
},
},
{
analysisIdOriginal: '14682-9',
isWaitingForResults: false,
analysisName: 'Kreatiniin',
results: {
nestedElements: [],
unit: 'µmol/l',
normLower: 64,
normUpper: 111,
normStatus: 0,
responseTime: '2024-02-29T10:19:00+00:00',
responseValue: 89,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '14682-9',
},
},
{
analysisIdOriginal: '22748-8',
isWaitingForResults: false,
analysisName: 'LDL kolesterool',
results: {
nestedElements: [],
unit: 'mmol/l',
normLower: null,
normUpper: 3,
normStatus: 1,
responseTime: '2024-02-29T10:21:15+00:00',
responseValue: 4.3,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '22748-8',
},
},
{
analysisIdOriginal: '58805-3',
isWaitingForResults: false,
analysisName: 'Leukotsüüdid',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: 10,
normStatus: 0,
responseTime: '2024-02-29T10:13:01+00:00',
responseValue: null,
responseValueIsNegative: true,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '58805-3',
},
},
{
analysisIdOriginal: '2601-3',
isWaitingForResults: false,
analysisName: 'Magneesium',
results: {
nestedElements: [],
unit: 'mmol/l',
normLower: 0.66,
normUpper: 1.07,
normStatus: 0,
responseTime: '2024-02-29T10:17:26+00:00',
responseValue: 0.82,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '2601-3',
},
},
{
analysisIdOriginal: '70204-3',
isWaitingForResults: false,
analysisName: 'Mitte-HDL kolesterool',
results: {
nestedElements: [],
labComment: 'Mitte-paastu veri <3,9 mmol/L',
unit: 'mmol/l',
normLower: null,
normUpper: 3.8,
normStatus: 1,
responseTime: '2024-02-29T10:20:55+00:00',
responseValue: 4.9,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '70204-3',
},
},
{
analysisIdOriginal: '14798-3',
isWaitingForResults: false,
analysisName: 'Raud',
results: {
nestedElements: [],
unit: 'µmol/l',
normLower: 11.6,
normUpper: 31.3,
normStatus: 0,
responseTime: '2024-02-29T10:21:16+00:00',
responseValue: 16.5,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '14798-3',
},
},
{
analysisIdOriginal: '14927-8',
isWaitingForResults: false,
analysisName: 'Triglütseriidid',
results: {
nestedElements: [],
labComment: 'Mitte-paastu veri <2,0 mmol/L',
unit: 'mmol/l',
normLower: null,
normUpper: 1.7,
normStatus: 1,
responseTime: '2024-02-29T10:21:16+00:00',
responseValue: 1.89,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '14927-8',
},
},
{
analysisIdOriginal: '3016-3',
isWaitingForResults: false,
analysisName: 'TSH',
results: {
nestedElements: [],
unit: 'mIU/l',
normLower: 0.4,
normUpper: 4,
normStatus: 0,
responseTime: '2024-02-29T10:49:02+00:00',
responseValue: 1.27,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '3016-3',
},
},
{
analysisIdOriginal: '22664-7',
isWaitingForResults: false,
analysisName: 'Uurea',
results: {
nestedElements: [],
unit: 'mmol/l',
normLower: 3.2,
normUpper: 7.4,
normStatus: 0,
responseTime: '2024-02-29T10:19:11+00:00',
responseValue: 6.4,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '22664-7',
},
},
{
analysisIdOriginal: '50561-0',
isWaitingForResults: false,
analysisName: 'Valk',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: 0.25,
normStatus: 0,
responseTime: '2024-02-29T10:13:01+00:00',
responseValue: null,
responseValueIsNegative: true,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '50561-0',
},
},
{
analysisIdOriginal: '60493-4',
isWaitingForResults: false,
analysisName: 'Vitamiin D (25-OH)',
results: {
nestedElements: [],
labComment: 'Väärtus >75 nmol/l on D-vitamiini tervislik tase',
unit: 'nmol/l',
normLower: 75,
normUpper: null,
normStatus: 0,
responseTime: '2024-02-29T10:49:22+00:00',
responseValue: 105.5,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '60493-4',
},
},
{
analysisIdOriginal: '60025-4',
isWaitingForResults: false,
analysisName: 'Urobilinogeen',
results: {
nestedElements: [],
unit: null,
normLower: null,
normUpper: 17,
normStatus: 0,
responseTime: '2024-02-29T10:13:01+00:00',
responseValue: null,
responseValueIsNegative: null,
responseValueIsWithinNorm: true,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '60025-4',
},
},
],
};
const big2: AnalysisTestResponse = {
id: 3,
orderedAnalysisElements: [
{
analysisIdOriginal: '1988-5',
isWaitingForResults: false,
analysisName: 'CRP',
results: {
nestedElements: [],
unit: 'mg/L',
normLower: null,
normUpper: 5,
normStatus: 0,
responseTime: '2025-09-12T14:02:04+00:00',
responseValue: 1,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
normLowerIncluded: false,
normUpperIncluded: false,
status: 4,
analysisElementOriginalId: '1988-5',
},
},
{
analysisIdOriginal: '57021-8',
isWaitingForResults: false,
analysisName: 'Hemogramm',
results: {
nestedElements: [
{
status: 4,
unit: 'g/L',
normLower: 134,
normUpper: 170,
normStatus: 0,
responseTime: '2025-09-12 14:02:03',
responseValue: 150,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '718-7',
},
{
status: 4,
unit: '%',
normLower: 40,
normUpper: 49,
normStatus: 0,
responseTime: '2025-09-12 14:02:03',
responseValue: 45,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '4544-3',
},
{
status: 4,
unit: 'E9/L',
normLower: 4.1,
normUpper: 9.7,
normStatus: 0,
responseTime: '2025-09-12 14:02:03',
responseValue: 5,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '6690-2',
},
{
status: 4,
unit: 'E12/L',
normLower: 4.5,
normUpper: 5.7,
normStatus: 0,
responseTime: '2025-09-12 14:02:03',
responseValue: 5,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '789-8',
},
{
status: 4,
unit: 'fL',
normLower: 82,
normUpper: 95,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 85,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '787-2',
},
{
status: 4,
unit: 'pg',
normLower: 28,
normUpper: 33,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 30,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '785-6',
},
{
status: 4,
unit: 'g/L',
normLower: 322,
normUpper: 356,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 355,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '786-4',
},
{
status: 4,
unit: '%',
normLower: 12,
normUpper: 15,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 15,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '788-0',
},
{
status: 4,
unit: 'E9/L',
normLower: 157,
normUpper: 372,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 255,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '777-3',
},
{
status: 4,
unit: '%',
normLower: 0.18,
normUpper: 0.38,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 0.2,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '51637-7',
},
{
status: 4,
unit: 'fL',
normLower: 9.2,
normUpper: 12.3,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 10,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '32623-1',
},
{
status: 4,
unit: 'fL',
normLower: 10.1,
normUpper: 16.2,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 15,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '32207-3',
},
{
status: 4,
unit: 'E9/L',
normLower: 0.01,
normUpper: 0.08,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 0.05,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '704-7',
},
{
status: 4,
unit: 'E9/L',
normLower: 0.02,
normUpper: 0.4,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 0.05,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '711-2',
},
{
status: 4,
unit: 'E9/L',
normLower: 1.9,
normUpper: 6.7,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 5,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '751-8',
},
{
status: 4,
unit: 'E9/L',
normLower: 0.24,
normUpper: 0.8,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 0.5,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '742-7',
},
{
status: 4,
unit: 'E9/L',
normLower: 1.3,
normUpper: 3.1,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 1.5,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '731-0',
},
{
status: 4,
unit: 'E9/L',
normLower: 0,
normUpper: 0.03,
normStatus: 0,
responseTime: '2025-09-12 14:02:03',
responseValue: 0,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '51584-1',
},
{
status: 4,
unit: '%',
normLower: 0,
normUpper: 0.5,
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 0,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '38518-7',
},
{
status: 4,
unit: 'E9/L',
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 0,
normUpper: null,
normLower: null,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '771-6',
},
{
status: 4,
unit: '/100WBC',
normStatus: 0,
responseTime: '2025-09-12 14:02:04',
responseValue: 0,
normUpper: null,
normLower: null,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
analysisElementOriginalId: '58413-6',
},
],
unit: null,
normLower: null,
normUpper: null,
normStatus: null,
responseTime: null,
responseValue: null,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: false,
responseValueIsWithinNorm: false,
status: 4,
analysisElementOriginalId: '57021-8',
},
},
{
analysisIdOriginal: '43583-4',
isWaitingForResults: false,
analysisName: 'Lipoproteiin a',
results: {
nestedElements: [],
labComment: 'Kliendi soovil analüüs tühistatud.',
unit: null,
normLower: null,
normUpper: null,
normStatus: null,
responseTime: null,
responseValue: null,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: false,
responseValueIsWithinNorm: false,
status: 5,
analysisElementOriginalId: '43583-4',
},
},
{
analysisIdOriginal: '60493-4',
isWaitingForResults: false,
analysisName: 'Vitamiin D (25-OH)',
results: {
nestedElements: [],
labComment:
'Väärtus vahemikus 30-49.9 nmol/L on D-vitamiini ebapiisav tase.',
unit: 'nmol/L',
normLower: 75,
normUpper: null,
normStatus: 1,
responseTime: '2025-09-12T14:02:04+00:00',
responseValue: 30,
normLowerIncluded: false,
normUpperIncluded: false,
responseValueIsNegative: null,
responseValueIsWithinNorm: null,
status: 4,
analysisElementOriginalId: '60493-4',
},
},
],
};
export const analysisResponses: AnalysisTestResponse[] = [empty1, big1, big2];

View File

@@ -1,12 +1,20 @@
import { redirect } from 'next/navigation';
import { HomeLayoutPageHeader } from '@/app/home/(user)/_components/home-page-header'; import { HomeLayoutPageHeader } from '@/app/home/(user)/_components/home-page-header';
import { loadCategory } from '@/app/home/(user)/_lib/server/load-category'; import { loadCategory } from '@/app/home/(user)/_lib/server/load-category';
import { pathsConfig } from '@kit/shared/config';
import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs'; import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs';
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import BookingContainer from '~/home/(user)/_components/booking/booking-container';
import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n'; import { withI18n } from '~/lib/i18n/with-i18n';
import {
PageViewAction,
createPageViewLog,
} from '~/lib/services/audit/pageView.service';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const i18n = await createI18nServerInstance(); const i18n = await createI18nServerInstance();
@@ -17,9 +25,30 @@ export const generateMetadata = async () => {
}; };
}; };
async function BookingHandlePage({ params }: { params: { handle: string } }) { async function BookingHandlePage({
const handle = await params.handle; params,
}: {
params: Promise<{ handle: string }>;
}) {
const { handle } = await params;
const { category } = await loadCategory({ handle }); const { category } = await loadCategory({ handle });
const { account } = await loadCurrentUserAccount();
if (!category) {
return <div>Category not found</div>;
}
if (!account) {
return redirect(pathsConfig.auth.signIn);
}
await createPageViewLog({
accountId: account.id,
action: PageViewAction.VIEW_TTO_SERVICE_BOOKING,
extraData: {
handle,
},
});
return ( return (
<> <>
@@ -30,10 +59,10 @@ async function BookingHandlePage({ params }: { params: { handle: string } }) {
/> />
<HomeLayoutPageHeader <HomeLayoutPageHeader
title={<Trans i18nKey={'booking:title'} />} title={<Trans i18nKey={'booking:title'} />}
description={<Trans i18nKey={'booking:description'} />} description=""
/> />
<PageBody></PageBody> <BookingContainer category={category} />
</> </>
); );
} }

View File

@@ -28,9 +28,13 @@ function BookingPage() {
return ( return (
<> <>
<AppBreadcrumbs /> <AppBreadcrumbs />
<h3 className="mt-8"> <HomeLayoutPageHeader
title={<Trans i18nKey={'booking:title'} />}
description={<Trans i18nKey={'booking:description'} />}
/>
<h4 className="mt-8">
<Trans i18nKey="booking:noCategories" /> <Trans i18nKey="booking:noCategories" />
</h3> </h4>
</> </>
); );
} }

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