161 Commits

Author SHA1 Message Date
b211cd538a Fix warnings "You need to pass in an i18next instance using i18nextReactModule" 2025-11-13 13:23:30 +02:00
d643788919 MED-244: Hide CANCELLED jobs from user "in progress" jobs table 2025-11-13 13:12:03 +02:00
96fbb71c87 MED-244: Console.log the actual error that happened 2025-11-13 13:11:04 +02:00
bf5546c48b MED-244: Medipost can send more responses than ordered (hemogramm with sub-analyses) 2025-11-13 13:10:30 +02:00
44ca92ac91 MED-244: Fix loading doctor in progress jobs list 2025-11-13 13:10:00 +02:00
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
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
Danel Kungla
cc8c4093ff updated openai Prompt for live 2025-09-25 16:02:09 +03:00
d37a99d7cd Merge pull request #107 from MR-medreport/develop
develop -> main
2025-09-24 17:02:45 +03:00
d941d82373 rerun pipeline for updated aws parameters 2025-09-24 15:27:59 +03:00
857044793d Merge pull request #103 from MR-medreport/develop
develop -> main
2025-09-22 15:56:20 +03:00
ccbaba291e Merge pull request #99 from MR-medreport/develop
develop -> main
2025-09-19 11:38:30 +03:00
danelkungla
03ec6eb670 Merge pull request #95 from MR-medreport/develop
add user consent url
2025-09-12 18:37:19 +03:00
32ce3807e8 Merge pull request #94 from MR-medreport/develop
develop -> main
2025-09-12 11:26:24 +00:00
90aa6f6fd6 Merge pull request #90 from MR-medreport/develop
develop -> main
2025-09-10 07:13:24 +00:00
be1ba7ef16 Merge pull request #88 from MR-medreport/develop
develop -> main
2025-09-10 04:31:11 +00:00
d3d937dbb2 Merge pull request #85 from MR-medreport/develop
develop -> main
2025-09-09 12:50:30 +00:00
7baa1d43a2 Merge pull request #81 from MR-medreport/develop
develop -> main; keycloak, fixes etc
2025-09-09 07:10:01 +00:00
7a479787c8 Merge pull request #77 from MR-medreport/develop
develop -> main
2025-09-06 19:59:39 +00:00
247 changed files with 9899 additions and 4467 deletions

View File

@@ -10,6 +10,10 @@ NEXT_PUBLIC_AUTH_PASSWORD=true
## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION
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
# CONTACT FORM
@@ -34,6 +38,7 @@ 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

View File

@@ -10,6 +10,7 @@ MEDIPOST_PASSWORD=your-medipost-password
MEDIPOST_RECIPIENT=your-medipost-recipient
CONNECTED_ONLINE_URL=your-connected-online-url
CONNECTED_ONLINE_CONFIRMED_URL=your-connected-confirmed-url
EMAIL_SENDER=
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)
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
MEDUSA_SECRET_API_KEY=
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo

View File

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

View File

@@ -98,13 +98,13 @@ To access admin pages follow these steps:
- Register new user
- Go to Profile and add Multi-Factor Authentication
- 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
## Company User
- 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

View File

@@ -19,6 +19,8 @@ import { Label } from '@kit/ui/label';
import { Spinner } from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
import { sendCompanyOfferEmail } from '../_lib/server/company-offer-actions';
const CompanyOfferForm = () => {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
@@ -34,6 +36,16 @@ const CompanyOfferForm = () => {
const onSubmit = async (data: CompanySubmitData) => {
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();
Object.entries(data).forEach(([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

@@ -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

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

View File

@@ -41,7 +41,7 @@ export default async function syncAnalysisGroups() {
try {
console.info('Getting latest public message id');
const lastCheckedDate = await getLastCheckedDate();
// const lastCheckedDate = await getLastCheckedDate(); never used?
const latestMessage = await getLatestPublicMessageListItem();
if (!latestMessage) {

View File

@@ -1,4 +1,4 @@
import { readPrivateMessageResponse } from '~/lib/services/medipost/medipostPrivateMessage.service';
import MedipostPrivateMessageSync from '~/lib/services/medipost/medipostPrivateMessageSync.service';
type ProcessedMessage = {
messageId: string;
@@ -16,22 +16,22 @@ type GroupedResults = {
export default async function syncAnalysisResults() {
console.info('Syncing analysis results');
const sync = new MedipostPrivateMessageSync();
const processedMessages: ProcessedMessage[] = [];
const excludedMessageIds: string[] = [];
while (true) {
const result = await readPrivateMessageResponse({ excludedMessageIds });
if (result.messageId) {
processedMessages.push(result as ProcessedMessage);
}
const result = await sync.handleNextPrivateMessage({ excludedMessageIds });
if (!result.messageId) {
const { messageId } = result;
if (!messageId) {
console.info('No more messages to process');
break;
}
if (!excludedMessageIds.includes(result.messageId)) {
excludedMessageIds.push(result.messageId);
processedMessages.push(result as ProcessedMessage);
if (!excludedMessageIds.includes(messageId)) {
excludedMessageIds.push(messageId);
} else {
break;
}

View File

@@ -81,21 +81,19 @@ export default async function syncConnectedOnline() {
});
}
let clinics;
let services;
let serviceProviders;
let jobTitleTranslations;
// Filter out "Dentas Demo OÜ" in prod or only sync "Dentas Demo OÜ" in any other environment
const isDemoClinic = (clinicId: number) =>
isProd ? clinicId !== 2 : clinicId === 2;
clinics = responseData.Data.T_Lic.filter(({ ID }) => isDemoClinic(ID));
services = responseData.Data.T_Service.filter(({ ClinicID }) =>
const clinics = responseData.Data.T_Lic.filter(({ ID }) =>
isDemoClinic(ID),
);
const services = responseData.Data.T_Service.filter(({ ClinicID }) =>
isDemoClinic(ClinicID),
);
serviceProviders = responseData.Data.T_Doctor.filter(({ ClinicID }) =>
const serviceProviders = responseData.Data.T_Doctor.filter(({ ClinicID }) =>
isDemoClinic(ClinicID),
);
jobTitleTranslations = createTranslationMap(
const jobTitleTranslations = createTranslationMap(
responseData.Data.P_JobTitleTranslations.filter(({ ClinicID }) =>
isDemoClinic(ClinicID),
),

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getLogger } from '@/packages/shared/src/logger';
import { retrieveOrder } from '@lib/data/orders';
import { getMedipostDispatchTries } from '~/lib/services/audit.service';
@@ -10,13 +11,17 @@ import loadEnv from '../handler/load-env';
import validateApiKey from '../handler/validate-api-key';
export const POST = async (request: NextRequest) => {
const logger = await getLogger();
const ctx = {
api: '/job/medipost-retry-dispatch',
};
loadEnv();
const { medusaOrderId } = await request.json();
try {
validateApiKey(request);
} catch (e) {
} catch {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
@@ -36,15 +41,15 @@ export const POST = async (request: NextRequest) => {
medusaOrder,
});
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
console.info('Successfully sent order to medipost');
logger.info(ctx, 'Successfully sent order to medipost');
return NextResponse.json(
{
message: 'Successfully sent order to medipost',
},
{ status: 200 },
);
} catch (e) {
console.error('Error sending order to medipost', e);
} catch (error) {
logger.error({ ...ctx, error }, 'Error sending order to medipost');
return NextResponse.json(
{
message: 'Failed to send order to medipost',

View File

@@ -14,18 +14,20 @@ export const POST = async (request: NextRequest) => {
try {
validateApiKey(request);
} catch (e) {
} catch {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}
try {
await sendOpenJobsEmails();
const doctors = await sendOpenJobsEmails();
const doctorIds = doctors?.join(', ') ?? '-';
console.info(
'Successfully sent out open job notification emails to doctors.',
`Successfully sent out open job notification emails to doctorIds: ${doctorIds}`,
);
await createNotificationLog({
action: NotificationAction.DOCTOR_NEW_JOBS,
status: 'SUCCESS',
comment: `doctors that received email: ${doctorIds}`,
});
return NextResponse.json(
{
@@ -34,7 +36,7 @@ export const POST = async (request: NextRequest) => {
},
{ status: 200 },
);
} catch (e: any) {
} catch (e) {
console.error(
'Error sending out open job notification emails to doctors.',
e,
@@ -42,7 +44,7 @@ export const POST = async (request: NextRequest) => {
await createNotificationLog({
action: NotificationAction.DOCTOR_NEW_JOBS,
status: 'FAIL',
comment: e?.message,
comment: e instanceof Error ? e.message : 'Unknown error',
});
return NextResponse.json(
{

View File

@@ -9,7 +9,7 @@ export const POST = async (request: NextRequest) => {
try {
validateApiKey(request);
} catch (e) {
} catch {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}

View File

@@ -9,7 +9,7 @@ export const POST = async (request: NextRequest) => {
try {
validateApiKey(request);
} catch (e) {
} catch {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}

View File

@@ -9,7 +9,7 @@ export const POST = async (request: NextRequest) => {
try {
validateApiKey(request);
} catch (e) {
} catch {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}

View File

@@ -9,7 +9,7 @@ export const POST = async (request: NextRequest) => {
try {
validateApiKey(request);
} catch (e) {
} catch {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}

View File

@@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
try {
validateApiKey(request);
} catch (e) {
} catch {
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
}

View File

@@ -54,6 +54,7 @@ export async function POST(request: Request) {
action: 'send_fake_analysis_results_to_medipost',
xml: messageXml,
medusaOrderId,
medipostPrivateMessageId: `fake-response-${Date.now()}`,
});
await sendPrivateMessageTestResponse({ messageXml });
} catch (error) {

View File

@@ -47,6 +47,11 @@ export async function GET(request: NextRequest) {
const service = createAuthCallbackService(getSupabaseServerClient());
const oauthResult = await service.exchangeCodeForSession(authCode);
if (oauthResult.requiresMultiFactorAuthentication) {
redirect(pathsConfig.auth.verifyMfa);
}
if (!('isSuccess' in oauthResult)) {
return redirectOnError(oauthResult.searchParams);
}

View File

@@ -25,7 +25,7 @@ const MembershipConfirmationNotification: React.FC<{
descriptionKey="account:membershipConfirmation:successDescription"
buttonProps={{
buttonTitleKey: 'account:membershipConfirmation:successButton',
href: pathsConfig.app.home,
href: pathsConfig.app.selectPackage,
}}
/>
);

View File

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

View File

@@ -181,80 +181,74 @@ export function UpdateAccountForm({
)}
/>
{!isEmailUser && (
<>
<>
<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
name="city"
name="weight"
render={({ field }) => (
<FormItem>
<FormItem className="flex-1 basis-0">
<FormLabel>
<Trans i18nKey={'common:formField:city'} />
<Trans i18nKey={'common:formField:weight'} />
</FormLabel>
<FormControl>
<Input {...field} />
<Input
type="number"
placeholder="kg"
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value === '' ? null : Number(e.target.value),
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-between gap-4">
<FormField
name="weight"
render={({ field }) => (
<FormItem className="flex-1 basis-0">
<FormLabel>
<Trans i18nKey={'common:formField:weight'} />
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="kg"
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value === ''
? null
: Number(e.target.value),
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<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
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
name="userConsent"

View File

@@ -4,7 +4,6 @@ import { updateCustomer } from '@lib/data/customer';
import { AccountSubmitData, createAuthApi } from '@kit/auth/api';
import { enhanceAction } from '@kit/next/actions';
import { pathsConfig } from '@kit/shared/config';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { UpdateAccountSchemaServer } from '../schemas/update-account.schema';
@@ -16,7 +15,6 @@ export const onUpdateAccount = enhanceAction(
try {
await api.updateAccount(params);
console.log('SUCCESS', pathsConfig.auth.updateAccountSuccess);
} catch (err: unknown) {
if (err instanceof Error) {
console.warn('On update account error: ' + err.message);

View File

@@ -44,12 +44,7 @@ async function VerifyPage(props: Props) {
!!nextPath && nextPath.length > 0 ? nextPath : pathsConfig.app.home;
return (
<MultiFactorChallengeContainer
userId={user.id}
paths={{
redirectPath,
}}
/>
<MultiFactorChallengeContainer userId={user.id} paths={{ redirectPath }} />
);
}

View File

@@ -2,7 +2,6 @@
import React, { ReactElement, ReactNode, useMemo, useState } from 'react';
import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts';
import { format } from 'date-fns';
import { Info } from 'lucide-react';
@@ -10,29 +9,24 @@ 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 = 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,
}
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,
@@ -48,38 +42,41 @@ const AnalysisDoctor = ({
endIcon?: ReactNode | null;
}) => {
const name = analysisElement.analysis_name_lab || '';
const status = results?.norm_status || AnalysisStatus.NORMAL;
const status = results?.norm_status;
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 normLower = results?.norm_lower ?? null;
const normUpper = results?.norm_upper ?? null;
const [showTooltip, setShowTooltip] = useState(false);
const analysisResultLevel = useMemo(() => {
if (!results) {
if (!results || status === null || status === undefined) {
return null;
}
const isUnderNorm = value < normLower;
if (isUnderNorm) {
switch (status) {
case AnalysisStatus.MEDIUM:
return AnalysisResultLevel.LOW;
default:
return AnalysisResultLevel.VERY_LOW;
}
}
switch (status) {
case AnalysisStatus.MEDIUM:
return AnalysisResultLevel.HIGH;
case AnalysisStatus.HIGH:
return AnalysisResultLevel.VERY_HIGH;
case 1:
return AnalysisResultLevel.WARNING;
case 2:
return AnalysisResultLevel.CRITICAL;
case 0:
default:
return AnalysisResultLevel.NORMAL;
}
}, [results, value, normLower]);
}, [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">
@@ -110,37 +107,52 @@ const AnalysisDoctor = ({
</div>
)}
</div>
{results ? (
{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">
{normLower} - {normUpper}
{normRangeText}
<div>
<Trans i18nKey="analysis-results:results.range.normal" />
</div>
</div>
<AnalysisLevelBar
results={results}
normLowerIncluded={normLowerIncluded}
normUpperIncluded={normUpperIncluded}
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 />
</>
)}
{(() => {
// 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>
);

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

@@ -7,11 +7,9 @@ import { cn } from '@kit/ui/utils';
import { AnalysisResultForDisplay } from './analysis-doctor';
export enum AnalysisResultLevel {
VERY_LOW = 0,
LOW = 1,
NORMAL = 2,
HIGH = 3,
VERY_HIGH = 4,
NORMAL = 'NORMAL',
WARNING = 'WARNING',
CRITICAL = 'CRITICAL',
}
const Level = ({
@@ -20,17 +18,19 @@ const Level = ({
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-20': !isActive,
'opacity-60': !isActive,
'rounded-l-lg': isFirst,
'rounded-r-lg': isLast,
})}
@@ -38,11 +38,32 @@ const Level = ({
{isActive && (
<div
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} />
</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>
);
};
@@ -50,81 +71,148 @@ const Level = ({
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" />
<Level color="gray-200" isFirst isLast />
</div>
);
};
const AnalysisLevelBar = ({
normLowerIncluded = true,
normUpperIncluded = true,
level,
results,
normRangeText,
}: {
normLowerIncluded?: boolean;
normUpperIncluded?: boolean;
level: AnalysisResultLevel;
level: AnalysisResultLevel | null;
results: AnalysisResultForDisplay;
normRangeText: string | null;
}) => {
const {
norm_lower: lower,
norm_upper: upper,
response_value: value,
} = results;
const arrowLocation = useMemo(() => {
if (value < lower!) {
return 0;
}
if (normLowerIncluded || normUpperIncluded) {
// 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;
}
const calculated = ((value - lower!) / (upper! - lower!)) * 100;
if (calculated > 100) {
return 100;
// If no normal ranges defined, center the arrow
if (lower === null && upper === null) {
return 50;
}
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]);
const [isVeryLow, isLow, isHigh, isVeryHigh] = useMemo(
() => [
level === AnalysisResultLevel.VERY_LOW,
level === AnalysisResultLevel.LOW,
level === AnalysisResultLevel.HIGH,
level === AnalysisResultLevel.VERY_HIGH,
],
[level, 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;
const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh;
// 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="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
{normLowerIncluded && (
<>
<Level isActive={isVeryLow} color="destructive" isFirst />
<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 />
</>
<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>
);
};

View File

@@ -1,13 +1,10 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query';
import { capitalize } from 'lodash';
import { useForm } from 'react-hook-form';
import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions';
import {
getDOBWithAgeStringFromPersonalCode,
getResultSetName,
@@ -18,46 +15,50 @@ import {
Order,
Patient,
} from '@kit/doctor/schema/doctor-analysis-detail-view.schema';
import {
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 { 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 { bmiFromMetric } from '~/lib/utils';
import AnalysisFeedback from './analysis-feedback';
import DoctorAnalysisWrapper from './doctor-analysis-wrapper';
import DoctorJobSelect from './doctor-job-select';
import DoctorRecommendedAnalyses from './doctor-recommended-analyses';
export default function AnalysisView({
patient,
order,
analyses,
feedback,
aiDoctorFeedback,
recommendations,
availableAnalyses,
timestamp,
}: {
patient: Patient;
order: Order;
analyses: AnalysisResponse[];
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 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();
@@ -68,66 +69,11 @@ export default function AnalysisView({
);
const isCurrentDoctorJob =
!!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) {
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 (
<>
<div className="xs:flex xs:justify-between">
@@ -229,59 +175,30 @@ export default function AnalysisView({
);
})}
</div>
<h3>
<Trans i18nKey="doctor:feedback" />
</h3>
<p>{feedback?.value ?? '-'}</p>
{!isReadOnly && (
<Form {...form}>
<form className="space-y-4 lg:w-1/2">
<FormField
control={form.control}
name="feedbackValue"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea {...field} disabled={isReadOnly} />
</FormControl>
<FormMessage />
</FormItem>
)}
{order.isPackage && (
<>
<h3>
<Trans i18nKey="doctor:feedback" />
</h3>
<p>{feedback?.value ?? '-'}</p>
<div className="flex flex-col gap-4 lg:flex-row">
<AnalysisFeedback
order={order}
patient={patient}
feedback={feedback}
aiDoctorFeedback={aiDoctorFeedback}
timestamp={timestamp}
recommendations={recommendedAnalyses}
isRecommendationsEdited={isRecommendationsEdited}
/>
<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-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>
<DoctorRecommendedAnalyses
recommendedAnalyses={recommendedAnalyses}
availableAnalyses={availableAnalyses}
setRecommendedAnalyses={setRecommendedAnalyses}
/>
</div>
</>
)}
<ConfirmationModal
isOpen={isConfirmOpen}
onClose={() => setIsConfirmOpen(false)}
onConfirm={confirmComplete}
titleKey="doctor:confirmFeedbackModal.title"
descriptionKey="doctor:confirmFeedbackModal.description"
/>
</>
);
}

View File

@@ -1,5 +1,7 @@
'use client';
import React from 'react';
import { CaretDownIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons';
import { useTranslation } from 'react-i18next';
@@ -23,55 +25,25 @@ export default function DoctorAnalysisWrapper({
const { t } = useTranslation();
return (
<Collapsible className="w-full" key={analysisData.id}>
<CollapsibleTrigger
disabled={!analysisData.latestPreviousAnalysis}
asChild
>
<div className="[&[data-state=open]_.caret-icon]:rotate-180">
<AnalysisDoctor
startIcon={
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">
<>
<Collapsible className="w-full" key={analysisData.id}>
<CollapsibleTrigger
disabled={!analysisData.latestPreviousAnalysis}
asChild
>
<div className="[&[data-state=open]_.caret-icon]:rotate-180">
<AnalysisDoctor
startIcon={
analysisData.latestPreviousAnalysis && (
<CaretDownIcon className="caret-icon transition-transform duration-200" />
)
}
endIcon={
analysisData.latestPreviousAnalysis.comment && (
analysisData.comment && (
<>
<div className="xs:flex hidden">
<InfoTooltip
content={analysisData.latestPreviousAnalysis.comment}
content={analysisData.comment}
icon={
<QuestionMarkCircledIcon className="mx-2 text-blue-800" />
}
@@ -79,25 +51,103 @@ export default function DoctorAnalysisWrapper({
</div>
<p className="xs:hidden">
<strong>
<Trans i18nKey="doctor:labComment" />:{' '}
</strong>
{analysisData.latestPreviousAnalysis.comment}
<Trans i18nKey="doctor:labComment" />:
</strong>{' '}
{analysisData.comment}
</p>
</>
)
}
analysisElement={{
analysis_name_lab: t('doctor:previousResults', {
date: formatDate(
analysisData.latestPreviousAnalysis.response_time,
),
}),
analysis_name_lab: analysisData.analysis_name,
}}
results={analysisData.latestPreviousAnalysis}
results={analysisData}
/>
</div>
</CollapsibleContent>
)}
</Collapsible>
</CollapsibleTrigger>
{analysisData.latestPreviousAnalysis && (
<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

@@ -9,7 +9,7 @@ import {
import ResultsTableWrapper from './results-table-wrapper';
export default function Dashboard() {
export default function DoctorDashboard() {
return (
<>
<ResultsTableWrapper

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">
<SidebarHeader className={'m-2'}>
<AppLogo
href={pathsConfig.app.doctor}
href={pathsConfig.app.home}
className="max-w-full"
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

@@ -178,7 +178,7 @@ export default function ResultsTable({
<TableCell>
<Trans
i18nKey={
resultsReceived === elementsInOrder
resultsReceived >= elementsInOrder
? 'doctor:resultsTable.responsesReceived'
: 'doctor:resultsTable.waitingForNr'
}

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { ArrowLeft, MessageCircle } from 'lucide-react';
@@ -20,6 +21,22 @@ const ErrorPage = ({
}) => {
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 (
<div className={'flex h-screen flex-1 flex-col'}>
<SiteHeader />

View File

@@ -1,5 +1,6 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { ArrowLeft, MessageCircle } from 'lucide-react';
@@ -21,6 +22,22 @@ const GlobalErrorPage = ({
}) => {
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 (
<html>
<body>

View File

@@ -3,12 +3,17 @@ import React from 'react';
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 { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { formatDateAndTime } from '@kit/shared/utils';
import { withI18n } from '~/lib/i18n/with-i18n';
import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account';
import { loadUserAnalysis } from '~/home/(user)/_lib/server/load-user-analysis';
import {
@@ -18,14 +23,16 @@ import {
import Analysis from '../_components/analysis';
export default async function AnalysisResultsPage({
async function AnalysisResultsPage({
params,
}: {
params: Promise<{
id: string;
}>;
}) {
const supabaseClient = getSupabaseServerClient();
const { id: analysisOrderId } = await params;
const notificationsApi = createNotificationsApi(supabaseClient);
const [{ account }, analysisResponse] = await Promise.all([
loadCurrentUserAccount(),
@@ -41,6 +48,11 @@ export default async function AnalysisResultsPage({
action: PageViewAction.VIEW_ANALYSIS_RESULTS,
});
await notificationsApi.dismissNotification(
`/home/analysis-results/${analysisOrderId}`,
'link',
);
if (!analysisResponse) {
return (
<>
@@ -93,7 +105,14 @@ export default async function AnalysisResultsPage({
<h6>
<Trans i18nKey={`orders:status.${analysisResponse.order.status}`} />
<ButtonTooltip
content={`${analysisResponse.order.createdAt ? new Date(analysisResponse?.order?.createdAt).toLocaleString() : ''}`}
content={
<Trans
i18nKey="analysis-results:orderCreatedAt"
values={{
createdAt: formatDateAndTime(analysisResponse.order.createdAt)
}}
/>
}
className="ml-6"
/>
</h6>
@@ -108,7 +127,7 @@ export default async function AnalysisResultsPage({
)}
<div className="flex flex-col gap-2">
{orderedAnalysisElements ? (
orderedAnalysisElements.map((element, index) => (
orderedAnalysisElements.map((element) => (
<React.Fragment key={element.analysisIdOriginal}>
<Analysis element={element} />
{element.results?.nestedElements?.map(
@@ -132,3 +151,5 @@ export default async function AnalysisResultsPage({
</>
);
}
export default withI18n(AnalysisResultsPage);

View File

@@ -1,8 +1,7 @@
import { use } from 'react';
import Link from 'next/link';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
import { toArray } from '@kit/shared/utils';
@@ -19,7 +18,7 @@ export async function generateMetadata() {
};
}
export default async function MontonioCheckoutCallbackErrorPage({
async function MontonioCheckoutCallbackErrorPage({
searchParams,
}: {
searchParams: Promise<{ reasonFailed: string }>;
@@ -63,3 +62,5 @@ export default async function MontonioCheckoutCallbackErrorPage({
</div>
);
}
export default withI18n(MontonioCheckoutCallbackErrorPage);

View File

@@ -3,6 +3,7 @@ import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
import { retrieveCart } from '@lib/data/cart';
import { listProductTypes } from '@lib/data/products';
import { AccountBalanceService } from '@kit/accounts/services/account-balance.service';
import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
@@ -11,9 +12,8 @@ import { findProductTypeIdByHandle } from '~/lib/utils';
import Cart from '../../_components/cart';
import CartTimer from '../../_components/cart/cart-timer';
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
import { AccountBalanceService } from '@kit/accounts/services/account-balance.service';
import { EnrichedCartItem } from '../../_components/cart/types';
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
@@ -24,11 +24,7 @@ export async function generateMetadata() {
}
async function CartPage() {
const [
cart,
{ productTypes },
{ account },
] = await Promise.all([
const [cart, { productTypes }, { account }] = await Promise.all([
retrieveCart(),
listProductTypes(),
loadCurrentUserAccount(),
@@ -38,7 +34,9 @@ async function CartPage() {
return null;
}
const balanceSummary = await new AccountBalanceService().getBalanceSummary(account.id);
const balanceSummary = await new AccountBalanceService().getBalanceSummary(
account.id,
);
const synlabAnalysisTypeId = findProductTypeIdByHandle(
productTypes,

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { PageBody } from '@kit/ui/makerkit/page';
import { Trans } from '@kit/ui/makerkit/trans';
import { Skeleton } from '@kit/ui/shadcn/skeleton';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import {
PageViewAction,
createPageViewLog,
} from '~/lib/services/audit/pageView.service';
import { getLatestResponseTime } from '~/lib/utils';
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
import { loadLifeStyle } from '../../_lib/server/load-life-style';
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
return {
title: t('common:lifeStyle.title'),
};
}
async function LifeStylePage() {
const { account } = await loadCurrentUserAccount();
if (!account) {
return null;
}
const client = getSupabaseServerClient();
const userAnalysesApi = createUserAnalysesApi(client);
const analysisResponses = await userAnalysesApi.getAllUserAnalysisResponses();
const currentAIResponseTimestamp = getLatestResponseTime(analysisResponses);
const { response } = await loadLifeStyle({
account,
analysisResponses,
aiResponseTimestamp: currentAIResponseTimestamp,
});
await createPageViewLog({
accountId: account.id,
action: PageViewAction.VIEW_LIFE_STYLE,
});
if (!response.lifestyle) {
return <Skeleton className="mt-10 h-10 w-full" />;
}
return (
<>
<HomeLayoutPageHeader
title={<Trans i18nKey={'common:lifeStyle.title'} />}
description=""
/>
<PageBody>
<div className="mt-8">
{response.lifestyle.map(({ title, description }, index) => (
<React.Fragment key={`${index}-${title}`}>
<div className="flex items-center gap-2">
<h3>{title}</h3>
</div>
<p className="font-regular py-4">{description}</p>
</React.Fragment>
))}
</div>
</PageBody>
</>
);
}
export default withI18n(LifeStylePage);

View File

@@ -28,17 +28,18 @@ async function OrderAnalysisPackagePage() {
<PageBody>
<div className="space-y-3 text-center">
<h3>
<Trans i18nKey={'marketing:selectPackage'} />
<Trans i18nKey="order-analysis-package:selectPackage" />
</h3>
<ComparePackagesModal
analysisPackages={analysisPackages}
analysisPackageElements={analysisPackageElements}
triggerElement={
<Button variant="secondary" className="gap-2">
<Trans i18nKey={'marketing:comparePackages'} />
<Trans i18nKey="order-analysis-package:comparePackages" />
<Scale className="size-4 stroke-[1.5px]" />
</Button>
}
countryCode={countryCode}
/>
</div>
<SelectAnalysisPackages

View File

@@ -37,8 +37,8 @@ async function OrderAnalysisPage() {
return (
<>
<HomeLayoutPageHeader
title={<Trans i18nKey={'order-analysis:title'} />}
description={<Trans i18nKey={'order-analysis:description'} />}
title={<Trans i18nKey="order-analysis:title" />}
description={<Trans i18nKey="order-analysis:description" />}
/>
<PageBody>
<OrderAnalysesCards analyses={analyses} countryCode={countryCode} />

View File

@@ -1,18 +1,19 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import CartTotals from '@/app/home/(user)/_components/order/cart-totals';
import OrderDetails from '@/app/home/(user)/_components/order/order-details';
import OrderItems from '@/app/home/(user)/_components/order/order-items';
import { retrieveOrder } from '@lib/data/orders';
import { StoreOrder } from '@medusajs/types';
import Divider from '@modules/common/components/divider';
import { GlobalLoader } from '@kit/ui/makerkit/global-loader';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { StoreOrder } from '@medusajs/types';
import { AnalysisOrder } from '~/lib/types/analysis-order';
import { useEffect, useRef, useState } from 'react';
import { retrieveOrder } from '@lib/data/orders';
import { GlobalLoader } from '@kit/ui/makerkit/global-loader';
import { AnalysisOrder } from '~/lib/types/order';
function OrderConfirmedLoadingWrapper({
medusaOrder: initialMedusaOrder,
@@ -21,7 +22,8 @@ function OrderConfirmedLoadingWrapper({
medusaOrder: StoreOrder;
order: AnalysisOrder;
}) {
const [medusaOrder, setMedusaOrder] = useState<StoreOrder>(initialMedusaOrder);
const [medusaOrder, setMedusaOrder] =
useState<StoreOrder>(initialMedusaOrder);
const fetchingRef = useRef(false);
const paymentStatus = medusaOrder.payment_status;
@@ -52,7 +54,7 @@ function OrderConfirmedLoadingWrapper({
if (!isPaid) {
return (
<PageBody>
<div className="flex flex-col justify-start items-center h-full pt-[10vh]">
<div className="flex h-full flex-col items-center justify-start pt-[10vh]">
<div>
<GlobalLoader />
</div>
@@ -69,7 +71,14 @@ function OrderConfirmedLoadingWrapper({
<PageHeader title={<Trans i18nKey="cart:orderConfirmed.title" />} />
<Divider />
<div className="small:grid-cols-[1fr_360px] grid grid-cols-1 gap-x-40 gap-y-6 lg:px-4">
<OrderDetails order={order} />
<OrderDetails
order={{
id: order.medusa_order_id,
created_at: order.created_at,
location: null,
serviceProvider: null,
}}
/>
<Divider />
<OrderItems medusaOrder={medusaOrder} />
<CartTotals medusaOrder={medusaOrder} />

View File

@@ -7,6 +7,7 @@ import { pathsConfig } from '@kit/shared/config';
import { withI18n } from '~/lib/i18n/with-i18n';
import { getAnalysisOrder } from '~/lib/services/order.service';
import OrderConfirmedLoadingWrapper from './order-confirmed-loading-wrapper';
export async function generateMetadata() {
@@ -36,7 +37,9 @@ async function OrderConfirmedPage(props: {
redirect(pathsConfig.app.myOrders);
}
return <OrderConfirmedLoadingWrapper medusaOrder={medusaOrder} order={order} />;
return (
<OrderConfirmedLoadingWrapper medusaOrder={medusaOrder} order={order} />
);
}
export default withI18n(OrderConfirmedPage);

View File

@@ -4,6 +4,7 @@ import CartTotals from '@/app/home/(user)/_components/order/cart-totals';
import OrderDetails from '@/app/home/(user)/_components/order/order-details';
import OrderItems from '@/app/home/(user)/_components/order/order-items';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { retrieveOrder } from '@lib/data/orders';
import Divider from '@modules/common/components/divider';
@@ -12,7 +13,6 @@ import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { withI18n } from '~/lib/i18n/with-i18n';
import { getAnalysisOrder } from '~/lib/services/order.service';
export async function generateMetadata() {
const { t } = await createI18nServerInstance();
@@ -25,11 +25,46 @@ export async function generateMetadata() {
async function OrderConfirmedPage(props: {
params: Promise<{ orderId: string }>;
}) {
const supabaseClient = getSupabaseServerClient();
const params = await props.params;
const medusaOrder = await retrieveOrder(params.orderId).catch(() => null);
if (!medusaOrder) {
redirect(pathsConfig.app.myOrders);
}
const ttoReservationId =
medusaOrder.items &&
(medusaOrder.items.find(
({ metadata }) => !!metadata?.connectedOnlineReservationId,
)?.metadata?.connectedOnlineReservationId as number);
const [{ data: ttoReservation }] = await Promise.all([
ttoReservationId
? await supabaseClient
.schema('medreport')
.from('connected_online_reservation')
.select('*')
.eq('id', ttoReservationId)
.single()
: { data: null },
]);
const [{ data: location }, { data: serviceProvider }] = await Promise.all([
ttoReservation
? supabaseClient
.schema('medreport')
.from('connected_online_locations')
.select('name,address')
.eq('sync_id', ttoReservation.location_sync_id || 0)
.single()
: { data: null },
ttoReservation
? supabaseClient
.schema('medreport')
.from('connected_online_providers')
.select('email,phone_number,name')
.eq('id', ttoReservation.clinic_id)
.single()
: { data: null },
]);
return (
<PageBody>
@@ -40,6 +75,8 @@ async function OrderConfirmedPage(props: {
order={{
id: medusaOrder.id,
created_at: medusaOrder.created_at,
location,
serviceProvider,
}}
/>
<Divider />

View File

@@ -29,12 +29,13 @@ export async function generateMetadata() {
}
async function OrdersPage() {
const [medusaOrders, analysisOrders, ttoOrders, { productTypes }] = await Promise.all([
listOrders(ORDERS_LIMIT),
getAnalysisOrders(),
getTtoOrders(),
listProductTypes(),
]);
const [medusaOrders, analysisOrders, ttoOrders, { productTypes }] =
await Promise.all([
listOrders(ORDERS_LIMIT),
getAnalysisOrders(),
getTtoOrders(),
listProductTypes(),
]);
if (!medusaOrders || !productTypes || !ttoOrders) {
redirect(pathsConfig.auth.signIn);
@@ -57,13 +58,24 @@ async function OrdersPage() {
/>
<PageBody>
{medusaOrders.map((medusaOrder) => {
if (!medusaOrder) {
return null;
}
const analysisOrder = analysisOrders.find(
({ medusa_order_id }) => medusa_order_id === medusaOrder.id,
);
if (!medusaOrder) {
return null;
}
const orderIds = new Set(
(medusaOrder?.items ?? [])
.map((i) => i?.metadata?.connectedOnlineReservationId)
.filter(Boolean)
.map(String),
);
const ttoReservation = ttoOrders.find((o) =>
orderIds.has(String(o.id)),
);
const medusaOrderItems = medusaOrder.items || [];
const medusaOrderItemsAnalysisPackages = medusaOrderItems.filter(
@@ -80,13 +92,21 @@ async function OrdersPage() {
),
);
if (
medusaOrderItemsAnalysisPackages.length === 0 &&
medusaOrderItemsOther.length === 0 &&
medusaOrderItemsTtoServices.length === 0
) {
return null;
}
return (
<React.Fragment key={medusaOrder.id}>
<Divider className="my-6" />
<OrderBlock
medusaOrderId={medusaOrder.id}
analysisOrder={analysisOrder}
medusaOrderStatus={medusaOrder.status}
ttoReservation={ttoReservation}
itemsAnalysisPackage={medusaOrderItemsAnalysisPackages}
itemsTtoService={medusaOrderItemsTtoServices}
itemsOther={medusaOrderItemsOther}

View File

@@ -1,5 +1,3 @@
import { Suspense } from 'react';
import { redirect } from 'next/navigation';
import { toTitleCase } from '@/lib/utils';
@@ -12,11 +10,9 @@ import { createUserAnalysesApi } from '@kit/user-analyses/api';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import AIBlocks from '../_components/ai/ai-blocks';
import Dashboard from '../_components/dashboard';
import DashboardCards from '../_components/dashboard-cards';
import Recommendations from '../_components/recommendations';
import RecommendationsSkeleton from '../_components/recommendations-skeleton';
import { isValidOpenAiEnv } from '../_lib/server/is-valid-open-ai-env';
import { loadCurrentUserAccount } from '../_lib/server/load-user-account';
export const generateMetadata = async () => {
@@ -33,7 +29,10 @@ async function UserHomePage() {
const { account } = await loadCurrentUserAccount();
const api = createUserAnalysesApi(client);
const userAnalysesApi = createUserAnalysesApi(client);
const bmiThresholds = await api.fetchBmiThresholds();
const analysisResponses = await userAnalysesApi.getAllUserAnalysisResponses();
if (!account) {
redirect('/');
@@ -53,16 +52,13 @@ async function UserHomePage() {
/>
<PageBody>
<Dashboard account={account} bmiThresholds={bmiThresholds} />
{(await isValidOpenAiEnv()) && (
<>
<h4>
<Trans i18nKey="dashboard:recommendations.title" />
</h4>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations account={account} />
</Suspense>
</>
)}
<h4>
<Trans i18nKey="dashboard:recommendations.title" />
</h4>
<div className="mt-4 grid gap-6 sm:grid-cols-3">
<AIBlocks account={account} analysisResponses={analysisResponses} />
</div>
</PageBody>
</>
);

View File

@@ -0,0 +1,52 @@
'use server';
import React, { Suspense } from 'react';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import { isValidOpenAiEnv } from '../../_lib/server/is-valid-open-ai-env';
import LifeStyleCard from './life-style-card';
import OrderAnalysesPackageCard from './order-analyses-package-card';
import Recommendations from './recommendations';
import RecommendationsSkeleton from './recommendations-skeleton';
import { AnalysisResponses } from './types';
const AIBlocks = async ({
account,
analysisResponses,
}: {
account: AccountWithParams;
analysisResponses?: AnalysisResponses;
}) => {
const isOpenAiAvailable = await isValidOpenAiEnv();
if (!isOpenAiAvailable) {
return <OrderAnalysesPackageCard />;
}
if (analysisResponses?.length === 0) {
return (
<>
<OrderAnalysesPackageCard />
<Suspense fallback={<RecommendationsSkeleton amount={1} />}>
<LifeStyleCard
account={account}
analysisResponses={analysisResponses}
/>
</Suspense>
</>
);
}
return (
<Suspense fallback={<RecommendationsSkeleton />}>
<LifeStyleCard account={account} analysisResponses={analysisResponses} />
<Recommendations
account={account}
analysisResponses={analysisResponses}
/>
</Suspense>
);
};
export default AIBlocks;

View File

@@ -0,0 +1,56 @@
'use server';
import React from 'react';
import Link from 'next/link';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import { pathsConfig } from '@/packages/shared/src/config';
import { ChevronRight } from 'lucide-react';
import { Trans } from '@kit/ui/makerkit/trans';
import { Button } from '@kit/ui/shadcn/button';
import { Card, CardHeader } from '@kit/ui/shadcn/card';
import { getLatestResponseTime } from '~/lib/utils';
import { loadLifeStyle } from '../../_lib/server/load-life-style';
import { AnalysisResponses } from './types';
const LifeStyleCard = async ({
account,
analysisResponses,
}: {
account: AccountWithParams;
analysisResponses?: AnalysisResponses;
}) => {
const aiResponseTimestamp = getLatestResponseTime(analysisResponses);
const { response, dateCreated } = await loadLifeStyle({
account,
analysisResponses,
aiResponseTimestamp,
});
return (
<Card variant="gradient-success" className="flex flex-col justify-between">
<CardHeader className="flex-row justify-between">
<div>
<span className="text-xs">
{new Date(dateCreated).toLocaleString()}
</span>
<h5 className="flex flex-col">
<Trans i18nKey="dashboard:heroCard.lifeStyle.title" />
</h5>
</div>
<Link href={pathsConfig.app.lifeStyle}>
<Button size="icon" variant="outline" className="px-2 text-black">
<ChevronRight className="size-4 stroke-2" />
</Button>
</Link>
</CardHeader>
<span className="text-primary p-4 text-sm">{response.summary}</span>
</Card>
);
};
export default LifeStyleCard;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import Link from 'next/link';
import { pathsConfig } from '@/packages/shared/src/config';
import { ChevronRight, HeartPulse } from 'lucide-react';
import { Trans } from '@kit/ui/makerkit/trans';
import { Button } from '@kit/ui/shadcn/button';
import {
Card,
CardDescription,
CardFooter,
CardHeader,
} from '@kit/ui/shadcn/card';
const OrderAnalysesPackageCard = () => {
return (
<Card
variant="gradient-success"
className="xs:w-1/2 flex w-full flex-col justify-between sm:w-auto"
>
<CardHeader className="flex-row sm:pb-0">
<div
className={
'bg-primary/10 mb-6 flex size-8 items-center-safe justify-center-safe rounded-full text-white'
}
>
<HeartPulse className="size-4 fill-green-500" />
</div>
<div className="bg-warning ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white">
<Link href={pathsConfig.app.orderAnalysisPackage}>
<Button size="icon" variant="outline" className="px-2 text-black">
<ChevronRight className="size-4 stroke-2" />
</Button>
</Link>
</div>
</CardHeader>
<CardFooter className="flex flex-col items-start gap-2">
<h5>
<Trans i18nKey="dashboard:heroCard.orderPackage.title" />
</h5>
<CardDescription className="text-primary">
<Trans i18nKey="dashboard:heroCard.orderPackage.description" />
</CardDescription>
</CardFooter>
</Card>
);
};
export default OrderAnalysesPackageCard;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { InfoTooltip } from '@/packages/shared/src/components/ui/info-tooltip';
import { Button } from '@kit/ui/shadcn/button';
import {
Card,
CardDescription,
CardFooter,
CardHeader,
} from '@kit/ui/shadcn/card';
import { Skeleton } from '@kit/ui/skeleton';
const RecommendationsSkeleton = ({ amount = 2 }: { amount?: number }) => {
const emptyData = [
{
title: '1',
description: '',
subtitle: '',
variant: { id: '' },
price: 1,
},
];
return Array.from({ length: amount }, (_, index) => {
const { title, description, subtitle } = emptyData[0]!;
return (
<Skeleton key={title + index}>
<Card>
<CardHeader className="flex-row">
<div
className={
'mb-6 flex size-8 items-center-safe justify-center-safe'
}
/>
<div className="ml-auto flex size-8 items-center-safe justify-center-safe">
<Button size="icon" className="px-2" />
</div>
</CardHeader>
<CardFooter className="flex">
<div className="flex flex-1 flex-col items-start">
<h5>
{title}
{description && (
<>
{' '}
<InfoTooltip
content={
<div className="flex flex-col gap-2">
<span>{description}</span>
</div>
}
/>
</>
)}
</h5>
{subtitle && <CardDescription>{subtitle}</CardDescription>}
</div>
<div className="flex flex-col items-end gap-2 self-end text-sm"></div>
</CardFooter>
</Card>
</Skeleton>
);
});
};
export default RecommendationsSkeleton;

View File

@@ -0,0 +1,41 @@
'use server';
import React from 'react';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import { getLatestResponseTime } from '~/lib/utils';
import { loadAnalyses } from '../../_lib/server/load-analyses';
import { loadRecommendations } from '../../_lib/server/load-recommendations';
import OrderAnalysesCards from '../order-analyses-cards';
import { AnalysisResponses } from './types';
export default async function Recommendations({
account,
analysisResponses,
}: {
account: AccountWithParams;
analysisResponses?: AnalysisResponses;
}) {
const { analyses, countryCode } = await loadAnalyses();
const currentAIResponseTimestamp = getLatestResponseTime(analysisResponses);
const analysisRecommendations = await loadRecommendations({
account,
analyses,
analysisResponses,
aiResponseTimestamp: currentAIResponseTimestamp,
});
const orderAnalyses = analyses.filter((analysis) =>
analysisRecommendations.includes(analysis.title),
);
if (orderAnalyses.length === 0) {
return null;
}
return (
<OrderAnalysesCards analyses={orderAnalyses} countryCode={countryCode} />
);
}

View File

@@ -0,0 +1,19 @@
import { Database } from '@/packages/supabase/src/database.types';
export interface ILifeStyleResponse {
lifestyle: {
title: string;
description: string;
score: 0 | 1 | 2;
}[];
summary: string | null;
}
export enum PROMPT_NAME {
LIFE_STYLE = 'Life Style',
ANALYSIS_RECOMMENDATIONS = 'Analysis Recommendations',
FEEDBACK = 'Doctor Feedback',
}
export type AnalysisResponses =
Database['medreport']['Functions']['get_latest_analysis_response_elements_for_current_user']['Returns'];

View File

@@ -32,7 +32,16 @@ const BookingContainer = ({
<BookingProvider category={{ products }} service={cartItem?.product}>
<div className="xs:flex-row flex max-h-full flex-col gap-6">
<div className="flex flex-col">
<ServiceSelector products={products} />
<ServiceSelector
products={products.filter((product) => {
if (product.metadata?.serviceIds) {
return Array.isArray(
JSON.parse(product.metadata.serviceIds as string),
);
}
return false;
})}
/>
<BookingCalendar />
<LocationSelector />
</div>

View File

@@ -0,0 +1,113 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Trans } from '@kit/ui/makerkit/trans';
import { cn } from '@kit/ui/shadcn';
import { Button } from '@kit/ui/shadcn/button';
const BookingPagination = ({
totalPages,
setCurrentPage,
currentPage,
}: {
totalPages: number;
setCurrentPage: (page: number) => void;
currentPage: number;
}) => {
const { t } = useTranslation();
const generatePageNumbers = () => {
const pages = [];
const maxVisiblePages = 5;
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 4; i++) {
pages.push(i);
}
pages.push('...');
pages.push(totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(1);
pages.push('...');
for (let i = totalPages - 3; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
pages.push('...');
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i);
}
pages.push('...');
pages.push(totalPages);
}
}
return pages;
};
if (totalPages === 0) {
return (
<div className="wrap text-muted-foreground flex size-full content-center-safe justify-center-safe">
<p>{t('booking:noResults')}</p>
</div>
);
}
return (
totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
{t('common:pageOfPages', {
page: currentPage,
total: totalPages,
})}
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<Trans i18nKey="common:previous" defaultValue="Previous" />
</Button>
{generatePageNumbers().map((page, index) => (
<Button
key={index}
variant={page === currentPage ? 'default' : 'outline'}
size="sm"
onClick={() => typeof page === 'number' && setCurrentPage(page)}
disabled={page === '...'}
className={cn(
'min-w-[2rem]',
page === '...' && 'cursor-default hover:bg-transparent',
)}
>
{page}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
<Trans i18nKey="common:next" defaultValue="Next" />
</Button>
</div>
</div>
)
);
};
export default BookingPagination;

View File

@@ -45,14 +45,13 @@ export const BookingProvider: React.FC<{
const updateTimeSlots = async (serviceIds: number[]) => {
setIsLoadingTimeSlots(true);
try {
console.log('serviceIds', serviceIds, selectedLocationId);
const response = await getAvailableTimeSlotsForDisplay(
serviceIds,
selectedLocationId,
);
setTimeSlots(response.timeSlots);
setLocations(response.locations);
} catch (error) {
} catch {
setTimeSlots(null);
} finally {
setIsLoadingTimeSlots(false);

View File

@@ -1,5 +1,4 @@
import { Label } from '@medusajs/ui';
import { useTranslation } from 'react-i18next';
import { RadioGroup, RadioGroupItem } from '@kit/ui/radio-group';
import { Card } from '@kit/ui/shadcn/card';
@@ -8,7 +7,6 @@ import { Trans } from '@kit/ui/trans';
import { useBooking } from './booking.provider';
const LocationSelector = () => {
const { t } = useTranslation();
const { selectedLocationId, setSelectedLocationId, locations } = useBooking();
const onLocationSelect = (locationId: number | string | null) => {
@@ -16,6 +14,15 @@ const LocationSelector = () => {
setSelectedLocationId(Number(locationId));
};
const uniqueLocations = locations?.filter((item, index, self) => {
return (
index ===
self.findIndex(
(loc) => loc.sync_id === item.sync_id && loc.name === item.name,
)
);
});
return (
<Card className="mb-4 p-4">
<h5 className="text-semibold mb-2">
@@ -26,20 +33,23 @@ const LocationSelector = () => {
className="mb-2 flex flex-col"
onValueChange={(val) => onLocationSelect(val)}
>
<div className="flex items-center gap-2">
{/* <div className="flex items-center gap-2">
<RadioGroupItem
value={'all'}
id={'all'}
value="all"
id="all"
checked={selectedLocationId === null}
/>
<Label htmlFor={'all'}>{t('booking:showAllLocations')}</Label>
</div>
{locations?.map((location) => (
<Label htmlFor="all">{t('booking:showAllLocations')}</Label>
</div> */}
{uniqueLocations?.map((location, index) => (
<div key={location.sync_id} className="flex items-center gap-2">
<RadioGroupItem
value={location.sync_id.toString()}
id={location.sync_id.toString()}
checked={selectedLocationId === location.sync_id}
checked={
selectedLocationId === location.sync_id ||
(index === 0 && selectedLocationId === null)
}
/>
<Label htmlFor={location.sync_id.toString()}>
{location.name}

View File

@@ -11,6 +11,7 @@ import { pathsConfig } from '@kit/shared/config';
import { formatDateAndTime } from '@kit/shared/utils';
import { Button } from '@kit/ui/shadcn/button';
import { Card } from '@kit/ui/shadcn/card';
import { Skeleton } from '@kit/ui/shadcn/skeleton';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
@@ -19,6 +20,7 @@ import { updateReservationTime } from '~/lib/services/reservation.service';
import { createInitialReservationAction } from '../../_lib/server/actions';
import { EnrichedCartItem } from '../cart/types';
import BookingPagination from './booking-pagination';
import { ServiceProvider, TimeSlot } from './booking.context';
import { useBooking } from './booking.provider';
@@ -68,57 +70,16 @@ const TimeSlots = ({
}) ?? [],
'StartTime',
'asc',
),
).filter(({ StartTime }) => isSameDay(StartTime, selectedDate)),
[booking.timeSlots, selectedDate],
);
const totalPages = Math.ceil(filteredBookings.length / PAGE_SIZE);
const paginatedBookings = useMemo(() => {
const startIndex = (currentPage - 1) * PAGE_SIZE;
const endIndex = startIndex + PAGE_SIZE;
return filteredBookings.slice(startIndex, endIndex);
}, [filteredBookings, currentPage, PAGE_SIZE]);
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const generatePageNumbers = () => {
const pages = [];
const maxVisiblePages = 5;
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 4; i++) {
pages.push(i);
}
pages.push('...');
pages.push(totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(1);
pages.push('...');
for (let i = totalPages - 3; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
pages.push('...');
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i);
}
pages.push('...');
pages.push(totalPages);
}
}
return pages;
};
if (!booking?.timeSlots?.length) {
return null;
}
@@ -143,12 +104,17 @@ const TimeSlots = ({
timeSlot.StartTime,
booking.selectedLocationId ? booking.selectedLocationId : null,
comments,
).then(() => {
if (onComplete) {
onComplete();
}
router.push(pathsConfig.app.cart);
});
)
.then(() => {
if (onComplete) {
onComplete();
}
router.push(pathsConfig.app.cart);
})
.catch((error) => {
console.error('Booking error: ', error);
throw error;
});
toast.promise(() => bookTimePromise, {
success: <Trans i18nKey={'booking:bookTimeSuccess'} />,
@@ -203,10 +169,13 @@ const TimeSlots = ({
};
return (
<div className="flex w-full flex-col gap-4">
<Skeleton
isLoading={booking.isLoadingTimeSlots}
className="flex w-full flex-col gap-4"
>
<div className="flex h-full w-full flex-col gap-2 overflow-auto">
{paginatedBookings.map((timeSlot, index) => {
const isEHIF = timeSlot.HKServiceID > 0;
const isHaigeKassa = timeSlot.HKServiceID > 0;
const serviceProviderTitle = getServiceProviderTitle(
currentLocale,
timeSlot.serviceProvider,
@@ -214,9 +183,10 @@ const TimeSlots = ({
const price =
booking.selectedService?.variants?.[0]?.calculated_price
?.calculated_amount ?? cartItem?.unit_price;
return (
<Card
className="xs:flex xs:justify-between grid w-full justify-center-safe gap-3 p-4"
className="xs:flex xs:justify-between w-full justify-center-safe gap-3 p-4"
key={index}
>
<div>
@@ -224,7 +194,7 @@ const TimeSlots = ({
<div className="flex">
<h5
className={cn(
(serviceProviderTitle || isEHIF) &&
(serviceProviderTitle || isHaigeKassa) &&
"after:mx-2 after:content-['·']",
)}
>
@@ -232,12 +202,14 @@ const TimeSlots = ({
</h5>
{serviceProviderTitle && (
<span
className={cn(isEHIF && "after:mx-2 after:content-['·']")}
className={cn(
isHaigeKassa && "after:mx-2 after:content-['·']",
)}
>
{serviceProviderTitle}
</span>
)}
{isEHIF && <span>{t('booking:ehifBooking')}</span>}
{isHaigeKassa && <span>{t('booking:ehifBooking')}</span>}
</div>
<div className="flex text-xs">{timeSlot.location?.address}</div>
</div>
@@ -256,63 +228,14 @@ const TimeSlots = ({
</Card>
);
})}
{!paginatedBookings.length && (
<div className="wrap text-muted-foreground flex size-full content-center-safe justify-center-safe">
<p>{t('booking:noResults')}</p>
</div>
)}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
{t('common:pageOfPages', {
page: currentPage,
total: totalPages,
})}
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<Trans i18nKey="common:previous" defaultValue="Previous" />
</Button>
{generatePageNumbers().map((page, index) => (
<Button
key={index}
variant={page === currentPage ? 'default' : 'outline'}
size="sm"
onClick={() =>
typeof page === 'number' && handlePageChange(page)
}
disabled={page === '...'}
className={cn(
'min-w-[2rem]',
page === '...' && 'cursor-default hover:bg-transparent',
)}
>
{page}
</Button>
))}
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<Trans i18nKey="common:next" defaultValue="Next" />
</Button>
</div>
</div>
)}
</div>
<BookingPagination
totalPages={Math.ceil(filteredBookings.length / PAGE_SIZE)}
setCurrentPage={setCurrentPage}
currentPage={currentPage}
/>
</Skeleton>
);
};

View File

@@ -1,13 +1,10 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { StoreCart, StoreCartLineItem } from '@medusajs/types';
import { useForm } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { z } from 'zod';
import { Form } from '@kit/ui/form';
import {
Select,
SelectContent,
@@ -22,10 +19,6 @@ import { Trans } from '@kit/ui/trans';
import { updateCartPartnerLocation } from '../../_lib/server/update-cart-partner-location';
import partnerLocations from './partner-locations.json';
const AnalysisLocationSchema = z.object({
locationId: z.string().min(1),
});
export default function AnalysisLocation({
cart,
synlabAnalyses,
@@ -35,21 +28,15 @@ export default function AnalysisLocation({
}) {
const { t } = useTranslation('cart');
const form = useForm<z.infer<typeof AnalysisLocationSchema>>({
defaultValues: {
locationId: (cart.metadata?.partner_location_id as string) ?? '',
},
resolver: zodResolver(AnalysisLocationSchema),
});
const { watch, setValue } = useFormContext();
const currentValue = watch('locationId');
const getLocation = (locationId: string) =>
partnerLocations.find(({ name }) => name === locationId);
const selectedLocation = getLocation(form.watch('locationId'));
const selectedLocation = getLocation(currentValue);
const onSubmit = async ({
locationId,
}: z.infer<typeof AnalysisLocationSchema>) => {
const handleUpdateCartPartnerLocation = async (locationId: string) => {
const promise = updateCartPartnerLocation({
cartId: cart.id,
lineIds: synlabAnalyses.map(({ id }) => id),
@@ -70,53 +57,48 @@ export default function AnalysisLocation({
<Trans i18nKey={'cart:locations.description'} />
</p>
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => onSubmit(data))}
className="mb-2 flex w-full flex-1 gap-x-2"
<div className="mb-2 flex w-full flex-1 gap-x-2">
<Select
value={currentValue}
onValueChange={(value) => {
setValue('locationId', value, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
return handleUpdateCartPartnerLocation(value);
}}
>
<Select
value={form.watch('locationId')}
onValueChange={(value) => {
form.setValue('locationId', value, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
<SelectTrigger>
<SelectValue placeholder={t('cart:locations.locationSelect')} />
</SelectTrigger>
return onSubmit(form.getValues());
}}
>
<SelectTrigger>
<SelectValue placeholder={t('cart:locations.locationSelect')} />
</SelectTrigger>
<SelectContent>
{Object.entries(
partnerLocations.reduce(
(acc, curr) => ({
...acc,
[curr.city]: [
...((acc[curr.city] as typeof partnerLocations) ?? []),
curr,
],
}),
{} as Record<string, typeof partnerLocations>,
),
).map(([city, locations]) => (
<SelectGroup key={city}>
<SelectLabel>{city}</SelectLabel>
{locations.map((location) => (
<SelectItem key={location.name} value={location.name}>
{location.name}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</form>
</Form>
<SelectContent>
{Object.entries(
partnerLocations.reduce(
(acc, curr) => ({
...acc,
[curr.city]: [
...((acc[curr.city] as typeof partnerLocations) ?? []),
curr,
],
}),
{} as Record<string, typeof partnerLocations>,
),
).map(([city, locations]) => (
<SelectGroup key={city}>
<SelectLabel>{city}</SelectLabel>
{locations.map((location) => (
<SelectItem key={location.name} value={location.name}>
{location.name}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
{selectedLocation && (
<div className="mb-4 flex flex-col gap-y-2">

View File

@@ -0,0 +1,219 @@
'use client';
import { formatCurrency } from '@/packages/shared/src/utils';
import {
StoreCart,
StoreCartLineItem,
StoreCartPromotion,
} from '@medusajs/types';
import { Loader2 } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader } from '@kit/ui/card';
import { Checkbox } from '@kit/ui/checkbox';
import { FormControl, FormField, FormItem, FormLabel } from '@kit/ui/form';
import { Trans } from '@kit/ui/trans';
import { cn } from '~/lib/utils';
import AnalysisLocation from './analysis-location';
import CartItems from './cart-items';
import CartServiceItems from './cart-service-items';
import DiscountCode from './discount-code';
import { EnrichedCartItem, GetBalanceSummarySelection } from './types';
const IS_DISCOUNT_SHOWN = true as boolean;
export default function CartFormContent({
cart,
synlabAnalyses,
ttoServiceItems,
unavailableLineItemIds,
isInitiatingSession,
getBalanceSummarySelection,
}: {
cart: StoreCart & {
promotions: StoreCartPromotion[];
};
synlabAnalyses: StoreCartLineItem[];
ttoServiceItems: EnrichedCartItem[];
unavailableLineItemIds?: string[];
isInitiatingSession: boolean;
getBalanceSummarySelection: GetBalanceSummarySelection;
}) {
const {
i18n: { language },
} = useTranslation();
const { watch } = useFormContext();
const items = cart?.items ?? [];
const hasCartItems = cart && Array.isArray(items) && items.length > 0;
const isLocationsShown = synlabAnalyses.length > 0;
const useCompanyBenefits = watch('useCompanyBenefits');
const balanceSummary = getBalanceSummarySelection({ useCompanyBenefits });
const { benefitsAmount, benefitsAmountTotal, montonioAmount } =
balanceSummary;
const hasBenefitsApplied = benefitsAmountTotal > 0 && !!useCompanyBenefits;
return (
<>
<div className="flex flex-col gap-y-6 bg-white">
<CartItems
cart={cart}
items={synlabAnalyses}
productColumnLabelKey="cart:items.synlabAnalyses.productColumnLabel"
/>
<CartServiceItems
cart={cart}
items={ttoServiceItems}
productColumnLabelKey="cart:items.ttoServices.productColumnLabel"
unavailableLineItemIds={unavailableLineItemIds}
/>
</div>
{hasCartItems && (
<>
<div className="flex gap-x-4 px-4 pt-2 sm:justify-end sm:px-6 sm:pt-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.subtotal" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: cart.subtotal,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
<div
className={cn(
'flex gap-x-4 px-4 pt-2 sm:justify-end sm:px-6 sm:pt-4',
{
'py-2 sm:py-4': !hasBenefitsApplied,
},
)}
>
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.promotionsTotal" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: cart.discount_total,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
{hasBenefitsApplied && (
<div className="flex gap-x-4 px-4 py-2 sm:justify-end sm:px-6 sm:py-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.companyBenefitsTotal" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: benefitsAmount,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
)}
<div className="flex gap-x-4 px-4 py-2 sm:justify-end sm:px-6 sm:py-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.total" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: montonioAmount,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
{benefitsAmountTotal > 0 && (
<FormField
name="useCompanyBenefits"
render={({ field }) => (
<FormItem className="mt-8">
<div className="flex flex-row items-center gap-2 pb-1">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>
<Trans i18nKey={'cart:companyBenefits.label'} />
</FormLabel>
</div>
</FormItem>
)}
/>
)}
</>
)}
<div className="flex flex-col gap-x-4 gap-y-6 py-4 sm:flex-row sm:py-8">
{IS_DISCOUNT_SHOWN && (
<Card className="flex w-full flex-col justify-between sm:w-1/2">
<CardHeader className="pb-4">
<h5>
<Trans i18nKey="cart:discountCode.title" />
</h5>
</CardHeader>
<CardContent className="h-full">
<DiscountCode cart={{ ...cart }} />
</CardContent>
</Card>
)}
{isLocationsShown && (
<Card className="flex w-full flex-col justify-between sm:w-1/2">
<CardHeader className="pb-4">
<h5>
<Trans i18nKey="cart:locations.title" />
</h5>
</CardHeader>
<CardContent className="h-full">
<AnalysisLocation
cart={{ ...cart }}
synlabAnalyses={synlabAnalyses}
/>
</CardContent>
</Card>
)}
</div>
<div>
<Button className="h-10" type="submit" disabled={isInitiatingSession}>
{isInitiatingSession && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Trans i18nKey="cart:checkout.goToCheckout" />
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,49 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import type { StoreCart } from '@medusajs/types';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form } from '@kit/ui/form';
const CartFormSchema = z.object({
code: z.string().optional(),
locationId: z.string().optional(),
useCompanyBenefits: z.boolean(),
});
export type CartFormOnSubmit = (
data: z.infer<typeof CartFormSchema>,
) => Promise<void>;
export default function CartForm({
children,
cart,
onSubmit,
}: {
children: React.ReactNode;
cart: StoreCart;
onSubmit: CartFormOnSubmit;
}) {
const form = useForm<z.infer<typeof CartFormSchema>>({
defaultValues: {
code: '',
locationId: (cart.metadata?.partner_location_id as string) ?? '',
useCompanyBenefits: true,
},
resolver: zodResolver(CartFormSchema),
});
const handleSubmit: CartFormOnSubmit = async (data) => {
await onSubmit(data);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => handleSubmit(data))}>
{children}
</form>
</Form>
);
}

View File

@@ -38,6 +38,7 @@ const CartItemDelete = ({
<button
className="text-ui-fg-subtle hover:text-ui-fg-base flex cursor-pointer gap-x-1"
onClick={() => handleDelete()}
type="button"
>
{isDeleting ? <Spinner className="animate-spin" /> : <Trash />}
<span>{children}</span>

View File

@@ -20,7 +20,7 @@ export default function CartItem({
} = useTranslation();
return (
<TableRow className="w-full" data-testid="product-row">
<TableRow className="sm:w-full" data-testid="product-row">
<TableCell className="w-[100%] px-4 text-left sm:px-6">
<p
className="txt-medium-plus text-ui-fg-base"
@@ -41,11 +41,12 @@ export default function CartItem({
</TableCell>
<TableCell className="min-w-[80px] px-4 text-right sm:px-6">
{formatCurrency({
value: item.total,
currencyCode,
locale: language,
})}
{item.total &&
formatCurrency({
value: item.total,
currencyCode,
locale: language,
})}
</TableCell>
<TableCell className="px-4 text-right sm:px-6">

View File

@@ -10,6 +10,7 @@ import {
import { Trans } from '@kit/ui/trans';
import CartItem from './cart-item';
import MobileCartItems from './mobile-cart-items';
export default function CartItems({
cart,
@@ -25,37 +26,54 @@ export default function CartItems({
}
return (
<Table className="border-separate rounded-lg border">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey={productColumnLabelKey} />
</TableHead>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey="cart:table.quantity" />
</TableHead>
<TableHead className="min-w-[100px] px-4 sm:px-6">
<Trans i18nKey="cart:table.price" />
</TableHead>
<TableHead className="min-w-[100px] px-4 text-right sm:px-6">
<Trans i18nKey="cart:table.total" />
</TableHead>
<TableHead className="px-4 sm:px-6"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<>
<Table className="hidden border-separate rounded-lg border sm:block">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey={productColumnLabelKey} />
</TableHead>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey="cart:table.quantity" />
</TableHead>
<TableHead className="min-w-[100px] px-4 sm:px-6">
<Trans i18nKey="cart:table.price" />
</TableHead>
<TableHead className="min-w-[100px] px-4 text-right sm:px-6">
<Trans i18nKey="cart:table.total" />
</TableHead>
<TableHead className="px-4 sm:px-6"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items
.sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((item) => (
<CartItem
key={item.id}
item={item}
currencyCode={cart.currency_code}
/>
))}
</TableBody>
</Table>
<div className="sm:hidden">
{items
.sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((item) => (
<CartItem
<MobileCartItems
key={item.id}
item={item}
currencyCode={cart.currency_code}
productColumnLabelKey={productColumnLabelKey}
/>
))}
</TableBody>
</Table>
</div>
</>
);
}

View File

@@ -1,76 +1,26 @@
'use client';
import { useState } from 'react';
import { formatCurrency, formatDateAndTime } from '@/packages/shared/src/utils';
import { useTranslation } from 'react-i18next';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { TableCell, TableRow } from '@kit/ui/table';
import { Trans } from '@kit/ui/trans';
import BookingContainer from '../booking/booking-container';
import CartItemDelete from './cart-item-delete';
import { EnrichedCartItem } from './types';
const EditCartServiceItemModal = ({
item,
onComplete,
}: {
item: EnrichedCartItem | null;
onComplete: () => void;
}) => {
if (!item) return null;
return (
<Dialog defaultOpen>
<DialogContent className="xs:max-w-[90vw] flex max-h-screen max-w-full flex-col items-center gap-4 space-y-4 overflow-y-scroll">
<DialogHeader className="items-center text-center">
<DialogTitle>
<Trans i18nKey="cart:editServiceItem.title" />
</DialogTitle>
<DialogDescription>
<Trans i18nKey="cart:editServiceItem.description" />
</DialogDescription>
</DialogHeader>
<div>
{item.product && item.reservation.countryCode ? (
<BookingContainer
category={{
products: [item.product],
countryCode: item.reservation.countryCode,
}}
cartItem={item}
onComplete={onComplete}
/>
) : (
<p>
<Trans i18nKey="booking:noProducts" />
</p>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default function CartServiceItem({
item,
currencyCode,
isUnavailable,
setEditingItem,
}: {
item: EnrichedCartItem;
currencyCode: string;
isUnavailable?: boolean;
setEditingItem: (item: EnrichedCartItem | null) => void;
}) {
const [editingItem, setEditingItem] = useState<EnrichedCartItem | null>(null);
const {
i18n: { language },
} = useTranslation();
@@ -106,16 +56,21 @@ export default function CartServiceItem({
</TableCell>
<TableCell className="min-w-[80px] px-4 text-right sm:px-6">
{formatCurrency({
value: item.total,
currencyCode,
locale: language,
})}
{item.total &&
formatCurrency({
value: item.total,
currencyCode,
locale: language,
})}
</TableCell>
<TableCell className="px-4 text-right sm:px-6">
<span className="flex justify-end gap-x-1">
<Button size="sm" onClick={() => setEditingItem(item)}>
<Button
type="button"
size="sm"
onClick={() => setEditingItem(item)}
>
<Trans i18nKey="common:change" />
</Button>
</span>
@@ -137,10 +92,6 @@ export default function CartServiceItem({
</TableCell>
</TableRow>
)}
<EditCartServiceItemModal
item={editingItem}
onComplete={() => setEditingItem(null)}
/>
</>
);
}

View File

@@ -1,5 +1,14 @@
import { useState } from 'react';
import { StoreCart } from '@medusajs/types';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@kit/ui/shadcn/dialog';
import {
Table,
TableBody,
@@ -9,9 +18,52 @@ import {
} from '@kit/ui/table';
import { Trans } from '@kit/ui/trans';
import BookingContainer from '../booking/booking-container';
import CartServiceItem from './cart-service-item';
import MobileCartServiceItems from './mobile-cart-service-items';
import { EnrichedCartItem } from './types';
const EditCartServiceItemModal = ({
item,
onComplete,
}: {
item: EnrichedCartItem | null;
onComplete: () => void;
}) => {
if (!item) return null;
return (
<Dialog defaultOpen>
<DialogContent className="xs:max-w-[90vw] flex max-h-screen max-w-full flex-col items-center gap-4 space-y-4 overflow-y-scroll">
<DialogHeader className="items-center text-center">
<DialogTitle>
<Trans i18nKey="cart:editServiceItem.title" />
</DialogTitle>
<DialogDescription>
<Trans i18nKey="cart:editServiceItem.description" />
</DialogDescription>
</DialogHeader>
<div>
{item.product && item.reservation.countryCode ? (
<BookingContainer
category={{
products: [item.product],
countryCode: item.reservation.countryCode,
}}
cartItem={item}
onComplete={onComplete}
/>
) : (
<p>
<Trans i18nKey="booking:noProducts" />
</p>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default function CartServiceItems({
cart,
items,
@@ -23,50 +75,75 @@ export default function CartServiceItems({
productColumnLabelKey: string;
unavailableLineItemIds?: string[];
}) {
const [editingItem, setEditingItem] = useState<EnrichedCartItem | null>(null);
if (!items || items.length === 0) {
return null;
}
return (
<Table className="border-separate rounded-lg border">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey={productColumnLabelKey} />
</TableHead>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey="cart:table.time" />
</TableHead>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey="cart:table.location" />
</TableHead>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey="cart:table.quantity" />
</TableHead>
<TableHead className="min-w-[100px] px-4 sm:px-6">
<Trans i18nKey="cart:table.price" />
</TableHead>
<TableHead className="min-w-[100px] px-4 text-right sm:px-6">
<Trans i18nKey="cart:table.total" />
</TableHead>
<TableHead className="px-4 sm:px-6"></TableHead>
<TableHead className="px-4 sm:px-6"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<>
<Table className="hidden border-separate rounded-lg border sm:block">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey={productColumnLabelKey} />
</TableHead>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey="cart:table.time" />
</TableHead>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey="cart:table.location" />
</TableHead>
<TableHead className="px-4 sm:px-6">
<Trans i18nKey="cart:table.quantity" />
</TableHead>
<TableHead className="min-w-[100px] px-4 sm:px-6">
<Trans i18nKey="cart:table.price" />
</TableHead>
<TableHead className="min-w-[100px] px-4 text-right sm:px-6">
<Trans i18nKey="cart:table.total" />
</TableHead>
<TableHead className="px-4 sm:px-6"></TableHead>
<TableHead className="px-4 sm:px-6"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items
.sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((item) => (
<CartServiceItem
key={item.id}
item={item}
currencyCode={cart.currency_code}
isUnavailable={unavailableLineItemIds?.includes(item.id)}
setEditingItem={setEditingItem}
/>
))}
</TableBody>
</Table>
<div className="sm:hidden">
{items
.sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((item) => (
<CartServiceItem
<MobileCartServiceItems
key={item.id}
item={item}
currencyCode={cart.currency_code}
isUnavailable={unavailableLineItemIds?.includes(item.id)}
productColumnLabelKey={productColumnLabelKey}
setEditingItem={setEditingItem}
/>
))}
</TableBody>
</Table>
</div>
<EditCartServiceItemModal
item={editingItem}
onComplete={() => setEditingItem(null)}
/>
</>
);
}

View File

@@ -1,18 +1,16 @@
'use client';
import React from 'react';
import React, { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { convertToLocale } from '@lib/util/money';
import { StoreCart, StorePromotion } from '@medusajs/types';
import { StoreCart, StoreCartPromotion } from '@medusajs/types';
import { Badge, Text } from '@medusajs/ui';
import Trash from '@modules/common/icons/trash';
import { useForm } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { Button } from '@kit/ui/button';
import { Form, FormControl, FormField, FormItem } from '@kit/ui/form';
import { FormControl, FormField, FormItem } from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
@@ -22,21 +20,22 @@ import {
removePromotionCodeAction,
} from './discount-code-actions';
const DiscountCodeSchema = z.object({
code: z.string().min(1),
});
export default function DiscountCode({
cart,
}: {
cart: StoreCart & {
promotions: StorePromotion[];
promotions: StoreCartPromotion[];
};
}) {
const { t } = useTranslation('cart');
const { setValue, watch } = useFormContext();
const currentValue = watch('code');
const { promotions = [] } = cart;
const [isAddingPromotionCode, setIsAddingPromotionCode] = useState(false);
const removePromotionCode = async (code: string) => {
const appliedCodes = promotions
.filter((p) => p.code !== undefined)
@@ -55,57 +54,56 @@ export default function DiscountCode({
};
const addPromotionCode = async (code: string) => {
if (!code || code.length === 0) {
return;
}
setIsAddingPromotionCode(true);
const loading = toast.loading(t('cart:discountCode.addLoading'));
const result = await addPromotionCodeAction(code);
toast.dismiss(loading);
if (result.success) {
toast.success(t('cart:discountCode.addSuccess'));
form.reset();
setValue('code', '');
} else {
toast.error(t('cart:discountCode.addError'));
}
setIsAddingPromotionCode(false);
};
const form = useForm<z.infer<typeof DiscountCodeSchema>>({
defaultValues: {
code: '',
},
resolver: zodResolver(DiscountCodeSchema),
});
return (
<div className="txt-medium flex h-full w-full flex-col gap-y-4 bg-white">
<p className="text-muted-foreground text-sm">
<Trans i18nKey={'cart:discountCode.subtitle'} />
</p>
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => addPromotionCode(data.code))}
className="mb-2 flex w-full flex-1 flex-col gap-x-2 gap-y-2 sm:flex-row"
>
<FormField
name={'code'}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
required
type="text"
{...field}
placeholder={t('cart:discountCode.placeholder')}
/>
</FormControl>
</FormItem>
)}
/>
<div className="mb-2 flex w-full flex-1 flex-col gap-x-2 gap-y-2 sm:flex-row">
<FormField
name={'code'}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
type="text"
{...field}
placeholder={t('cart:discountCode.placeholder')}
/>
</FormControl>
</FormItem>
)}
/>
<Button type="submit" variant="secondary" className="h-min">
<Trans i18nKey={'cart:discountCode.apply'} />
</Button>
</form>
</Form>
<Button
type="button"
variant="secondary"
className="h-min"
onClick={() => addPromotionCode(currentValue)}
disabled={isAddingPromotionCode}
>
<Trans i18nKey={'cart:discountCode.apply'} />
</Button>
</div>
{promotions.length > 0 && (
<div className="mt-4 flex w-full items-center">

View File

@@ -1,27 +1,24 @@
'use client';
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { formatCurrency } from '@/packages/shared/src/utils';
import { StoreCart, StoreCartLineItem } from '@medusajs/types';
import { Loader2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import {
StoreCart,
StoreCartLineItem,
StoreCartPromotion,
} from '@medusajs/types';
import { useTranslation } from 'react-i18next';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader } from '@kit/ui/card';
import { AccountBalanceSummary } from '@kit/accounts/services/account-balance.service';
import { Trans } from '@kit/ui/trans';
import AnalysisLocation from './analysis-location';
import CartItems from './cart-items';
import CartServiceItems from './cart-service-items';
import DiscountCode from './discount-code';
import { initiatePayment } from '../../_lib/server/cart-actions';
import { useRouter } from 'next/navigation';
import { AccountBalanceSummary } from '@kit/accounts/services/account-balance.service';
import CartForm, { CartFormOnSubmit } from './cart-form';
import CartFormContent from './cart-form-content';
import { EnrichedCartItem } from './types';
const IS_DISCOUNT_SHOWN = true as boolean;
export default function Cart({
accountId,
cart,
@@ -30,7 +27,11 @@ export default function Cart({
balanceSummary,
}: {
accountId: string;
cart: StoreCart | null;
cart:
| (StoreCart & {
promotions: StoreCartPromotion[];
})
| null;
synlabAnalyses: StoreCartLineItem[];
ttoServiceItems: EnrichedCartItem[];
balanceSummary: AccountBalanceSummary | null;
@@ -44,6 +45,47 @@ export default function Cart({
const [unavailableLineItemIds, setUnavailableLineItemIds] =
useState<string[]>();
const getBalanceSummarySelection = useCallback(
({
useCompanyBenefits,
}: {
useCompanyBenefits: boolean;
}): {
benefitsAmount: number;
benefitsAmountTotal: number;
montonioAmount: number;
} => {
if (!cart) {
return {
benefitsAmount: 0,
benefitsAmountTotal: 0,
montonioAmount: 0,
};
}
const benefitsAmountTotal = balanceSummary?.totalBalance ?? 0;
const cartTotal = cart.total;
if (!useCompanyBenefits) {
return {
benefitsAmount: 0,
benefitsAmountTotal,
montonioAmount: cartTotal,
};
}
const benefitsAmount =
benefitsAmountTotal > cartTotal ? cartTotal : benefitsAmountTotal;
const montonioAmount =
benefitsAmount > 0 ? cartTotal - benefitsAmount : cartTotal;
return {
benefitsAmount,
benefitsAmountTotal,
montonioAmount,
};
},
[balanceSummary, cart],
);
const items = cart?.items ?? [];
const hasCartItems = cart && Array.isArray(items) && items.length > 0;
@@ -67,16 +109,20 @@ export default function Cart({
);
}
async function initiateSession() {
const initiateSession: CartFormOnSubmit = async ({ useCompanyBenefits }) => {
setIsInitiatingSession(true);
try {
const { url, isFullyPaidByBenefits, orderId, unavailableLineItemIds } = await initiatePayment({
accountId,
balanceSummary: balanceSummary!,
cart: cart!,
language,
const { benefitsAmount } = getBalanceSummarySelection({
useCompanyBenefits,
});
const { url, isFullyPaidByBenefits, orderId, unavailableLineItemIds } =
await initiatePayment({
accountId,
benefitsAmount,
cart: cart!,
language,
});
if (unavailableLineItemIds) {
setUnavailableLineItemIds(unavailableLineItemIds);
}
@@ -92,142 +138,20 @@ export default function Cart({
console.error('Failed to initiate payment', error);
setIsInitiatingSession(false);
}
}
const isLocationsShown = synlabAnalyses.length > 0;
const companyBenefitsTotal = balanceSummary?.totalBalance ?? 0;
const montonioTotal = cart && companyBenefitsTotal > 0 ? cart.total - companyBenefitsTotal : cart.total;
};
return (
<div className="small:grid-cols-[1fr_360px] grid grid-cols-1 gap-x-40 lg:px-4">
<div className="flex flex-col gap-y-6 bg-white">
<CartItems
<CartForm cart={cart} onSubmit={initiateSession}>
<CartFormContent
cart={cart}
items={synlabAnalyses}
productColumnLabelKey="cart:items.synlabAnalyses.productColumnLabel"
/>
<CartServiceItems
cart={cart}
items={ttoServiceItems}
productColumnLabelKey="cart:items.ttoServices.productColumnLabel"
synlabAnalyses={synlabAnalyses}
ttoServiceItems={ttoServiceItems}
unavailableLineItemIds={unavailableLineItemIds}
isInitiatingSession={isInitiatingSession}
getBalanceSummarySelection={getBalanceSummarySelection}
/>
</div>
{hasCartItems && (
<>
<div className="flex gap-x-4 px-4 pt-2 sm:justify-end sm:px-6 sm:pt-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.subtotal" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: cart.subtotal,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
<div className="flex gap-x-4 px-4 pt-2 sm:justify-end sm:px-6 sm:pt-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.promotionsTotal" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: cart.discount_total,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
{companyBenefitsTotal > 0 && (
<div className="flex gap-x-4 px-4 py-2 sm:justify-end sm:px-6 sm:py-4">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="text-muted-foreground ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.companyBenefitsTotal" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: (companyBenefitsTotal > cart.total ? cart.total : companyBenefitsTotal),
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
)}
<div className="flex gap-x-4 px-4 sm:justify-end sm:px-6">
<div className="w-full sm:mr-[42px] sm:w-auto">
<p className="ml-0 text-sm font-bold">
<Trans i18nKey="cart:order.total" />
</p>
</div>
<div className={`sm:mr-[112px] sm:w-[50px]`}>
<p className="text-right text-sm">
{formatCurrency({
value: montonioTotal < 0 ? 0 : montonioTotal,
currencyCode: cart.currency_code,
locale: language,
})}
</p>
</div>
</div>
</>
)}
<div className="flex flex-col gap-x-4 gap-y-6 py-4 sm:flex-row sm:py-8">
{IS_DISCOUNT_SHOWN && (
<Card className="flex w-full flex-col justify-between sm:w-1/2">
<CardHeader className="pb-4">
<h5>
<Trans i18nKey="cart:discountCode.title" />
</h5>
</CardHeader>
<CardContent className="h-full">
<DiscountCode cart={{ ...cart }} />
</CardContent>
</Card>
)}
{isLocationsShown && (
<Card className="flex w-full flex-col justify-between sm:w-1/2">
<CardHeader className="pb-4">
<h5>
<Trans i18nKey="cart:locations.title" />
</h5>
</CardHeader>
<CardContent className="h-full">
<AnalysisLocation
cart={{ ...cart }}
synlabAnalyses={synlabAnalyses}
/>
</CardContent>
</Card>
)}
</div>
<div>
<Button
className="h-10"
onClick={initiateSession}
disabled={isInitiatingSession}
>
{isInitiatingSession && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Trans i18nKey="cart:checkout.goToCheckout" />
</Button>
</div>
</CartForm>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { formatCurrency } from '@/packages/shared/src/utils';
import { StoreCartLineItem } from '@medusajs/types';
import { useTranslation } from 'react-i18next';
import { Table, TableBody } from '@kit/ui/shadcn/table';
import MobileTableRow from './mobile-table-row';
const MobileCartItems = ({
item,
currencyCode,
productColumnLabelKey,
}: {
item: StoreCartLineItem;
currencyCode: string;
productColumnLabelKey: string;
}) => {
const {
i18n: { language, t },
} = useTranslation();
return (
<Table className="border-separate rounded-lg border p-2">
<TableBody>
<MobileTableRow
title={t(productColumnLabelKey)}
value={item.product_title}
/>
<MobileTableRow title={t('cart:table.time')} value={item.quantity} />
<MobileTableRow
title={t('cart:table.price')}
value={formatCurrency({
value: item.unit_price,
currencyCode,
locale: language,
})}
/>
<MobileTableRow
title={t('cart:table.total')}
value={
item.total &&
formatCurrency({
value: item.total,
currencyCode,
locale: language,
})
}
/>
</TableBody>
</Table>
);
};
export default MobileCartItems;

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { formatCurrency, formatDateAndTime } from '@/packages/shared/src/utils';
import { useTranslation } from 'react-i18next';
import { Trans } from '@kit/ui/makerkit/trans';
import { Button } from '@kit/ui/shadcn/button';
import { Table, TableBody, TableCell, TableRow } from '@kit/ui/shadcn/table';
import CartItemDelete from './cart-item-delete';
import MobileTableRow from './mobile-table-row';
import { EnrichedCartItem } from './types';
const MobileCartServiceItems = ({
item,
currencyCode,
isUnavailable,
productColumnLabelKey,
setEditingItem,
}: {
item: EnrichedCartItem;
currencyCode: string;
isUnavailable?: boolean;
productColumnLabelKey: string;
setEditingItem: (item: EnrichedCartItem | null) => void;
}) => {
const {
i18n: { language, t },
} = useTranslation();
return (
<Table className="border-separate rounded-lg border p-2">
<TableBody>
<MobileTableRow
title={t(productColumnLabelKey)}
value={item.product_title}
/>
<MobileTableRow
title={t('cart:table.time')}
value={formatDateAndTime(item.reservation.startTime.toString())}
/>
<MobileTableRow
title={t('cart:table.location')}
value={item.reservation.location?.address ?? '-'}
/>
<MobileTableRow
title={t('cart:table.quantity')}
value={item.quantity}
/>
<MobileTableRow
title={t('cart:table.price')}
value={formatCurrency({
value: item.unit_price,
currencyCode,
locale: language,
})}
/>
<MobileTableRow
title={t('cart:table.total')}
value={
item.total &&
formatCurrency({
value: item.total,
currencyCode,
locale: language,
})
}
/>
<TableRow>
<TableCell />
<TableCell className="flex w-full items-center justify-end gap-4 p-0 pt-2">
<CartItemDelete id={item.id} />
<Button type="button" onClick={() => setEditingItem(item)}>
<Trans i18nKey="common:change" />
</Button>
</TableCell>
</TableRow>
{isUnavailable && (
<TableRow>
<TableCell
colSpan={8}
className="text-destructive px-4 text-left sm:px-6"
>
<Trans i18nKey="booking:timeSlotUnavailable" />
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
};
export default MobileCartServiceItems;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { TableCell, TableHead, TableRow } from '@kit/ui/shadcn/table';
const MobleTableRow = ({
title,
value,
}: {
title?: string;
value?: string | number;
}) => (
<TableRow>
<TableHead className="h-2 font-bold">{title}</TableHead>
<TableCell className="p-0 text-right">
<p className="txt-medium-plus text-ui-fg-base">{value}</p>
</TableCell>
</TableRow>
);
export default MobleTableRow;

View File

@@ -1,5 +1,6 @@
import { StoreCartLineItem } from "@medusajs/types";
import { Reservation } from "~/lib/types/reservation";
import { StoreCartLineItem } from '@medusajs/types';
import { Reservation } from '~/lib/types/reservation';
export interface MontonioOrderToken {
uuid: string;
@@ -13,12 +14,6 @@ export interface MontonioOrderToken {
| 'PENDING'
| 'EXPIRED'
| 'REFUNDED';
| 'PAID'
| 'FAILED'
| 'CANCELLED'
| 'PENDING'
| 'EXPIRED'
| 'REFUNDED';
paymentMethod: string;
grandTotal: number;
currency: string;
@@ -36,3 +31,13 @@ export enum CartItemType {
}
export type EnrichedCartItem = StoreCartLineItem & { reservation: Reservation };
export type GetBalanceSummarySelection = ({
useCompanyBenefits,
}: {
useCompanyBenefits: boolean;
}) => {
benefitsAmount: number;
benefitsAmountTotal: number;
montonioAmount: number;
};

View File

@@ -0,0 +1,11 @@
'use client';
import { Check } from 'lucide-react';
export const CheckWithBackground = () => {
return (
<div className="bg-primary w-min rounded-full p-1 text-white">
<Check className="size-3 stroke-2" />
</div>
);
};

View File

@@ -0,0 +1,115 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { AnalysisPackageWithVariant } from '@/packages/shared/src/components/select-analysis-package';
import { pathsConfig } from '@/packages/shared/src/config';
import { Spinner } from '@kit/ui/makerkit/spinner';
import { Trans } from '@kit/ui/makerkit/trans';
import { Button } from '@kit/ui/shadcn/button';
import { toast } from '@kit/ui/shadcn/sonner';
import { Table, TableBody, TableCell, TableRow } from '@kit/ui/shadcn/table';
import { handleAddToCart } from '~/lib/services/medusaCart.service';
import { cn } from '~/lib/utils';
const AddToCartButton = ({
onClick,
disabled,
isLoading,
}: {
onClick: () => void;
disabled: boolean;
isLoading: boolean;
}) => {
return (
<TableCell align="center" className="xs:px-2 px-1 py-6">
<Button
onClick={onClick}
disabled={disabled}
className="xs:p-6 xs:text-sm relative p-2 text-[10px]"
>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner />
</div>
)}
<div
className={cn({
invisible: isLoading,
})}
>
<Trans i18nKey="compare-packages-modal:selectThisPackage" />
</div>
</Button>
</TableCell>
);
};
const ComparePackagesAddToCartButtons = ({
countryCode,
standardPackage,
standardPlusPackage,
premiumPackage,
}: {
countryCode: string;
standardPackage: AnalysisPackageWithVariant;
standardPlusPackage: AnalysisPackageWithVariant;
premiumPackage: AnalysisPackageWithVariant;
}) => {
const [addedPackage, setAddedPackage] = useState<string | null>(null);
const router = useRouter();
const handleSelect = async ({ variantId }: AnalysisPackageWithVariant) => {
setAddedPackage(variantId);
try {
await handleAddToCart({
selectedVariant: { id: variantId },
countryCode,
});
setAddedPackage(null);
toast.success(
<Trans i18nKey={'order-analysis-package:analysisPackageAddedToCart'} />,
);
router.push(pathsConfig.app.cart);
} catch (e) {
toast.error(
<Trans
i18nKey={'order-analysis-package:analysisPackageAddToCartError'}
/>,
);
setAddedPackage(null);
console.error(e);
}
};
return (
<Table>
<TableBody>
<TableRow>
<TableCell className="w-[30vw] py-6" />
<AddToCartButton
onClick={() => handleSelect(standardPackage)}
disabled={!!addedPackage}
isLoading={addedPackage === standardPackage.variantId}
/>
<AddToCartButton
onClick={() => handleSelect(standardPlusPackage)}
disabled={!!addedPackage}
isLoading={addedPackage === standardPlusPackage.variantId}
/>
<AddToCartButton
onClick={() => handleSelect(premiumPackage)}
disabled={!!addedPackage}
isLoading={addedPackage === premiumPackage.variantId}
/>
</TableRow>
</TableBody>
</Table>
);
};
export default ComparePackagesAddToCartButtons;

View File

@@ -3,7 +3,7 @@ import { JSX } from 'react';
import { StoreProduct } from '@medusajs/types';
import { QuestionMarkCircledIcon } from '@radix-ui/react-icons';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { Check, X } from 'lucide-react';
import { X } from 'lucide-react';
import { PackageHeader } from '@kit/shared/components/package-header';
import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
@@ -26,6 +26,10 @@ import {
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { CheckWithBackground } from './check-with-background';
import ComparePackagesAddToCartButtons from './compare-packages-add-to-cart-buttons';
import DefaultPackageFeaturesRows from './default-package-features-rows';
export type AnalysisPackageElement = Pick<
StoreProduct,
'title' | 'id' | 'description'
@@ -35,14 +39,6 @@ export type AnalysisPackageElement = Pick<
isIncludedInPremium: boolean;
};
const CheckWithBackground = () => {
return (
<div className="bg-primary w-min rounded-full p-1 text-white">
<Check className="size-3 stroke-2" />
</div>
);
};
const PackageTableHead = async ({
product,
}: {
@@ -53,7 +49,7 @@ const PackageTableHead = async ({
const { title, price, nrOfAnalyses } = product;
return (
<TableHead className="py-2">
<TableHead className="xs:content-normal content-start py-2">
<PackageHeader
title={t(title)}
tagColor="bg-cyan"
@@ -69,10 +65,12 @@ const ComparePackagesModal = async ({
analysisPackages,
analysisPackageElements,
triggerElement,
countryCode,
}: {
analysisPackages: AnalysisPackageWithVariant[];
analysisPackageElements: AnalysisPackageElement[];
triggerElement: JSX.Element;
countryCode: string;
}) => {
const { t } = await createI18nServerInstance();
@@ -92,7 +90,7 @@ const ComparePackagesModal = async ({
<DialogContent
className="min-h-screen max-w-fit min-w-screen"
customClose={
<div className="inline-flex place-items-center-safe gap-1 align-middle">
<div className="absolute top-6 right-0 flex place-items-center-safe sm:top-0">
<p className="text-sm font-medium text-black">
{t('common:close')}
</p>
@@ -106,11 +104,13 @@ const ComparePackagesModal = async ({
</VisuallyHidden>
<div className="m-auto">
<div className="space-y-6 text-center">
<h3>{t('product:healthPackageComparison.label')}</h3>
<p className="text-muted-foreground mx-auto w-3/5 text-sm">
<h3 className="sm:text-xxl text-lg">
{t('product:healthPackageComparison.label')}
</h3>
<p className="text-muted-foreground text-sm sm:mx-auto sm:w-3/5">
{t('product:healthPackageComparison.description')}
</p>
<div className="max-h-[80vh] overflow-y-auto rounded-md border">
<div className="max-h-[50vh] overflow-y-auto rounded-md border sm:max-h-[70vh]">
<Table>
<TableHeader>
<TableRow>
@@ -121,6 +121,8 @@ const ComparePackagesModal = async ({
</TableRow>
</TableHeader>
<TableBody>
<DefaultPackageFeaturesRows />
{analysisPackageElements.map(
({
title,
@@ -136,12 +138,14 @@ const ComparePackagesModal = async ({
return (
<TableRow key={id}>
<TableCell className="py-6 sm:max-w-[30vw]">
<TableCell className="relative py-6 sm:w-[30vw]">
{title}{' '}
{description && (
<InfoTooltip
content={description}
icon={<QuestionMarkCircledIcon />}
icon={
<QuestionMarkCircledIcon className="absolute top-2 right-0 size-5 sm:static sm:ml-2 sm:size-4" />
}
/>
)}
</TableCell>
@@ -164,6 +168,12 @@ const ComparePackagesModal = async ({
</Table>
</div>
</div>
<ComparePackagesAddToCartButtons
countryCode={countryCode}
standardPackage={standardPackage}
premiumPackage={premiumPackage}
standardPlusPackage={standardPlusPackage}
/>
</div>
</DialogContent>
</Dialog>

View File

@@ -1,5 +1,7 @@
import Link from 'next/link';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { formatCurrency } from '@/packages/shared/src/utils';
import { ChevronRight, HeartPulse } from 'lucide-react';
import { Button } from '@kit/ui/button';
@@ -11,25 +13,27 @@ import {
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import { formatCurrency } from '@/packages/shared/src/utils';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { cn } from '@kit/ui/lib/utils';
import { loadCurrentUserAccount } from '../_lib/server/load-user-account';
import { Trans } from '@kit/ui/trans';
import { getAccountBalanceSummary } from '../_lib/server/balance-actions';
import { loadCurrentUserAccount } from '../_lib/server/load-user-account';
export default async function DashboardCards() {
const { language } = await createI18nServerInstance();
const { account } = await loadCurrentUserAccount();
const balanceSummary = account ? await getAccountBalanceSummary(account.id) : null;
const balanceSummary = account
? await getAccountBalanceSummary(account.id)
: null;
return (
<div
className={cn(
'grid grid-cols-1 gap-4',
'md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
)}>
)}
>
<Card
variant="gradient-success"
className="xs:w-1/2 flex w-full flex-col justify-between sm:w-auto"
@@ -79,7 +83,7 @@ export default async function DashboardCards() {
<Trans
i18nKey="dashboard:heroCard.benefits.validUntil"
values={{ date: '31.12.2025' }}
/>
/>
</CardDescription>
</CardContent>
</Card>

View File

@@ -14,7 +14,10 @@ import {
User,
} from 'lucide-react';
import type { AccountWithParams, BmiThresholds } from '@kit/accounts/types/accounts';
import type {
AccountWithParams,
BmiThresholds,
} from '@kit/accounts/types/accounts';
import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import {

View File

@@ -0,0 +1,57 @@
'use client';
import React from 'react';
import { InfoTooltip } from '@/packages/shared/src/components/ui/info-tooltip';
import { QuestionMarkCircledIcon } from '@radix-ui/react-icons';
import { Trans } from '@kit/ui/makerkit/trans';
import { TableCell, TableRow } from '@kit/ui/shadcn/table';
import { CheckWithBackground } from './check-with-background';
const DefaultPackageFeaturesRows = () => {
return (
<>
<TableRow key="digital-doctor-feedback">
<TableCell className="relative max-w-[30vw] py-6">
<Trans i18nKey="order-analysis-package:digitalDoctorFeedback" />
<InfoTooltip
content={
<Trans i18nKey="order-analysis-package:digitalDoctorFeedbackInfo" />
}
icon={
<QuestionMarkCircledIcon className="absolute top-2 right-0 size-5 sm:static sm:ml-2 sm:size-4" />
}
/>
</TableCell>
<TableCell align="center" className="py-6">
<CheckWithBackground />
</TableCell>
<TableCell align="center" className="py-6">
<CheckWithBackground />
</TableCell>
<TableCell align="center" className="py-6">
<CheckWithBackground />
</TableCell>
</TableRow>
<TableRow key="give-analyses">
<TableCell className="py-6 sm:max-w-[30vw]">
<Trans i18nKey="order-analysis-package:giveAnalyses" />
</TableCell>
<TableCell align="center" className="py-6">
<CheckWithBackground />
</TableCell>
<TableCell align="center" className="py-6">
<CheckWithBackground />
</TableCell>
<TableCell align="center" className="py-6">
<CheckWithBackground />
</TableCell>
</TableRow>
</>
);
};
export default DefaultPackageFeaturesRows;

View File

@@ -9,12 +9,12 @@ import { ShoppingCart } from 'lucide-react';
import { AppLogo } from '@kit/shared/components/app-logo';
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
import { Search } from '@kit/shared/components/ui/search';
import { Button } from '@kit/ui/button';
import { Card } from '@kit/ui/shadcn/card';
import { Trans } from '@kit/ui/trans';
import { UserNotifications } from '../_components/user-notifications';
import { getAccountBalanceSummary } from '../_lib/server/balance-actions';
import { type UserWorkspace } from '../_lib/server/load-user-workspace';
export async function HomeMenuNavigation(props: {
@@ -23,13 +23,9 @@ export async function HomeMenuNavigation(props: {
}) {
const { language } = await createI18nServerInstance();
const { workspace, user, accounts } = props.workspace;
const totalValue = props.cart?.total
? formatCurrency({
currencyCode: props.cart.currency_code,
locale: language,
value: props.cart.total,
})
: 0;
const balanceSummary = workspace?.id
? await getAccountBalanceSummary(workspace.id)
: null;
const cartQuantityTotal =
props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
@@ -47,29 +43,32 @@ export async function HomeMenuNavigation(props: {
/> */}
<div className="flex items-center justify-end gap-3">
{/* TODO: add wallet functionality
<Card className="px-6 py-2">
<span>€ {Number(0).toFixed(2).replace('.', ',')}</span>
<span>
{formatCurrency({
value: balanceSummary?.totalBalance || 0,
locale: language,
currencyCode: 'EUR',
})}
</span>
</Card>
*/}
{hasCartItems && (
<Button
className="relative mr-0 h-10 cursor-pointer border-1 px-4 py-2"
variant="ghost"
>
<span className="flex items-center text-nowrap">{totalValue}</span>
</Button>
)}
<Link href={pathsConfig.app.cart}>
<Button
variant="ghost"
className="relative mr-0 h-10 cursor-pointer border-1 px-4 py-2"
>
<ShoppingCart className="stroke-[1.5px]" />
<Trans
i18nKey="common:shoppingCartCount"
values={{ count: cartQuantityTotal }}
/>
<Trans i18nKey="common:shoppingCart" />{' '}
{hasCartItems && (
<>
(
<span className="text-success font-bold">
{cartQuantityTotal}
</span>
)
</>
)}
</Button>
</Link>
<UserNotifications userId={user.id} />

View File

@@ -25,16 +25,22 @@ import {
DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu';
import { If } from '@kit/ui/if';
import { cn } from '@kit/ui/shadcn';
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/shadcn/avatar';
import { Button } from '@kit/ui/shadcn/button';
import { Trans } from '@kit/ui/trans';
// home imports
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
import { UserNotifications } from './user-notifications';
const PERSONAL_ACCOUNT_SLUG = 'personal';
export function HomeMobileNavigation(props: {
workspace: UserWorkspace;
cart: StoreCart | null;
}) {
const user = props.workspace.user;
const { user, accounts } = props.workspace;
const signOut = useSignOut();
const { data: personalAccountData } = usePersonalAccountData(user.id);
@@ -76,8 +82,8 @@ export function HomeMobileNavigation(props: {
const hasDoctorRole =
personalAccountData?.application_role === ApplicationRoleEnum.Doctor;
return hasDoctorRole && hasTotpFactor;
}, [user, personalAccountData, hasTotpFactor]);
return hasDoctorRole;
}, [personalAccountData]);
const cartQuantityTotal =
props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
@@ -85,10 +91,31 @@ export function HomeMobileNavigation(props: {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Menu className={'h-9'} />
</DropdownMenuTrigger>
<div className="flex justify-between gap-3">
<Link href={pathsConfig.app.cart}>
<Button
variant="ghost"
className="relative mr-0 h-10 cursor-pointer border-1 px-4 py-2"
>
<ShoppingCart className="stroke-[1.5px]" />
{hasCartItems && (
<>
(
<span className="text-success font-bold">
{cartQuantityTotal}
</span>
)
</>
)}
</Button>
</Link>
<UserNotifications userId={user.id} />
<DropdownMenuTrigger>
<Menu className="h-6 w-6" />
</DropdownMenuTrigger>
</div>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
<If condition={props.cart && hasCartItems}>
<DropdownMenuGroup>
@@ -148,6 +175,46 @@ export function HomeMobileNavigation(props: {
</If>
<DropdownMenuSeparator />
<If condition={accounts.length > 0}>
<span className="text-muted-foreground px-2 text-xs">
<Trans
i18nKey={'teams:yourTeams'}
values={{ teamsCount: accounts.length }}
/>
</span>
{accounts.map((account) => (
<DropdownMenuItem key={account.value} asChild>
<Link
className={'s-full flex cursor-pointer items-center space-x-2'}
href={`${pathsConfig.app.home}/${account.value}`}
>
<div className={'flex items-center'}>
<Avatar className={'h-5 w-5 rounded-xs ' + account.image}>
<AvatarImage
{...(account.image && { src: account.image })}
/>
<AvatarFallback
className={cn('rounded-md', {
['bg-background']:
PERSONAL_ACCOUNT_SLUG === account.value,
['group-hover:bg-background']:
PERSONAL_ACCOUNT_SLUG !== account.value,
})}
>
{account.label ? account.label[0] : ''}
</AvatarFallback>
</Avatar>
<span className={'pl-3'}>{account.label}</span>
</div>
</Link>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</If>
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -57,72 +57,64 @@ export default function OrderAnalysesCards({
}
};
return (
<div className="xs:grid-cols-3 mt-4 grid gap-6">
{analyses.map(({ title, variant, description, subtitle, price }) => {
const formattedPrice =
typeof price === 'number'
? formatCurrency({
currencyCode: 'eur',
locale: language,
value: price,
})
: null;
return (
<Card
key={title}
variant="gradient-success"
className="flex flex-col justify-between"
>
<CardHeader className="flex-row">
<div
className={
'bg-primary/10 mb-6 flex size-8 items-center-safe justify-center-safe rounded-full text-white'
}
>
<HeartPulse className="size-4 fill-green-500" />
</div>
<div className="bg-warning ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white">
<Button
size="icon"
variant="outline"
className="px-2 text-black"
onClick={() => handleSelect(variant.id)}
>
{variantAddingToCart === variant.id ? (
<Loader2 className="size-4 animate-spin stroke-2" />
) : (
<ShoppingCart className="size-4 stroke-2" />
)}
</Button>
</div>
</CardHeader>
<CardFooter className="flex gap-2">
<div className="flex flex-1 flex-col items-start gap-2">
<h5>
{title}
{description && (
<>
<InfoTooltip
content={
<div className="flex flex-col gap-2">
<span>{formattedPrice}</span>
<span>{description}</span>
</div>
}
/>
</>
)}
</h5>
{subtitle && <CardDescription>{subtitle}</CardDescription>}
</div>
<div className="flex flex-col items-end gap-2 self-end text-sm">
<span>{formattedPrice}</span>
</div>
</CardFooter>
</Card>
);
})}
</div>
);
return analyses.map(({ title, variant, description, subtitle, price }) => {
const formattedPrice =
typeof price === 'number'
? formatCurrency({
currencyCode: 'eur',
locale: language,
value: price,
})
: null;
return (
<Card
key={title}
variant="gradient-success"
className="flex flex-col justify-between"
>
<CardHeader className="flex-row">
<div className="bg-primary/10 mb-6 flex size-8 items-center-safe justify-center-safe rounded-full text-white">
<HeartPulse className="size-4 fill-green-500" />
</div>
<div className="bg-warning ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white">
<Button
size="icon"
variant="outline"
className="px-2 text-black"
onClick={() => handleSelect(variant.id)}
>
{variantAddingToCart === variant.id ? (
<Loader2 className="size-4 animate-spin stroke-2" />
) : (
<ShoppingCart className="size-4 stroke-2" />
)}
</Button>
</div>
</CardHeader>
<CardFooter className="flex gap-2">
<div className="flex flex-1 flex-col items-start gap-2">
<h5>
{title}
{description && (
<>
<InfoTooltip
content={
<div className="flex flex-col gap-2">
<span>{formattedPrice}</span>
<span>{description}</span>
</div>
}
/>
</>
)}
</h5>
{subtitle && <CardDescription>{subtitle}</CardDescription>}
</div>
<div className="flex flex-col items-end gap-2 self-end text-sm">
<span>{formattedPrice}</span>
</div>
</CardFooter>
</Card>
);
});
}

View File

@@ -5,7 +5,7 @@ import Link from 'next/link';
import { cn } from '@/lib/utils';
import { pathsConfig } from '@/packages/shared/src/config';
import { ComponentInstanceIcon } from '@radix-ui/react-icons';
import { ChevronRight, HeartPulse } from 'lucide-react';
import { ChevronRight } from 'lucide-react';
import { Button } from '@kit/ui/button';
import {

View File

@@ -9,8 +9,8 @@ import { useTranslation } from 'react-i18next';
import { Trans } from '@kit/ui/trans';
const PaymentProviderIds = {
COMPANY_BENEFITS: "pp_company-benefits_company-benefits",
MONTONIO: "pp_montonio_montonio",
COMPANY_BENEFITS: 'pp_company-benefits_company-benefits',
MONTONIO: 'pp_montonio_montonio',
};
export default function CartTotals({
@@ -30,10 +30,12 @@ export default function CartTotals({
payment_collections,
} = medusaOrder;
const montonioPayment = payment_collections?.[0]?.payments
?.find(({ provider_id }) => provider_id === PaymentProviderIds.MONTONIO);
const companyBenefitsPayment = payment_collections?.[0]?.payments
?.find(({ provider_id }) => provider_id === PaymentProviderIds.COMPANY_BENEFITS);
const montonioPayment = payment_collections?.[0]?.payments?.find(
({ provider_id }) => provider_id === PaymentProviderIds.MONTONIO,
);
const companyBenefitsPayment = payment_collections?.[0]?.payments?.find(
({ provider_id }) => provider_id === PaymentProviderIds.COMPANY_BENEFITS,
);
return (
<div>
@@ -96,7 +98,6 @@ export default function CartTotals({
</span>
</div>
)}
</div>
<div className="my-4 h-px w-full border-b border-gray-200" />
@@ -126,7 +127,10 @@ export default function CartTotals({
<span className="flex items-center gap-x-1">
<Trans i18nKey="cart:order.benefitsTotal" />
</span>
<span data-testid="cart-subtotal" data-value={companyBenefitsPayment.amount || 0}>
<span
data-testid="cart-subtotal"
data-value={companyBenefitsPayment.amount || 0}
>
-{' '}
{formatCurrency({
value: companyBenefitsPayment.amount ?? 0,
@@ -142,7 +146,10 @@ export default function CartTotals({
<span className="flex items-center gap-x-1">
<Trans i18nKey="cart:order.montonioTotal" />
</span>
<span data-testid="cart-subtotal" data-value={montonioPayment.amount || 0}>
<span
data-testid="cart-subtotal"
data-value={montonioPayment.amount || 0}
>
-{' '}
{formatCurrency({
value: montonioPayment.amount ?? 0,

View File

@@ -1,27 +1,63 @@
import { formatDate } from 'date-fns';
import { Database } from '@kit/supabase/database';
import { Trans } from '@kit/ui/trans';
export default function OrderDetails({
order,
}: {
order: { id: string; created_at: string | Date };
order: {
id: string;
created_at: string | Date;
location: Pick<
Database['medreport']['Tables']['connected_online_locations']['Row'],
'name' | 'address'
> | null;
serviceProvider: Pick<
Database['medreport']['Tables']['connected_online_providers']['Row'],
'email' | 'phone_number' | 'name'
> | null;
};
}) {
const { id, created_at, location, serviceProvider } = order;
return (
<div className="flex flex-col gap-y-2">
<div>
<span className="font-bold">
<Trans i18nKey="cart:orderConfirmed.orderNumber" />:{' '}
</span>
<span className="break-all">{order.id}</span>
<span className="break-all">{id}</span>
</div>
<div>
<span className="font-bold">
<Trans i18nKey="cart:orderConfirmed.orderDate" />:{' '}
</span>
<span>{formatDate(order.created_at, 'dd.MM.yyyy HH:mm')}</span>
<span>{formatDate(created_at, 'dd.MM.yyyy HH:mm')}</span>
</div>
{(location?.name || location?.address) && (
<div>
<span className="font-bold">
<Trans i18nKey="cart:orderConfirmed.location" />:{' '}
</span>
<span>
{location.name || location.address}{' '}
{location?.name ? location.address : ''}
</span>
</div>
)}
{serviceProvider && (
<div className="flex flex-col">
<span className="font-bold">
<Trans i18nKey="cart:orderConfirmed.serviceProvider" />:{' '}
</span>
<span>{serviceProvider.name}</span>
<span>{serviceProvider.phone_number}</span>
<span>{serviceProvider.email}</span>
</div>
)}
</div>
);
}

View File

@@ -5,20 +5,22 @@ import { Eye } from 'lucide-react';
import { Trans } from '@kit/ui/makerkit/trans';
import type { AnalysisOrder } from '~/lib/types/order';
import type { AnalysisOrder, TTOOrder } from '~/lib/types/order';
import OrderItemsTable from './order-items-table';
export default function OrderBlock({
analysisOrder,
medusaOrderStatus,
ttoLocation,
ttoReservation,
itemsAnalysisPackage,
itemsTtoService,
itemsOther,
medusaOrderId,
}: {
analysisOrder?: AnalysisOrder;
medusaOrderStatus: string;
ttoLocation?: { name: string };
ttoReservation?: TTOOrder;
itemsAnalysisPackage: StoreOrderLineItem[];
itemsTtoService: StoreOrderLineItem[];
itemsOther: StoreOrderLineItem[];
@@ -38,7 +40,7 @@ export default function OrderBlock({
<Trans i18nKey={`orders:status.${analysisOrder.status}`} />
</h5>
<Link
href={`/home/order/${analysisOrder.id}`}
href={`/home/order/${medusaOrderId}`}
className="text-small-regular flex items-center justify-between"
>
<button className="text-ui-fg-subtle hover:text-ui-fg-base flex cursor-pointer gap-x-1">
@@ -47,7 +49,7 @@ export default function OrderBlock({
</Link>
</div>
)}
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:gap-4">
{analysisOrder && (
<OrderItemsTable
items={itemsAnalysisPackage}
@@ -57,6 +59,7 @@ export default function OrderBlock({
id: analysisOrder.id,
status: analysisOrder.status,
}}
isPackage
/>
)}
{itemsTtoService && (
@@ -65,8 +68,12 @@ export default function OrderBlock({
title="orders:table.ttoService"
type="ttoService"
order={{
status: medusaOrderStatus.toUpperCase(),
status: ttoReservation?.status,
medusaOrderId,
location: ttoLocation?.name,
bookingCode: ttoReservation?.booking_code,
clinicId: ttoReservation?.clinic_id,
medusaLineItemId: ttoReservation?.medusa_cart_line_item_id,
}}
/>
)}
@@ -74,6 +81,8 @@ export default function OrderBlock({
items={itemsOther}
title="orders:table.otherOrders"
order={{
medusaOrderId: analysisOrder?.medusa_order_id,
id: analysisOrder?.id,
status: analysisOrder?.status,
}}
/>

View File

@@ -1,9 +1,13 @@
'use client';
import { useRouter } from 'next/navigation';
import React, { useState } from 'react';
import { redirect, useRouter } from 'next/navigation';
import ConfirmationModal from '@/packages/shared/src/components/confirmation-modal';
import { StoreOrderLineItem } from '@medusajs/types';
import { formatDate } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
@@ -19,6 +23,8 @@ import { Trans } from '@kit/ui/trans';
import type { Order } from '~/lib/types/order';
import { cancelTtoBooking } from '../../_lib/server/actions';
import MobileTableRow from '../cart/mobile-table-row';
import { logAnalysisResultsNavigateAction } from './actions';
export type OrderItemType = 'analysisOrder' | 'ttoService';
@@ -28,19 +34,31 @@ export default function OrderItemsTable({
title,
order,
type = 'analysisOrder',
isPackage = false,
}: {
items: StoreOrderLineItem[];
title: string;
order: Order;
type?: OrderItemType;
isPackage?: boolean;
}) {
const {
i18n: { t },
} = useTranslation();
const router = useRouter();
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const isCancelOrderAllowed =
order?.bookingCode &&
order?.clinicId &&
order?.medusaLineItemId &&
order?.status === 'CONFIRMED';
if (!items || items.length === 0) {
return null;
}
const isAnalysisOrder = type === 'analysisOrder';
const isTtoservice = type === 'ttoService';
const openDetailedView = async () => {
if (isAnalysisOrder && order?.medusaOrderId && order?.id) {
@@ -52,52 +70,159 @@ export default function OrderItemsTable({
};
return (
<Table className="border-separate rounded-lg border">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
<TableHead className="px-6">
<Trans i18nKey={title} />
</TableHead>
<TableHead className="px-6">
<Trans i18nKey="orders:table.createdAt" />
</TableHead>
<TableHead className="px-6">
<Trans i18nKey="orders:table.status" />
</TableHead>
{isAnalysisOrder && <TableHead className="px-6"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{items
.sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((orderItem) => (
<TableRow className="w-full" key={orderItem.id}>
<TableCell className="w-[100%] px-6 text-left">
<p className="txt-medium-plus text-ui-fg-base">
{orderItem.product_title}
</p>
</TableCell>
<TableCell className="px-6 whitespace-nowrap">
{formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')}
</TableCell>
<TableCell className="min-w-[180px] px-6">
<Trans
i18nKey={`orders:status.${type}.${order?.status ?? 'CONFIRMED'}`}
<>
<Table className="border-separate rounded-lg border p-2 sm:hidden">
<TableBody>
{items
.sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((orderItem) => (
<React.Fragment key={`${orderItem.id}-mobile`}>
<MobileTableRow
title={t(title)}
value={orderItem.product_title || ''}
/>
</TableCell>
<MobileTableRow
title={t('orders:table.createdAt')}
value={formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')}
/>
{order.location && (
<MobileTableRow
title={t('orders:table.location')}
value={order.location}
/>
)}
<MobileTableRow
title={t('orders:table.status')}
value={
isPackage
? t(
`orders:status.analysisPackageOrder.${order?.status ?? 'CONFIRMED'}`,
)
: t(
`orders:status.${type}.${order?.status ?? 'CONFIRMED'}`,
)
}
/>
<TableRow>
<TableCell />
<TableCell className="flex w-full items-center justify-end gap-2 p-0 pt-2">
<Button size="sm" onClick={openDetailedView}>
<Trans
i18nKey={
isTtoservice ? 'orders:view' : 'analysis-results:view'
}
/>
</Button>
{isTtoservice && order.bookingCode && (
<Button
size="sm"
className="bg-warning/90 hover:bg-warning"
onClick={() => setIsConfirmOpen(true)}
>
<Trans i18nKey="analysis-results:cancel" />
</Button>
)}
</TableCell>
</TableRow>
</React.Fragment>
))}
</TableBody>
</Table>
<TableCell className="px-6 text-right">
<Button size="sm" onClick={openDetailedView}>
<Trans i18nKey="analysis-results:view" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Table className="hidden border-separate rounded-lg border sm:block">
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow>
<TableHead className="px-6">
<Trans i18nKey={title} />
</TableHead>
<TableHead className="px-6">
<Trans i18nKey="orders:table.createdAt" />
</TableHead>
{order.location && (
<TableHead className="px-6">
<Trans i18nKey="orders:table.location" />
</TableHead>
)}
<TableHead className="px-6">
<Trans i18nKey="orders:table.status" />
</TableHead>
{isAnalysisOrder && <TableHead className="px-6"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{items
.sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((orderItem) => (
<TableRow className="w-full" key={orderItem.id}>
<TableCell className="w-[100%] px-6 text-left">
<p className="txt-medium-plus text-ui-fg-base">
{orderItem.product_title}
</p>
</TableCell>
<TableCell className="px-6 whitespace-nowrap">
{formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')}
</TableCell>
{order.location && (
<TableCell className="min-w-[180px] px-6">
{order.location}
</TableCell>
)}
<TableCell className="min-w-[180px] px-6">
{isPackage ? (
<Trans
i18nKey={`orders:status.analysisPackageOrder.${order?.status ?? 'CONFIRMED'}`}
/>
) : (
<Trans
i18nKey={`orders:status.${type}.${order?.status ?? 'CONFIRMED'}`}
/>
)}
</TableCell>
<TableCell className="px-6 text-right">
<Button size="sm" onClick={openDetailedView}>
<Trans
i18nKey={
isTtoservice ? 'orders:view' : 'analysis-results:view'
}
/>
</Button>
{isCancelOrderAllowed && (
<Button
size="sm"
className="bg-warning/90 hover:bg-warning mt-2 w-full"
onClick={() => setIsConfirmOpen(true)}
>
<Trans i18nKey="analysis-results:cancel" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{isCancelOrderAllowed && (
<ConfirmationModal
isOpen={isConfirmOpen}
onClose={() => setIsConfirmOpen(false)}
onConfirm={async () => {
cancelTtoBooking(
order.bookingCode!,
order.clinicId!,
order.medusaLineItemId!,
).then(() => {
redirect(pathsConfig.app.myOrders);
});
}}
titleKey="orders:confirmBookingCancel.title"
descriptionKey="orders:confirmBookingCancel.description"
/>
)}
</>
);
}

View File

@@ -1,75 +0,0 @@
import React from 'react';
import { InfoTooltip } from '@/packages/shared/src/components/ui/info-tooltip';
import { HeartPulse } from 'lucide-react';
import { Button } from '@kit/ui/shadcn/button';
import {
Card,
CardDescription,
CardFooter,
CardHeader,
} from '@kit/ui/shadcn/card';
import { Skeleton } from '@kit/ui/skeleton';
const RecommendationsSkeleton = () => {
const emptyData = [
{
title: '1',
description: '',
subtitle: '',
variant: { id: '' },
price: 1,
},
{
title: '2',
description: '',
subtitle: '',
variant: { id: '' },
price: 1,
},
];
return (
<div className="xs:grid-cols-3 mt-4 grid gap-6">
{emptyData.map(({ title, description, subtitle }) => (
<Skeleton key={title}>
<Card>
<CardHeader className="flex-row">
<div
className={
'mb-6 flex size-8 items-center-safe justify-center-safe'
}
/>
<div className="ml-auto flex size-8 items-center-safe justify-center-safe">
<Button size="icon" className="px-2" />
</div>
</CardHeader>
<CardFooter className="flex">
<div className="flex flex-1 flex-col items-start">
<h5>
{title}
{description && (
<>
{' '}
<InfoTooltip
content={
<div className="flex flex-col gap-2">
<span>{description}</span>
</div>
}
/>
</>
)}
</h5>
{subtitle && <CardDescription>{subtitle}</CardDescription>}
</div>
<div className="flex flex-col items-end gap-2 self-end text-sm"></div>
</CardFooter>
</Card>
</Skeleton>
))}
</div>
);
};
export default RecommendationsSkeleton;

View File

@@ -1,30 +0,0 @@
'use server';
import React from 'react';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
import { loadAnalyses } from '../_lib/server/load-analyses';
import { loadRecommendations } from '../_lib/server/load-recommendations';
import OrderAnalysesCards from './order-analyses-cards';
export default async function Recommendations({
account,
}: {
account: AccountWithParams;
}) {
const { analyses, countryCode } = await loadAnalyses();
const analysisRecommendations = await loadRecommendations(analyses, account);
const orderAnalyses = analyses.filter((analysis) =>
analysisRecommendations.includes(analysis.title),
);
if (orderAnalyses.length === 0) {
return null;
}
return (
<OrderAnalysesCards analyses={orderAnalyses} countryCode={countryCode} />
);
}

View File

@@ -1,10 +1,18 @@
'use server';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
import { updateLineItem } from '@lib/data/cart';
import { StoreProductVariant } from '@medusajs/types';
import logRequestResult from '~/lib/services/audit.service';
import { handleAddToCart } from '~/lib/services/medusaCart.service';
import { createInitialReservation } from '~/lib/services/reservation.service';
import {
cancelReservation,
createInitialReservation,
} from '~/lib/services/reservation.service';
import { RequestStatus } from '~/lib/types/audit';
import { ConnectedOnlineMethodName } from '~/lib/types/connected-online';
import { ExternalApi } from '~/lib/types/external';
export async function createInitialReservationAction(
selectedVariant: Pick<StoreProductVariant, 'id'>,
@@ -41,3 +49,63 @@ export async function createInitialReservationAction(
});
}
}
export async function cancelTtoBooking(
bookingCode: string,
clinicId: number,
medusaLineItemId: string,
) {
try {
await fetch(
`${process.env.CONNECTED_ONLINE_URL}/${ConnectedOnlineMethodName.ConfirmedCancel}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
param: `{'Value':'${bookingCode}|${clinicId}|et'}`,
}),
},
);
await cancelReservation(medusaLineItemId);
await logRequestResult(
ExternalApi.ConnectedOnline,
ConnectedOnlineMethodName.ConfirmedCancel,
RequestStatus.Success,
medusaLineItemId,
);
} catch (error) {
await logRequestResult(
ExternalApi.ConnectedOnline,
ConnectedOnlineMethodName.ConfirmedCancel,
RequestStatus.Fail,
JSON.stringify(error),
);
if (error instanceof Error) {
console.error('Error cancelling booking: ' + error.message);
}
console.error('Error cancelling booking: ', error);
}
}
export async function isPaymentRequiredForService(serviceId: number) {
const supabaseClient = getSupabaseServerClient();
try {
const { data } = await supabaseClient
.schema('medreport')
.from('connected_online_services')
.select('requires_payment')
.eq('id', serviceId)
.is('requires_payment', true)
.maybeSingle();
return !!data;
} catch (error) {
console.error('Error checking payment requirement: ', error);
return false;
}
}

View File

@@ -0,0 +1,324 @@
'use server';
import { AccountWithParams } from '@/packages/features/accounts/src/types/accounts';
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';
import OpenAI from 'openai';
import PersonalCode from '~/lib/utils';
import {
AnalysisResponses,
ILifeStyleResponse,
PROMPT_NAME,
} from '../../_components/ai/types';
import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
export async function updateLifeStyle({
account,
analysisResponses,
isDoctorView = false,
aiResponseTimestamp,
}: {
account: AccountWithParams;
analysisResponses?: AnalysisResponses;
isDoctorView?: boolean;
aiResponseTimestamp: string;
}): Promise<ILifeStyleResponse> {
const LIFE_STYLE_PROMPT_ID = process.env.PROMPT_ID_LIFE_STYLE;
if (!LIFE_STYLE_PROMPT_ID || !account?.personal_code) {
return {
lifestyle: [],
summary: null,
};
}
const openAIClient = new OpenAI();
const supabaseClient = getSupabaseServerClient();
const { gender, age } = PersonalCode.parsePersonalCode(account.personal_code);
const weight = account.accountParams?.weight || 'unknown';
const height = account.accountParams?.height || 'unknown';
const isSmoker = !!account.accountParams?.isSmoker;
const cholesterol =
analysisResponses
?.find((ar) => ar.analysis_name_lab === 'Kolesterool')
?.response_value.toString() || 'unknown';
const ldl =
analysisResponses
?.find((ar) => ar.analysis_name_lab === 'LDL kolesterool')
?.response_value.toString() || 'unknown';
const hdl =
analysisResponses
?.find((ar) => ar.analysis_name_lab === 'HDL kolesterool')
?.response_value.toString() || 'unknown';
const vitamind =
analysisResponses
?.find((ar) => ar.analysis_name_lab === 'Vitamiin D (25-OH)')
?.response_value.toString() || 'unknown';
try {
const response = await openAIClient.responses.create({
store: false,
prompt: {
id: LIFE_STYLE_PROMPT_ID,
variables: {
gender: gender.value,
age: age.toString(),
weight: weight.toString(),
height: height.toString(),
cholesterol,
ldl,
hdl,
vitamind,
is_smoker: isSmoker.toString(),
},
},
});
await supabaseClient
.schema('medreport')
.from('ai_responses')
.insert({
account_id: account.id,
prompt_name: PROMPT_NAME.LIFE_STYLE,
prompt_id: LIFE_STYLE_PROMPT_ID,
input: JSON.stringify({
gender: gender.value,
age: age.toString(),
weight: weight.toString(),
cholesterol,
ldl,
hdl,
vitamind,
is_smoker: isSmoker.toString(),
}),
latest_data_change: aiResponseTimestamp,
response: response.output_text,
is_visible_to_customer: !isDoctorView,
});
const json = JSON.parse(response.output_text);
return json;
} catch (error) {
console.error('Error calling OpenAI: ', error);
return {
lifestyle: [],
summary: null,
};
}
}
export async function updateRecommendations({
analyses,
analysisResponses,
account,
aiResponseTimestamp,
}: {
analyses: OrderAnalysisCard[];
analysisResponses?: AnalysisResponses;
account: AccountWithParams;
aiResponseTimestamp: string;
}) {
const RECOMMENDATIONS_PROMPT_IT =
process.env.PROMPT_ID_ANALYSIS_RECOMMENDATIONS;
if (!RECOMMENDATIONS_PROMPT_IT || !account?.personal_code) {
console.error('No prompt ID for analysis recommendations');
return [];
}
const openAIClient = new OpenAI();
const supabaseClient = getSupabaseServerClient();
const { gender, age } = PersonalCode.parsePersonalCode(account.personal_code);
const weight = account.accountParams?.weight || 'unknown';
const formattedAnalysisResponses = analysisResponses?.map(
({
analysis_name_lab,
response_value,
norm_upper,
norm_lower,
norm_status,
}) => ({
name: analysis_name_lab,
value: response_value,
normUpper: norm_upper,
normLower: norm_lower,
normStatus: norm_status,
}),
);
const formattedAnalyses = analyses.map(({ description, title }) => ({
description,
title,
}));
try {
const response = await openAIClient.responses.create({
store: false,
prompt: {
id: RECOMMENDATIONS_PROMPT_IT,
variables: {
analyses: JSON.stringify(formattedAnalyses),
results: JSON.stringify(formattedAnalysisResponses),
gender: gender.value,
age: age.toString(),
weight: weight.toString(),
},
},
});
await supabaseClient
.schema('medreport')
.from('ai_responses')
.insert({
account_id: account.id,
prompt_name: PROMPT_NAME.ANALYSIS_RECOMMENDATIONS,
prompt_id: RECOMMENDATIONS_PROMPT_IT,
input: JSON.stringify({
analyses: formattedAnalyses,
results: formattedAnalysisResponses,
gender,
age,
weight,
}),
latest_data_change: aiResponseTimestamp,
response: response.output_text,
is_visible_to_customer: false,
});
const json = JSON.parse(response.output_text);
return json.recommended;
} catch (error) {
console.error('Error getting recommendations: ', error);
return [];
}
}
export async function generateDoctorFeedback({
patient,
analysisResponses,
aiResponseTimestamp,
}: {
patient: Patient;
analysisResponses: AnalysisResponse[];
aiResponseTimestamp: string;
}): Promise<string> {
const DOCTOR_FEEDBACK_PROMPT_ID = process.env.PROMPT_ID_DOCTOR_FEEDBACK;
if (!DOCTOR_FEEDBACK_PROMPT_ID) {
console.error('No secrets for doctor feedback');
return '';
}
const openAIClient = new OpenAI();
const supabaseClient = getSupabaseServerClient();
const formattedAnalysisResponses = analysisResponses?.map(
({
analysis_name,
response_value,
norm_upper,
norm_lower,
norm_status,
}) => ({
name: analysis_name,
value: response_value,
normUpper: norm_upper,
normLower: norm_lower,
normStatus: norm_status,
}),
);
try {
const response = await openAIClient.responses.create({
store: false,
prompt: {
id: DOCTOR_FEEDBACK_PROMPT_ID,
variables: {
analysesresults: JSON.stringify(formattedAnalysisResponses),
},
},
});
await supabaseClient
.schema('medreport')
.from('ai_responses')
.insert({
account_id: patient.userId,
prompt_name: PROMPT_NAME.FEEDBACK,
prompt_id: DOCTOR_FEEDBACK_PROMPT_ID,
input: JSON.stringify({
analysesresults: formattedAnalysisResponses,
}),
latest_data_change: aiResponseTimestamp,
response: response.output_text,
});
return response.output_text;
} catch (error) {
console.error('Error getting doctor feedback: ', error);
return '';
}
}
export async function confirmPatientAIResponses(
patientId: string,
aiResponseTimestamp: string,
recommendations: string[],
isRecommendationsEdited: boolean,
) {
const logger = await getLogger();
const supabaseClient = getSupabaseServerClient();
const { error } = await supabaseClient
.schema('medreport')
.from('ai_responses')
.update({
is_visible_to_customer: true,
})
.eq('latest_data_change', aiResponseTimestamp)
.eq('account_id', patientId)
.eq('prompt_name', PROMPT_NAME.LIFE_STYLE);
if (error) {
logger.error(
{ error, patientId, aiResponseTimestamp },
'Failed updating life style',
);
}
const { error: _error } = await supabaseClient
.schema('medreport')
.from('ai_responses')
.update({
is_visible_to_customer: true,
...(isRecommendationsEdited && {
response: JSON.stringify({
why: 'This was edited by doctor',
recommended: recommendations,
}),
}),
})
.eq('latest_data_change', aiResponseTimestamp)
.eq('account_id', patientId)
.eq('prompt_name', PROMPT_NAME.ANALYSIS_RECOMMENDATIONS);
if (_error) {
logger.error(
{
error,
aiResponseTimestamp,
patientId,
isRecommendationsEdited,
},
'Failed updating analysis recommendations',
);
}
}

View File

@@ -1,8 +1,13 @@
'use server';
import { AccountBalanceService, AccountBalanceSummary } from '@kit/accounts/services/account-balance.service';
import {
AccountBalanceService,
AccountBalanceSummary,
} from '@kit/accounts/services/account-balance.service';
export async function getAccountBalanceSummary(accountId: string): Promise<AccountBalanceSummary | null> {
export async function getAccountBalanceSummary(
accountId: string,
): Promise<AccountBalanceSummary | null> {
try {
const service = new AccountBalanceService();
return await service.getBalanceSummary(accountId);

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