Compare commits
1 Commits
testing-11
...
MED-122-v2
| Author | SHA1 | Date | |
|---|---|---|---|
| ab0834149d |
42
.env
42
.env
@@ -5,6 +5,7 @@
|
|||||||
# TO OVERRIDE THESE VARIABLES IN A SPECIFIC ENVIRONMENT, PLEASE ADD THEM TO THE SPECIFIC ENVIRONMENT FILE (e.g. .env.development, .env.production)
|
# TO OVERRIDE THESE VARIABLES IN A SPECIFIC ENVIRONMENT, PLEASE ADD THEM TO THE SPECIFIC ENVIRONMENT FILE (e.g. .env.development, .env.production)
|
||||||
|
|
||||||
# SITE
|
# SITE
|
||||||
|
NEXT_PUBLIC_SITE_URL=https://localhost:3000
|
||||||
NEXT_PUBLIC_PRODUCT_NAME=MedReport
|
NEXT_PUBLIC_PRODUCT_NAME=MedReport
|
||||||
NEXT_PUBLIC_SITE_TITLE="MedReport"
|
NEXT_PUBLIC_SITE_TITLE="MedReport"
|
||||||
NEXT_PUBLIC_SITE_DESCRIPTION="MedReport."
|
NEXT_PUBLIC_SITE_DESCRIPTION="MedReport."
|
||||||
@@ -13,12 +14,12 @@ NEXT_PUBLIC_THEME_COLOR="#ffffff"
|
|||||||
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
|
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
|
||||||
|
|
||||||
# AUTH
|
# AUTH
|
||||||
NEXT_PUBLIC_AUTH_PASSWORD=false
|
NEXT_PUBLIC_AUTH_PASSWORD=true
|
||||||
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
|
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
|
||||||
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
|
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
|
||||||
|
|
||||||
# BILLING
|
# BILLING
|
||||||
NEXT_PUBLIC_BILLING_PROVIDER=montonio
|
NEXT_PUBLIC_BILLING_PROVIDER=stripe
|
||||||
|
|
||||||
# CMS
|
# CMS
|
||||||
CMS_CLIENT=keystatic
|
CMS_CLIENT=keystatic
|
||||||
@@ -34,25 +35,13 @@ NEXT_PUBLIC_ENABLE_THEME_TOGGLE=true
|
|||||||
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION=true
|
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION=true
|
||||||
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=false
|
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=false
|
||||||
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=false
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=false
|
||||||
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=false
|
||||||
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
|
||||||
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true
|
||||||
NEXT_PUBLIC_LANGUAGE_PRIORITY=application
|
NEXT_PUBLIC_LANGUAGE_PRIORITY=application
|
||||||
NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true
|
NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true
|
||||||
NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true
|
NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true
|
||||||
|
|
||||||
WEBHOOK_SENDER_PROVIDER=postgres
|
|
||||||
|
|
||||||
# MAILER DEV
|
|
||||||
CONTACT_EMAIL=info@medreport.ee
|
|
||||||
MAILER_PROVIDER=nodemailer
|
|
||||||
EMAIL_SENDER=info@medreport.ee
|
|
||||||
EMAIL_USER= # refer to your email provider's documentation
|
|
||||||
EMAIL_PASSWORD= # refer to your email provider's documentation
|
|
||||||
EMAIL_HOST=localhost # refer to your email provider's documentation
|
|
||||||
EMAIL_PORT=1025 # or 465 for SSL
|
|
||||||
EMAIL_TLS=true
|
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
|
||||||
|
|
||||||
# NEXTJS
|
# NEXTJS
|
||||||
NEXT_TELEMETRY_DISABLED=1
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
@@ -64,10 +53,23 @@ NEXT_PUBLIC_DEFAULT_LOCALE=et
|
|||||||
NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
|
NEXT_PUBLIC_TEAM_NAVIGATION_STYLE=custom
|
||||||
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
|
NEXT_PUBLIC_USER_NAVIGATION_STYLE=custom
|
||||||
|
|
||||||
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
|
#### MEDUSA
|
||||||
|
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_0ec86252438b38ce18d5601f7877e4395d7e0a6afa8687dfea8d37af33015633
|
||||||
|
#MEDUSA_BACKEND_URL=http://5.181.51.38:9000
|
||||||
|
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_e23a820689a07d55aa0a0ad187268559f5d6288ecb0768ff4520516285bdef84
|
||||||
|
MEDUSA_BACKEND_URL=http://localhost:9000
|
||||||
|
# NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_068d930c33fea53608a410d84a51935f6ce2ccec5bef8e0ecf75eaee602ac486
|
||||||
|
# MEDUSA_BACKEND_URL=https://backoffice-test.medreport.ee:443
|
||||||
|
|
||||||
# Configure Medusa password secret for Keycloak users
|
#### MONTONIO
|
||||||
MEDUSA_PASSWORD_SECRET=ODEwMGNiMmUtOGMxYS0xMWYwLWJlZDYtYTM3YzYyMWY0NGEzCg==
|
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
||||||
|
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
|
||||||
|
MONTONIO_API_URL=https://sandbox-stargate.montonio.com
|
||||||
|
|
||||||
# False by default
|
#### SUPABASE
|
||||||
MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false
|
# NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
|
||||||
|
# NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
|
||||||
|
# SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NjUyODEyMywiZXhwIjoyMDYyMTA0MTIzfQ.KVcnkZ21Pd0XkJho23dZqFHawVTLQqfvF7l2RxsELLk
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=http://5.181.51.38:54321
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
|
||||||
|
|||||||
@@ -1,61 +1,27 @@
|
|||||||
# This file is used to define environment variables for the development environment.
|
# This file is used to define environment variables for the development environment.
|
||||||
# These values are only used when running the app in development mode.
|
# These values are only used when running the app in development mode.
|
||||||
|
|
||||||
# SITE
|
# SUPABASE
|
||||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
NEXT_PUBLIC_SUPABASE_URL=http://5.181.51.38:54321
|
||||||
NEXT_PUBLIC_AUTH_PASSWORD=true
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
|
||||||
# SUPABASE DEVELOPMENT
|
|
||||||
|
|
||||||
## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION
|
## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION
|
||||||
SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET
|
SUPABASE_DB_WEBHOOK_SECRET=WEBHOOKSECRET
|
||||||
|
|
||||||
# MEDUSA MONTONIO URLS FOR LOCALHOST
|
|
||||||
# Montonio doesn't allow localhost as notification/callback URL
|
|
||||||
DEV_MONTONIO_CALLBACK_URL=http://webhook.site:3000
|
|
||||||
|
|
||||||
# EMAILS
|
# EMAILS
|
||||||
|
|
||||||
# CONTACT FORM
|
# CONTACT FORM
|
||||||
CONTACT_EMAIL=test@makerkit.dev
|
CONTACT_EMAIL=test@makerkit.dev
|
||||||
|
|
||||||
|
# STRIPE
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||||
|
|
||||||
# MAILER
|
# MAILER
|
||||||
MAILER_PROVIDER=nodemailer
|
MAILER_PROVIDER=nodemailer
|
||||||
EMAIL_SENDER=info@medreport.ee
|
# EMAIL_SENDER=
|
||||||
EMAIL_USER= # refer to your email provider's documentation
|
# EMAIL_USER= # refer to your email provider's documentation
|
||||||
EMAIL_PASSWORD= # refer to your email provider's documentation
|
# EMAIL_PASSWORD= # refer to your email provider's documentation
|
||||||
EMAIL_HOST=localhost # refer to your email provider's documentation
|
# EMAIL_HOST= # refer to your email provider's documentation
|
||||||
EMAIL_PORT=1025 # or 465 for SSL
|
# EMAIL_PORT= # or 465 for SSL
|
||||||
EMAIL_TLS=false
|
# EMAIL_TLS= # or false for SSL (see provider documentation)
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
|
||||||
|
|
||||||
# MEDIPOST
|
|
||||||
|
|
||||||
MEDIPOST_URL=https://meditest.medisoft.ee:7443/Medipost/MedipostServlet
|
|
||||||
MEDIPOST_USER=trvurgtst
|
|
||||||
MEDIPOST_PASSWORD=SRB48HZMV
|
|
||||||
MEDIPOST_RECIPIENT=trvurgtst
|
|
||||||
MEDIPOST_MESSAGE_SENDER=trvurgtst
|
|
||||||
MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=true
|
|
||||||
MEDIPOST_ENABLE_DELETE_RESPONSE_PRIVATE_MESSAGE_ON_READ=false
|
|
||||||
|
|
||||||
#MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet
|
|
||||||
#MEDIPOST_USER=medreport
|
|
||||||
#MEDIPOST_PASSWORD=
|
|
||||||
#MEDIPOST_RECIPIENT=HTI
|
|
||||||
#MEDIPOST_MESSAGE_SENDER=medreport
|
|
||||||
#MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false
|
|
||||||
|
|
||||||
# MEDUSA
|
|
||||||
COMPANY_BENEFITS_PAYMENT_SECRET_KEY=NzcwMzE2NmEtOThiMS0xMWYwLWI4NjYtMDMwZDQzMjFhMjExCg==
|
|
||||||
MEDUSA_BACKEND_URL=http://localhost:9000
|
|
||||||
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
|
|
||||||
|
|
||||||
# MONTONIO
|
|
||||||
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
|
||||||
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
|
|
||||||
MONTONIO_API_URL=https://sandbox-stargate.montonio.com
|
|
||||||
|
|
||||||
# JOBS
|
|
||||||
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
# https://app.supabase.com/project/_/settings/api
|
# https://app.supabase.com/project/_/settings/api
|
||||||
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||||
|
|
||||||
MEDIPOST_URL=your-medipost-url
|
MEDIPOST_URL=your-medipost-url
|
||||||
MEDIPOST_USER=your-medipost-user
|
MEDIPOST_USER=your-medipost-user
|
||||||
@@ -10,7 +10,6 @@ MEDIPOST_PASSWORD=your-medipost-password
|
|||||||
MEDIPOST_RECIPIENT=your-medipost-recipient
|
MEDIPOST_RECIPIENT=your-medipost-recipient
|
||||||
|
|
||||||
CONNECTED_ONLINE_URL=your-connected-online-url
|
CONNECTED_ONLINE_URL=your-connected-online-url
|
||||||
CONNECTED_ONLINE_CONFIRMED_URL=your-connected-confirmed-url
|
|
||||||
|
|
||||||
EMAIL_SENDER=
|
EMAIL_SENDER=
|
||||||
EMAIL_USER= # refer to your email provider's documentation
|
EMAIL_USER= # refer to your email provider's documentation
|
||||||
@@ -20,10 +19,7 @@ EMAIL_PORT= # or 465 for SSL
|
|||||||
EMAIL_TLS= # or false for SSL (see provider documentation)
|
EMAIL_TLS= # or false for SSL (see provider documentation)
|
||||||
|
|
||||||
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
|
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
|
||||||
MEDUSA_SECRET_API_KEY=
|
|
||||||
|
|
||||||
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
||||||
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
|
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
|
||||||
MONTONIO_API_URL=https://sandbox-stargate.montonio.com
|
MONTONIO_API_URL=https://sandbox-stargate.montonio.com
|
||||||
|
|
||||||
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197
|
|
||||||
|
|||||||
@@ -6,10 +6,7 @@
|
|||||||
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
|
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
|
||||||
|
|
||||||
# SUPABASE
|
# SUPABASE
|
||||||
# NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
# NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
|
|
||||||
|
|
||||||
# NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
|
# STRIPE
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||||
# # MONTONIO
|
|
||||||
# NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
|
||||||
|
|||||||
15
.env.staging
15
.env.staging
@@ -1,15 +0,0 @@
|
|||||||
# PRODUCTION ENVIRONMENT VARIABLES
|
|
||||||
|
|
||||||
## DO NOT ADD VARS HERE UNLESS THEY ARE PUBLIC OR NOT SENSITIVE
|
|
||||||
## THIS ENV IS USED FOR PRODUCTION AND IS COMMITED TO THE REPO
|
|
||||||
## AVOID PLACING SENSITIVE DATA IN THIS FILE.
|
|
||||||
## PUBLIC KEYS OR CONFIGURATION ARE OKAY TO BE PLACED HERE.
|
|
||||||
|
|
||||||
# SUPABASE
|
|
||||||
# NEXT_PUBLIC_SUPABASE_URL=https://klocrucggryikaxzvxgc.supabase.co
|
|
||||||
# NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTY5ODQ2MjgsImV4cCI6MjA3MjU2MDYyOH0.2XOQngowcymiSUZO_XEEWAWzco2uRIjwG7TAeRRLIdU
|
|
||||||
|
|
||||||
# NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
|
|
||||||
|
|
||||||
# # MONTONIO
|
|
||||||
# NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,4 +43,3 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
dump.sql
|
dump.sql
|
||||||
data.sql
|
|
||||||
1
.npmrc
1
.npmrc
@@ -3,7 +3,6 @@ dedupe-peer-dependents=true
|
|||||||
use-lockfile-v6=true
|
use-lockfile-v6=true
|
||||||
resolution-mode=highest
|
resolution-mode=highest
|
||||||
package-manager-strict=true
|
package-manager-strict=true
|
||||||
ignore-workspace-root-check=true
|
|
||||||
public-hoist-pattern[]=*i18next*
|
public-hoist-pattern[]=*i18next*
|
||||||
public-hoist-pattern[]=*eslint*
|
public-hoist-pattern[]=*eslint*
|
||||||
public-hoist-pattern[]=*prettier*
|
public-hoist-pattern[]=*prettier*
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
database.types.ts
|
database.types.ts
|
||||||
playwright-report
|
playwright-report
|
||||||
*.hbs
|
*.hbs
|
||||||
.history
|
|
||||||
node_modules
|
|
||||||
44
Dockerfile
44
Dockerfile
@@ -1,55 +1,17 @@
|
|||||||
# --- Stage 1: Build ---
|
|
||||||
FROM node:20-alpine as builder
|
FROM node:20-alpine as builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm install -g pnpm@9
|
RUN npm install -g pnpm@9
|
||||||
RUN npm install -g dotenv-cli
|
|
||||||
|
|
||||||
|
# Copy necessary files for workspace resolution
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
COPY packages packages
|
COPY packages packages
|
||||||
COPY tooling tooling
|
COPY tooling tooling
|
||||||
COPY .env .env
|
|
||||||
COPY .env.production .env.production
|
|
||||||
COPY .env.staging .env.staging
|
|
||||||
|
|
||||||
# Load env file and echo a specific variable
|
# Install all dependencies
|
||||||
# RUN dotenv -e .env -- printenv | grep 'SUPABASE' || true
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
RUN pnpm build
|
||||||
|
|
||||||
RUN set -a \
|
|
||||||
&& . .env \
|
|
||||||
&& . .env.production \
|
|
||||||
&& . .env.staging \
|
|
||||||
&& set +a \
|
|
||||||
&& node check-env.js \
|
|
||||||
&& pnpm build
|
|
||||||
|
|
||||||
|
|
||||||
# --- Stage 2: Runtime ---
|
|
||||||
FROM node:20-alpine
|
|
||||||
ARG APP_ENV=production
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=builder /app ./
|
|
||||||
|
|
||||||
RUN cp ".env.${APP_ENV}" .env.local
|
|
||||||
|
|
||||||
RUN npm install -g pnpm@9 \
|
|
||||||
&& pnpm install --prod --frozen-lockfile
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
|
|
||||||
# 🔍 Optional: Log key envs for debug
|
|
||||||
RUN echo "📄 .env contents:" && cat .env.local \
|
|
||||||
&& echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true
|
|
||||||
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["pnpm", "start"]
|
|
||||||
67
README.md
67
README.md
@@ -47,18 +47,6 @@ if you get missing dependency error do `pnpm i --force`
|
|||||||
pnpm add <pacakge-name> -w
|
pnpm add <pacakge-name> -w
|
||||||
```
|
```
|
||||||
|
|
||||||
## Eslint and prettier
|
|
||||||
|
|
||||||
To clean code run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run lint:fix
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run prettier:fix
|
|
||||||
```
|
|
||||||
|
|
||||||
## Supabase
|
## Supabase
|
||||||
|
|
||||||
Start supabase in docker
|
Start supabase in docker
|
||||||
@@ -97,58 +85,5 @@ To access admin pages follow these steps:
|
|||||||
|
|
||||||
- Register new user
|
- Register new user
|
||||||
- Go to Profile and add Multi-Factor Authentication
|
- Go to Profile and add Multi-Factor Authentication
|
||||||
- Authenticate with mfa (at current time profile page prompts it again)
|
|
||||||
- update your `account.application_role` to `super_admin`.
|
|
||||||
- Sign out and Sign in
|
- Sign out and Sign in
|
||||||
|
- Authenticate with mfa (at current time profile page prompts it again)
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Start email server
|
|
||||||
|
|
||||||
`docker run -p 1080:1080 -p 1025:1025 maildev/maildev`
|
|
||||||
|
|
||||||
- [View emails](http://localhost:1080/#/)
|
|
||||||
- Mail server is running on `localhost:1025` without password
|
|
||||||
|
|
||||||
## Medipost flow
|
|
||||||
|
|
||||||
1. Customer adds analysis to cart in **B2B** storefront
|
|
||||||
2. Customer checks out from cart and is redirected to **Montonio**
|
|
||||||
3. Customer pays and is redirected back to **B2B** `GET B2B/home/cart/montonio-callback?order-token=$JWT`
|
|
||||||
|
|
||||||
- **Medusa** order is created and cart is emptied
|
|
||||||
- email is sent to customer
|
|
||||||
- B2B sends order XML as private message to Medipost.
|
|
||||||
|
|
||||||
When **Montonio** has confirmed payment, it will call **Medusa** webhook endpoint and **Medusa** will mark order payment as captured.
|
|
||||||
|
|
||||||
In background a job will call `POST B2B/api/job/sync-analysis-results` every n minutes and sync private messages with responses from **Medipost**.
|
|
||||||
|
|
||||||
In local dev environment, you can create a private message with analysis responses in **Medipost** system for a submitted order:
|
|
||||||
`POST B2B/api/order/medipost-test-response body={medusaOrderId:'input here'}`
|
|
||||||
After that run `POST /api/job/sync-analysis-results` and analysis results should be synced.
|
|
||||||
|
|
||||||
In local dev environment, you can import products from B2B to Medusa with this API:
|
|
||||||
|
|
||||||
- `POST /api/job/sync-analysis-groups-store`
|
|
||||||
- Syncs required data of `analyses`, `analysis_elements` data from **B2B** to **Medusa** and creates relevant products and categories.
|
|
||||||
If product or category already exists, then it is not recreated. Old entries are not deleted either currently.
|
|
||||||
|
|
||||||
## Jobs
|
|
||||||
|
|
||||||
Required headers:
|
|
||||||
|
|
||||||
- `x-jobs-api-key` in UUID format
|
|
||||||
|
|
||||||
Endpoints:
|
|
||||||
|
|
||||||
- `POST /api/job/sync-analysis-groups`
|
|
||||||
- Queries **Medipost** for public messages list and takes analysis info from the latest event.
|
|
||||||
Updates `analyses` and `analysis_elements` lists in **B2B**.
|
|
||||||
- `POST /api/job/sync-analysis-results`
|
|
||||||
- Queries **Medipost** for latest private message that has a response and updates order analysis results from lab results data.
|
|
||||||
- `POST /api/job/sync-connected-online`
|
|
||||||
- TODO
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
// Mock for isikukood library to avoid ES module issues in tests
|
|
||||||
|
|
||||||
export enum Gender {
|
|
||||||
MALE = 'male',
|
|
||||||
FEMALE = 'female',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class Isikukood {
|
|
||||||
private code: string;
|
|
||||||
|
|
||||||
constructor(code: string) {
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
static validate(code: string): boolean {
|
|
||||||
return true; // Mock always returns true for tests
|
|
||||||
}
|
|
||||||
|
|
||||||
static generate(options?: { gender?: Gender; century?: number }): string {
|
|
||||||
return '39001010002'; // Mock Estonian ID code
|
|
||||||
}
|
|
||||||
|
|
||||||
isValid(): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
getGender(): Gender {
|
|
||||||
return Gender.MALE;
|
|
||||||
}
|
|
||||||
|
|
||||||
getBirthDate(): Date {
|
|
||||||
return new Date('1990-01-01');
|
|
||||||
}
|
|
||||||
|
|
||||||
getAge(): number {
|
|
||||||
return 30;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCentury(): number {
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Isikukood };
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Mock for server-only to avoid Next.js server component issues in tests
|
|
||||||
// This module does nothing in tests - it's just a marker for Next.js
|
|
||||||
export {};
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { AppLogo } from '@kit/shared/components/app-logo';
|
|
||||||
import { appConfig } from '@kit/shared/config';
|
|
||||||
import { Footer } from '@kit/ui/marketing';
|
import { Footer } from '@kit/ui/marketing';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
import appConfig from '~/config/app.config';
|
||||||
|
|
||||||
export function SiteFooter() {
|
export function SiteFooter() {
|
||||||
return (
|
return (
|
||||||
<Footer
|
<Footer
|
||||||
logo={<AppLogo className="w-[85px] md:w-[95px]" />}
|
logo={<AppLogo className="w-[85px] md:w-[95px]" />}
|
||||||
description=""
|
description={<Trans i18nKey="marketing:footerDescription" />}
|
||||||
copyright={
|
copyright={
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="marketing:copyright"
|
i18nKey="marketing:copyright"
|
||||||
@@ -18,6 +19,22 @@ export function SiteFooter() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
sections={[
|
sections={[
|
||||||
|
{
|
||||||
|
heading: <Trans i18nKey="marketing:about" />,
|
||||||
|
links: [
|
||||||
|
{ href: '/blog', label: <Trans i18nKey="marketing:blog" /> },
|
||||||
|
{ href: '/contact', label: <Trans i18nKey="marketing:contact" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: <Trans i18nKey="marketing:product" />,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
href: '/docs',
|
||||||
|
label: <Trans i18nKey="marketing:documentation" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
heading: <Trans i18nKey="marketing:legal" />,
|
heading: <Trans i18nKey="marketing:legal" />,
|
||||||
links: [
|
links: [
|
||||||
|
|||||||
@@ -3,18 +3,18 @@
|
|||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
|
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
|
||||||
import { featureFlagsConfig, pathsConfig } from '@kit/shared/config';
|
|
||||||
import { useAuthConfig } from '@kit/shared/hooks';
|
|
||||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { If } from '@kit/ui/if';
|
import { If } from '@kit/ui/if';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
|
||||||
const ModeToggle = dynamic(() =>
|
const ModeToggle = dynamic(() =>
|
||||||
import('@kit/ui/mode-toggle').then((mod) => ({
|
import('@kit/ui/mode-toggle').then((mod) => ({
|
||||||
default: mod.ModeToggle,
|
default: mod.ModeToggle,
|
||||||
@@ -23,20 +23,13 @@ const ModeToggle = dynamic(() =>
|
|||||||
|
|
||||||
const paths = {
|
const paths = {
|
||||||
home: pathsConfig.app.home,
|
home: pathsConfig.app.home,
|
||||||
admin: pathsConfig.app.admin,
|
|
||||||
doctor: pathsConfig.app.doctor,
|
|
||||||
personalAccountSettings: pathsConfig.app.personalAccountSettings,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const features = {
|
const features = {
|
||||||
enableThemeToggle: featureFlagsConfig.enableThemeToggle,
|
enableThemeToggle: featuresFlagConfig.enableThemeToggle,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SiteHeaderAccountSection({
|
export function SiteHeaderAccountSection() {
|
||||||
accounts,
|
|
||||||
}: {
|
|
||||||
accounts: UserWorkspace['accounts'];
|
|
||||||
}) {
|
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const signOut = useSignOut();
|
const signOut = useSignOut();
|
||||||
|
|
||||||
@@ -48,7 +41,6 @@ export function SiteHeaderAccountSection({
|
|||||||
features={features}
|
features={features}
|
||||||
user={session.data.user}
|
user={session.data.user}
|
||||||
signOutRequested={() => signOut.mutateAsync()}
|
signOutRequested={() => signOut.mutateAsync()}
|
||||||
accounts={accounts}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -57,8 +49,6 @@ export function SiteHeaderAccountSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AuthButtons() {
|
function AuthButtons() {
|
||||||
const { config } = useAuthConfig();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'animate-in fade-in flex gap-x-2.5 duration-500'}>
|
<div className={'animate-in fade-in flex gap-x-2.5 duration-500'}>
|
||||||
<div className={'hidden md:flex'}>
|
<div className={'hidden md:flex'}>
|
||||||
@@ -67,25 +57,19 @@ function AuthButtons() {
|
|||||||
</If>
|
</If>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config && (
|
<div className={'flex gap-x-2.5'}>
|
||||||
<div className={'flex gap-x-2.5'}>
|
<Button className={'hidden md:block'} asChild variant={'ghost'}>
|
||||||
{(config.providers.password || config.providers.oAuth.length > 0) && (
|
<Link href={pathsConfig.auth.signIn}>
|
||||||
<Button className={'block'} asChild variant={'ghost'}>
|
<Trans i18nKey={'auth:signIn'} />
|
||||||
<Link href={pathsConfig.auth.signIn}>
|
</Link>
|
||||||
<Trans i18nKey={'auth:signIn'} />
|
</Button>
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{config.providers.password && (
|
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
||||||
<Button asChild className="text-xs md:text-sm" variant={'default'}>
|
<Link href={pathsConfig.auth.signUp}>
|
||||||
<Link href={pathsConfig.auth.signUp}>
|
<Trans i18nKey={'auth:signUp'} />
|
||||||
<Trans i18nKey={'auth:signUp'} />
|
</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
|
|
||||||
|
|
||||||
import { AppLogo } from '@kit/shared/components/app-logo';
|
|
||||||
import { Header } from '@kit/ui/marketing';
|
import { Header } from '@kit/ui/marketing';
|
||||||
|
|
||||||
import { SiteHeaderAccountSection } from './site-header-account-section';
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
|
||||||
export function SiteHeader({
|
import { SiteHeaderAccountSection } from './site-header-account-section';
|
||||||
accounts,
|
import { SiteNavigation } from './site-navigation';
|
||||||
}: {
|
|
||||||
accounts: UserWorkspace['accounts'];
|
export function SiteHeader() {
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Header
|
<Header
|
||||||
logo={<AppLogo />}
|
logo={<AppLogo />}
|
||||||
actions={<SiteHeaderAccountSection accounts={accounts} />}
|
navigation={<SiteNavigation />}
|
||||||
|
actions={<SiteHeaderAccountSection />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ const links = {
|
|||||||
label: 'marketing:documentation',
|
label: 'marketing:documentation',
|
||||||
path: '/docs',
|
path: '/docs',
|
||||||
},
|
},
|
||||||
|
Pricing: {
|
||||||
|
label: 'marketing:pricing',
|
||||||
|
path: '/pricing',
|
||||||
|
},
|
||||||
FAQ: {
|
FAQ: {
|
||||||
label: 'marketing:faq',
|
label: 'marketing:faq',
|
||||||
path: '/faq',
|
path: '/faq',
|
||||||
|
|||||||
@@ -9,18 +9,18 @@ import { ContactEmailSchema } from '../contact-email.schema';
|
|||||||
|
|
||||||
const contactEmail = z
|
const contactEmail = z
|
||||||
.string({
|
.string({
|
||||||
error:
|
description: `The email where you want to receive the contact form submissions.`,
|
||||||
|
required_error:
|
||||||
'Contact email is required. Please use the environment variable CONTACT_EMAIL.',
|
'Contact email is required. Please use the environment variable CONTACT_EMAIL.',
|
||||||
})
|
})
|
||||||
.describe(`The email where you want to receive the contact form submissions.`)
|
|
||||||
.parse(process.env.CONTACT_EMAIL);
|
.parse(process.env.CONTACT_EMAIL);
|
||||||
|
|
||||||
const emailFrom = z
|
const emailFrom = z
|
||||||
.string({
|
.string({
|
||||||
error:
|
description: `The email sending address.`,
|
||||||
|
required_error:
|
||||||
'Sender email is required. Please use the environment variable EMAIL_SENDER.',
|
'Sender email is required. Please use the environment variable EMAIL_SENDER.',
|
||||||
})
|
})
|
||||||
.describe(`The email sending address.`)
|
|
||||||
.parse(process.env.EMAIL_SENDER);
|
.parse(process.env.EMAIL_SENDER);
|
||||||
|
|
||||||
export const sendContactEmail = enhanceAction(
|
export const sendContactEmail = enhanceAction(
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { use } from 'react';
|
|
||||||
|
|
||||||
import { SiteFooter } from '~/(marketing)/_components/site-footer';
|
import { SiteFooter } from '~/(marketing)/_components/site-footer';
|
||||||
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
||||||
import { loadCurrentUserAccounts } from '~/home/(user)/_lib/server/load-accounts';
|
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
function SiteLayout(props: React.PropsWithChildren) {
|
function SiteLayout(props: React.PropsWithChildren) {
|
||||||
const accounts = use(loadCurrentUserAccounts());
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex min-h-[100vh] flex-col'}>
|
<div className={'flex min-h-[100vh] flex-col'}>
|
||||||
<SiteHeader accounts={accounts} />
|
<SiteHeader />
|
||||||
|
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { MedReportLogo } from '@/components/med-report-logo';
|
||||||
import { ArrowRightIcon } from 'lucide-react';
|
import { ArrowRightIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
|
||||||
import { CtaButton, Hero } from '@kit/ui/marketing';
|
import { CtaButton, Hero } from '@kit/ui/marketing';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
@@ -33,7 +32,7 @@ function MainCallToActionButton() {
|
|||||||
return (
|
return (
|
||||||
<div className={'flex space-x-4'}>
|
<div className={'flex space-x-4'}>
|
||||||
<CtaButton>
|
<CtaButton>
|
||||||
<Link href={pathsConfig.auth.signUp}>
|
<Link href={'/auth/sign-up'}>
|
||||||
<span className={'flex items-center space-x-0.5'}>
|
<span className={'flex items-center space-x-0.5'}>
|
||||||
<span>
|
<span>
|
||||||
<Trans i18nKey={'common:getStarted'} />
|
<Trans i18nKey={'common:getStarted'} />
|
||||||
@@ -50,8 +49,8 @@ function MainCallToActionButton() {
|
|||||||
</CtaButton>
|
</CtaButton>
|
||||||
|
|
||||||
<CtaButton variant={'link'}>
|
<CtaButton variant={'link'}>
|
||||||
<Link href="/company-offer">
|
<Link href={'/company-offer'}>
|
||||||
<Trans i18nKey="account:createCompanyAccount" />
|
<Trans i18nKey={'account:createCompanyAccount'} />
|
||||||
</Link>
|
</Link>
|
||||||
</CtaButton>
|
</CtaButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { sendEmailFromTemplate } from '@/lib/services/mailer.service';
|
import { SubmitButton } from '@/components/ui/submit-button';
|
||||||
|
import { sendCompanyOfferEmail } from '@/lib/services/mailer.service';
|
||||||
import { CompanySubmitData } from '@/lib/types/company';
|
import { CompanySubmitData } from '@/lib/types/company';
|
||||||
import { companyOfferSchema } from '@/lib/validations/company-offer.schema';
|
import { companyOfferSchema } from '@/lib/validations/company-offer.schema';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { renderCompanyOfferEmail } from '@kit/email-templates';
|
|
||||||
import { SubmitButton } from '@kit/shared/components/ui/submit-button';
|
|
||||||
import { FormItem } from '@kit/ui/form';
|
import { FormItem } from '@kit/ui/form';
|
||||||
import { Input } from '@kit/ui/input';
|
import { Input } from '@kit/ui/input';
|
||||||
import { Label } from '@kit/ui/label';
|
import { Label } from '@kit/ui/label';
|
||||||
import { Spinner } from '@kit/ui/spinner';
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { sendCompanyOfferEmail } from '../_lib/server/company-offer-actions';
|
|
||||||
|
|
||||||
const CompanyOfferForm = () => {
|
const CompanyOfferForm = () => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const language = useTranslation().i18n.language;
|
const language = useTranslation().i18n.language;
|
||||||
const {
|
const {
|
||||||
@@ -35,39 +28,16 @@ const CompanyOfferForm = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (data: CompanySubmitData) => {
|
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();
|
const formData = new FormData();
|
||||||
Object.entries(data).forEach(([key, value]) => {
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
if (value !== undefined) formData.append(key, value);
|
if (value !== undefined) formData.append(key, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sendEmailFromTemplate(
|
sendCompanyOfferEmail(data, language)
|
||||||
renderCompanyOfferEmail,
|
|
||||||
{
|
|
||||||
companyData: data,
|
|
||||||
language,
|
|
||||||
},
|
|
||||||
process.env.CONTACT_EMAIL!,
|
|
||||||
)
|
|
||||||
.then(() => router.push('/company-offer/success'))
|
.then(() => router.push('/company-offer/success'))
|
||||||
.catch((error) => {
|
.catch((error) => alert('error: ' + error));
|
||||||
setIsLoading(false);
|
|
||||||
alert('Could not send email at this time');
|
|
||||||
console.warn('error: ' + error);
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setIsLoading(false);
|
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
console.warn('Server validation error: ' + err.message);
|
console.warn('Server validation error: ' + err.message);
|
||||||
}
|
}
|
||||||
@@ -106,15 +76,11 @@ const CompanyOfferForm = () => {
|
|||||||
<Input type="tel" {...register('phone')} />
|
<Input type="tel" {...register('phone')} />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
disabled={isLoading || !isValid || isSubmitting}
|
disabled={!isValid || isSubmitting}
|
||||||
pendingText="Saatmine..."
|
pendingText="Saatmine..."
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
<Trans i18nKey={'account:requestCompanyAccount:button'} />
|
||||||
<Spinner />
|
|
||||||
) : (
|
|
||||||
<Trans i18nKey={'account:requestCompanyAccount:button'} />
|
|
||||||
)}
|
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
'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!,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
import { MedReportLogo } from '@/components/med-report-logo';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { withI18n } from '@/lib/i18n/with-i18n';
|
||||||
|
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import CompanyOfferForm from './_components/company-offer-form';
|
import CompanyOfferForm from './_components/company-offer-form';
|
||||||
|
|
||||||
function CompanyOffer() {
|
function CompanyOffer() {
|
||||||
return (
|
return (
|
||||||
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
|
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
|
||||||
<div className="flex flex-col px-12 py-14 text-center md:w-1/2">
|
<div className="flex w-1/2 flex-col px-12 py-14 text-center">
|
||||||
<MedReportLogo />
|
<MedReportLogo />
|
||||||
<h1 className="pt-8">
|
<h1 className="pt-8">
|
||||||
<Trans i18nKey={'account:requestCompanyAccount:title'} />
|
<Trans i18nKey={'account:requestCompanyAccount:title'} />
|
||||||
@@ -20,7 +20,7 @@ function CompanyOffer() {
|
|||||||
</p>
|
</p>
|
||||||
<CompanyOfferForm />
|
<CompanyOfferForm />
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat md:block"></div>
|
<div className="w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { withI18n } from '~/lib/i18n/with-i18n';
|
|||||||
|
|
||||||
function SiteLayout(props: React.PropsWithChildren) {
|
function SiteLayout(props: React.PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}>
|
<div className={'flex min-h-[100vh] flex-col justify-center items-center'}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { SIDEBAR_WIDTH_PROPERTY } from '@/packages/ui/src/shadcn/constants';
|
|
||||||
|
|
||||||
import { AppLogo } from '@kit/shared/components/app-logo';
|
|
||||||
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
|
|
||||||
|
|
||||||
import type { UserWorkspace } from '../../home/(user)/_lib/server/load-user-workspace';
|
|
||||||
|
|
||||||
export function AdminMenuNavigation(props: { workspace: UserWorkspace }) {
|
|
||||||
const { accounts } = props.workspace;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={'flex w-full flex-1 items-center justify-between gap-3'}>
|
|
||||||
<div className={`flex items-center ${SIDEBAR_WIDTH_PROPERTY}`}>
|
|
||||||
<AppLogo href={'/admin'} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3">
|
|
||||||
<div>
|
|
||||||
<ProfileAccountDropdownContainer accounts={accounts} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,69 @@
|
|||||||
import { adminNavigationConfig } from '@kit/shared/config';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { LayoutDashboard, Users } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarNavigation,
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
useSidebar,
|
||||||
} from '@kit/ui/shadcn-sidebar';
|
} from '@kit/ui/shadcn-sidebar';
|
||||||
|
|
||||||
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
|
||||||
|
|
||||||
export function AdminSidebar() {
|
export function AdminSidebar() {
|
||||||
const collapsible = adminNavigationConfig.sidebarCollapsedStyle;
|
const path = usePathname();
|
||||||
|
const { open } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible={collapsible}>
|
<Sidebar collapsible="icon">
|
||||||
<SidebarHeader className="h-24 justify-center">
|
<SidebarHeader className={'m-2'}>
|
||||||
<div className="mt-24 flex items-center">
|
<AppLogo href={'/admin'} className="max-w-full" compact={!open} />
|
||||||
<h5>Superadmin</h5>
|
|
||||||
</div>
|
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarNavigation config={adminNavigationConfig} />
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Super Admin</SidebarGroupLabel>
|
||||||
|
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuButton isActive={path === '/admin'} asChild>
|
||||||
|
<Link className={'flex gap-2.5'} href={'/admin'}>
|
||||||
|
<LayoutDashboard className={'h-4'} />
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={path.includes('/admin/accounts')}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className={'flex size-full gap-2.5'}
|
||||||
|
href={'/admin/accounts'}
|
||||||
|
>
|
||||||
|
<Users className={'h-4'} />
|
||||||
|
<span>Accounts</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter>
|
||||||
|
<ProfileAccountDropdownContainer />
|
||||||
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,7 @@ import { cache } from 'react';
|
|||||||
|
|
||||||
import { AdminAccountPage } from '@kit/admin/components/admin-account-page';
|
import { AdminAccountPage } from '@kit/admin/components/admin-account-page';
|
||||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
|
||||||
import { getAccount } from '~/lib/services/account.service';
|
|
||||||
|
|
||||||
interface Params {
|
interface Params {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -28,6 +26,23 @@ async function AccountPage(props: Params) {
|
|||||||
return <AdminAccountPage account={account} />;
|
return <AdminAccountPage account={account} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n(AdminGuard(AccountPage));
|
export default AdminGuard(AccountPage);
|
||||||
|
|
||||||
const loadAccount = cache(getAccount);
|
const loadAccount = cache(accountLoader);
|
||||||
|
|
||||||
|
async function accountLoader(id: string) {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
|
const { data, error } = await client
|
||||||
|
.schema('medreport')
|
||||||
|
.from('accounts')
|
||||||
|
.select('*, memberships: accounts_memberships (*)')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs';
|
import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs';
|
||||||
|
|
||||||
import { AdminAccountsTable } from '@kit/admin/components/admin-accounts-table';
|
import { AdminAccountsTable } from '@kit/admin/components/admin-accounts-table';
|
||||||
import { AdminCreateCompanyDialog } from '@kit/admin/components/admin-create-company-dialog';
|
|
||||||
import { AdminCreateUserDialog } from '@kit/admin/components/admin-create-user-dialog';
|
import { AdminCreateUserDialog } from '@kit/admin/components/admin-create-user-dialog';
|
||||||
|
import { AdminCreateCompanyDialog } from '@kit/admin/components/admin-create-company-dialog';
|
||||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
|
||||||
|
|
||||||
interface SearchParams {
|
interface SearchParams {
|
||||||
page?: string;
|
page?: string;
|
||||||
account_type?: 'all' | 'team' | 'personal';
|
account_type?: 'all' | 'team' | 'personal';
|
||||||
@@ -26,28 +24,27 @@ export const metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function AccountsPage(props: AdminAccountsPageProps) {
|
async function AccountsPage(props: AdminAccountsPageProps) {
|
||||||
const client = getSupabaseServerClient().schema('medreport');
|
const client = getSupabaseServerClient();
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const page = searchParams.page ? parseInt(searchParams.page) : 1;
|
const page = searchParams.page ? parseInt(searchParams.page) : 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader description={<AppBreadcrumbs />} title="Accounts">
|
<PageHeader description={<AppBreadcrumbs />}>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<AdminCreateUserDialog>
|
<AdminCreateUserDialog>
|
||||||
<Button data-test="admin-create-user-button">
|
<Button data-test="admin-create-user-button">Create Personal Account</Button>
|
||||||
Create Personal Account
|
|
||||||
</Button>
|
|
||||||
</AdminCreateUserDialog>
|
</AdminCreateUserDialog>
|
||||||
<AdminCreateCompanyDialog>
|
<AdminCreateCompanyDialog>
|
||||||
<Button>Create Company Account</Button>
|
<Button>Create Company Account</Button>
|
||||||
</AdminCreateCompanyDialog>
|
</AdminCreateCompanyDialog>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<ServerDataLoader
|
<ServerDataLoader
|
||||||
table="accounts"
|
table={'accounts'}
|
||||||
client={client}
|
client={client}
|
||||||
page={page}
|
page={page}
|
||||||
where={(queryBuilder) => {
|
where={(queryBuilder) => {
|
||||||
@@ -58,9 +55,7 @@ async function AccountsPage(props: AdminAccountsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
queryBuilder.or(
|
queryBuilder.or(`name.ilike.%${query}%,email.ilike.%${query}%,personal_code.ilike.%${query}%`);
|
||||||
`name.ilike.%${query}%,email.ilike.%${query}%,personal_code.ilike.%${query}%`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return queryBuilder;
|
return queryBuilder;
|
||||||
@@ -86,4 +81,4 @@ async function AccountsPage(props: AdminAccountsPageProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n(AdminGuard(AccountsPage));
|
export default AdminGuard(AccountsPage);
|
||||||
|
|||||||
@@ -5,12 +5,9 @@ import { cookies } from 'next/headers';
|
|||||||
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
||||||
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
|
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
|
||||||
|
|
||||||
import { AdminMenuNavigation } from '~/admin/_components/admin-menu-navigation';
|
|
||||||
import { AdminSidebar } from '~/admin/_components/admin-sidebar';
|
import { AdminSidebar } from '~/admin/_components/admin-sidebar';
|
||||||
import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation';
|
import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation';
|
||||||
|
|
||||||
import { loadUserWorkspace } from '../home/(user)/_lib/server/load-user-workspace';
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: `Super Admin`,
|
title: `Super Admin`,
|
||||||
};
|
};
|
||||||
@@ -19,27 +16,21 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export default function AdminLayout(props: React.PropsWithChildren) {
|
export default function AdminLayout(props: React.PropsWithChildren) {
|
||||||
const state = use(getLayoutState());
|
const state = use(getLayoutState());
|
||||||
const workspace = use(loadUserWorkspace());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page style={'header'}>
|
<SidebarProvider defaultOpen={state.open}>
|
||||||
<PageNavigation>
|
<Page style={'sidebar'}>
|
||||||
<AdminMenuNavigation workspace={workspace} />
|
<PageNavigation>
|
||||||
</PageNavigation>
|
<AdminSidebar />
|
||||||
|
</PageNavigation>
|
||||||
|
|
||||||
<PageMobileNavigation>
|
<PageMobileNavigation>
|
||||||
<AdminMobileNavigation />
|
<AdminMobileNavigation />
|
||||||
</PageMobileNavigation>
|
</PageMobileNavigation>
|
||||||
|
|
||||||
<SidebarProvider defaultOpen={state.open}>
|
{props.children}
|
||||||
<Page style={'sidebar'}>
|
</Page>
|
||||||
<PageNavigation>
|
</SidebarProvider>
|
||||||
<AdminSidebar />
|
|
||||||
</PageNavigation>
|
|
||||||
{props.children}
|
|
||||||
</Page>
|
|
||||||
</SidebarProvider>
|
|
||||||
</Page>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ import { AdminDashboard } from '@kit/admin/components/admin-dashboard';
|
|||||||
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
import { AdminGuard } from '@kit/admin/components/admin-guard';
|
||||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||||
|
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
|
||||||
|
|
||||||
function AdminPage() {
|
function AdminPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={`Super Admin`} />
|
<PageHeader description={`Super Admin`} />
|
||||||
|
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<AdminDashboard />
|
<AdminDashboard />
|
||||||
@@ -16,4 +14,4 @@ function AdminPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n(AdminGuard(AdminPage));
|
export default AdminGuard(AdminPage);
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { getPlanTypesMap } from '@kit/billing';
|
import { getPlanTypesMap } from '@kit/billing';
|
||||||
import { getBillingEventHandlerService } from '@kit/billing-gateway';
|
import { getBillingEventHandlerService } from '@kit/billing-gateway';
|
||||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||||
import { billingConfig } from '@kit/shared/config';
|
|
||||||
import { getLogger } from '@kit/shared/logger';
|
import { getLogger } from '@kit/shared/logger';
|
||||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
|
|
||||||
|
import billingConfig from '~/config/billing.config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Handle the webhooks from Stripe related to checkouts
|
* @description Handle the webhooks from Stripe related to checkouts
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { config } from 'dotenv';
|
|
||||||
|
|
||||||
export default function loadEnv() {
|
|
||||||
config({ path: `.env` });
|
|
||||||
if (
|
|
||||||
['local', 'test', 'development', 'production'].includes(
|
|
||||||
process.env.NODE_ENV!,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
config({ path: `.env.${process.env.NODE_ENV}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { renderNewJobsAvailableEmail } from '@kit/email-templates';
|
|
||||||
|
|
||||||
import { getDoctorAccounts } from '~/lib/services/account.service';
|
|
||||||
import { getOpenJobAnalysisResponseIds } from '~/lib/services/doctor-jobs.service';
|
|
||||||
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 = doctorAccounts
|
|
||||||
.map(({ email }) => email)
|
|
||||||
.filter((email): email is string => !!email);
|
|
||||||
|
|
||||||
if (doctorEmails.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendEmailFromTemplate(
|
|
||||||
renderNewJobsAvailableEmail,
|
|
||||||
{
|
|
||||||
language: 'et',
|
|
||||||
analysisResponseIds,
|
|
||||||
},
|
|
||||||
doctorEmails,
|
|
||||||
);
|
|
||||||
|
|
||||||
return doctorAccounts.filter((email) => !!email).map(({ id }) => id);
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
import { listProductTypes } from '@lib/data/products';
|
|
||||||
import Medusa from '@medusajs/js-sdk';
|
|
||||||
import type { AdminProductCategory } from '@medusajs/types';
|
|
||||||
|
|
||||||
import {
|
|
||||||
createMedusaSyncFailEntry,
|
|
||||||
createMedusaSyncSuccessEntry,
|
|
||||||
} from '~/lib/services/analyses.service';
|
|
||||||
import { getAnalysisElements } from '~/lib/services/analysis-element.service';
|
|
||||||
import { getAnalysisGroups } from '~/lib/services/analysis-group.service';
|
|
||||||
|
|
||||||
const SYNLAB_SERVICES_CATEGORY_HANDLE = 'synlab-services';
|
|
||||||
const SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE = 'synlab-analysis';
|
|
||||||
|
|
||||||
const BASE_ANALYSIS_PRODUCT_HANDLE = 'analysis-base';
|
|
||||||
|
|
||||||
const getAdminSdk = () => {
|
|
||||||
const medusaBackendUrl = process.env.MEDUSA_BACKEND_PUBLIC_URL!;
|
|
||||||
const medusaPublishableApiKey =
|
|
||||||
process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!;
|
|
||||||
const key = process.env.MEDUSA_SECRET_API_KEY!;
|
|
||||||
|
|
||||||
if (!medusaBackendUrl || !medusaPublishableApiKey) {
|
|
||||||
throw new Error('Medusa environment variables not set');
|
|
||||||
}
|
|
||||||
return new Medusa({
|
|
||||||
baseUrl: medusaBackendUrl,
|
|
||||||
debug: process.env.NODE_ENV === 'development',
|
|
||||||
apiKey: key,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
async function createProductCategories({ medusa }: { medusa: Medusa }) {
|
|
||||||
const { product_categories: existingProductCategories } =
|
|
||||||
await medusa.admin.productCategory.list();
|
|
||||||
const parentCategory = existingProductCategories.find(
|
|
||||||
({ handle }) => handle === SYNLAB_SERVICES_CATEGORY_HANDLE,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!parentCategory) {
|
|
||||||
throw new Error('Parent category not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const analysisGroups = await getAnalysisGroups();
|
|
||||||
if (!analysisGroups) {
|
|
||||||
throw new Error('Analysis groups not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdCategories: AdminProductCategory[] = [];
|
|
||||||
for (const analysisGroup of analysisGroups) {
|
|
||||||
console.info(`Processing analysis group '${analysisGroup.name}'`);
|
|
||||||
|
|
||||||
const isExisting = existingProductCategories.find(
|
|
||||||
({ name }) => name === analysisGroup.name,
|
|
||||||
);
|
|
||||||
const isNewlyCreated = createdCategories.find(
|
|
||||||
({ name }) => name === analysisGroup.name,
|
|
||||||
);
|
|
||||||
if (isExisting || isNewlyCreated) {
|
|
||||||
console.info(`Analysis group '${analysisGroup.name}' already exists`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createResponse = await medusa.admin.productCategory.create({
|
|
||||||
name: analysisGroup.name,
|
|
||||||
handle: analysisGroup.name,
|
|
||||||
parent_category_id: parentCategory.id,
|
|
||||||
is_active: true,
|
|
||||||
metadata: {
|
|
||||||
analysisGroupOriginalId: analysisGroup.original_id,
|
|
||||||
analysisGroupId: analysisGroup.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.info(
|
|
||||||
`Successfully created category, id=${createResponse.product_category.id}`,
|
|
||||||
);
|
|
||||||
createdCategories.push(createResponse.product_category);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getChildProductCategories({ medusa }: { medusa: Medusa }) {
|
|
||||||
const { product_categories: allCategories } =
|
|
||||||
await medusa.admin.productCategory.list();
|
|
||||||
const childCategories = allCategories.filter(
|
|
||||||
({ parent_category_id }) => parent_category_id !== null,
|
|
||||||
);
|
|
||||||
return childCategories;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteProductCategories({
|
|
||||||
medusa,
|
|
||||||
categories,
|
|
||||||
}: {
|
|
||||||
medusa: Medusa;
|
|
||||||
categories: AdminProductCategory[];
|
|
||||||
}) {
|
|
||||||
for (const category of categories) {
|
|
||||||
await medusa.admin.productCategory.delete(category.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In case a reset is needed
|
|
||||||
*/
|
|
||||||
async function deleteProducts({ medusa }: { medusa: Medusa }) {
|
|
||||||
const { products: existingProducts } = await medusa.admin.product.list({
|
|
||||||
fields: 'id,collection_id',
|
|
||||||
limit: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
existingProducts
|
|
||||||
.filter((a) => !a.collection_id)
|
|
||||||
.map(({ id }) => medusa.admin.product.delete(id)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAnalysisPackagesType() {
|
|
||||||
const { productTypes } = await listProductTypes();
|
|
||||||
const analysisPackagesType = productTypes.find(
|
|
||||||
({ metadata }) => metadata?.handle === SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE,
|
|
||||||
);
|
|
||||||
if (!analysisPackagesType) {
|
|
||||||
throw new Error('Synlab analysis packages type not found');
|
|
||||||
}
|
|
||||||
return analysisPackagesType;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getProductDefaultFields({ medusa }: { medusa: Medusa }) {
|
|
||||||
const baseProductsResponse = await medusa.admin.product.list({
|
|
||||||
handle: BASE_ANALYSIS_PRODUCT_HANDLE,
|
|
||||||
});
|
|
||||||
const baseProduct = baseProductsResponse.products[0];
|
|
||||||
if (!baseProduct) {
|
|
||||||
throw new Error('Base product not found');
|
|
||||||
}
|
|
||||||
const defaultSalesChannels = baseProduct.sales_channels;
|
|
||||||
if (!Array.isArray(defaultSalesChannels)) {
|
|
||||||
throw new Error('Base analysis product has no required sales channels');
|
|
||||||
}
|
|
||||||
const defaultProductOption = baseProduct.options;
|
|
||||||
if (!Array.isArray(defaultProductOption)) {
|
|
||||||
throw new Error('Base analysis product has no required options');
|
|
||||||
}
|
|
||||||
const defaultProductVariant = baseProduct.variants?.[0];
|
|
||||||
if (!defaultProductVariant) {
|
|
||||||
throw new Error('Base analysis product has no required variant');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
defaultSalesChannels,
|
|
||||||
defaultProductOption,
|
|
||||||
defaultProductVariant,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createProducts({ medusa }: { medusa: Medusa }) {
|
|
||||||
const { product_categories: allCategories } =
|
|
||||||
await medusa.admin.productCategory.list();
|
|
||||||
|
|
||||||
const [
|
|
||||||
{ products: existingProducts },
|
|
||||||
analysisElements,
|
|
||||||
analysisPackagesType,
|
|
||||||
{ defaultSalesChannels, defaultProductOption, defaultProductVariant },
|
|
||||||
] = await Promise.all([
|
|
||||||
medusa.admin.product.list({
|
|
||||||
category_id: allCategories.map(({ id }) => id),
|
|
||||||
}),
|
|
||||||
getAnalysisElements({ getAll: true }),
|
|
||||||
getAnalysisPackagesType(),
|
|
||||||
getProductDefaultFields({ medusa }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
for (const analysisElement of analysisElements) {
|
|
||||||
const { analysis_id_original: originalId } = analysisElement;
|
|
||||||
const isExisting = existingProducts.find(
|
|
||||||
({ metadata }) => metadata?.analysisIdOriginal === originalId,
|
|
||||||
);
|
|
||||||
if (isExisting) {
|
|
||||||
console.info(
|
|
||||||
`Analysis element '${analysisElement.analysis_name_lab}' already exists`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const { analysis_name_lab: name } = analysisElement;
|
|
||||||
if (!name) {
|
|
||||||
console.error(`Analysis element '${originalId}' has no name`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const category = allCategories.find(
|
|
||||||
({ metadata }) =>
|
|
||||||
metadata?.analysisGroupId === analysisElement.parent_analysis_group_id,
|
|
||||||
);
|
|
||||||
if (!category) {
|
|
||||||
console.error(`Category not found for analysis element '${name}'`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await medusa.admin.product.create({
|
|
||||||
title: name,
|
|
||||||
handle: `analysis-element-${analysisElement.id}`,
|
|
||||||
categories: [{ id: category.id }],
|
|
||||||
options: defaultProductOption.map(({ id, title, values }) => ({
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
values: values?.map(({ value }) => value) ?? [],
|
|
||||||
})),
|
|
||||||
metadata: {
|
|
||||||
analysisIdOriginal: originalId,
|
|
||||||
},
|
|
||||||
is_giftcard: false,
|
|
||||||
discountable: false,
|
|
||||||
status: 'published',
|
|
||||||
sales_channels: defaultSalesChannels.map(({ id }) => ({ id })),
|
|
||||||
variants: [
|
|
||||||
{
|
|
||||||
title: defaultProductVariant.title!,
|
|
||||||
prices: defaultProductVariant.prices!,
|
|
||||||
manage_inventory: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
type_id: analysisPackagesType.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function syncAnalysisGroupsStore() {
|
|
||||||
const medusa = getAdminSdk();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createProductCategories({ medusa });
|
|
||||||
|
|
||||||
// const categories = await getChildProductCategories({ medusa });
|
|
||||||
// await deleteProductCategories({ medusa, categories });
|
|
||||||
// await deleteProducts({ medusa });
|
|
||||||
// return;
|
|
||||||
|
|
||||||
await createProducts({ medusa });
|
|
||||||
|
|
||||||
await createMedusaSyncSuccessEntry();
|
|
||||||
} catch (e) {
|
|
||||||
await createMedusaSyncFailEntry(JSON.stringify(e));
|
|
||||||
console.error(e);
|
|
||||||
throw new Error(
|
|
||||||
`Failed to sync analyses to Medusa, error: ${JSON.stringify(e)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import { XMLParser } from 'fast-xml-parser';
|
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
import { toArray } from '@kit/shared/utils';
|
|
||||||
|
|
||||||
import {
|
|
||||||
createAnalysis,
|
|
||||||
createNoDataReceivedEntry,
|
|
||||||
createNoNewDataReceivedEntry,
|
|
||||||
createSyncFailEntry,
|
|
||||||
createSyncSuccessEntry,
|
|
||||||
getAnalyses,
|
|
||||||
} from '~/lib/services/analyses.service';
|
|
||||||
import {
|
|
||||||
AnalysisElement,
|
|
||||||
createAnalysisElement,
|
|
||||||
getAnalysisElements,
|
|
||||||
} from '~/lib/services/analysis-element.service';
|
|
||||||
import {
|
|
||||||
createAnalysisGroup,
|
|
||||||
getAnalysisGroups,
|
|
||||||
} from '~/lib/services/analysis-group.service';
|
|
||||||
import { createCodes } from '~/lib/services/codes.service';
|
|
||||||
import { IMedipostPublicMessageDataParsed } from '~/lib/services/medipost/medipost.types';
|
|
||||||
import { getLatestPublicMessageListItem } from '~/lib/services/medipost/medipostPublicMessage.service';
|
|
||||||
import { getLastCheckedDate } from '~/lib/services/sync-entries.service';
|
|
||||||
import type { ICode } from '~/lib/types/code';
|
|
||||||
|
|
||||||
const WRITE_XML_TO_FILE = false as boolean;
|
|
||||||
|
|
||||||
export default async function syncAnalysisGroups() {
|
|
||||||
const baseUrl = process.env.MEDIPOST_URL;
|
|
||||||
const user = process.env.MEDIPOST_USER;
|
|
||||||
const password = process.env.MEDIPOST_PASSWORD;
|
|
||||||
const sender = process.env.MEDIPOST_MESSAGE_SENDER;
|
|
||||||
|
|
||||||
if (!baseUrl || !user || !password || !sender) {
|
|
||||||
throw new Error('Could not access all necessary environment variables');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.info('Getting latest public message id');
|
|
||||||
// const lastCheckedDate = await getLastCheckedDate(); never used?
|
|
||||||
|
|
||||||
const latestMessage = await getLatestPublicMessageListItem();
|
|
||||||
if (!latestMessage) {
|
|
||||||
console.info('No new data received');
|
|
||||||
await createNoNewDataReceivedEntry();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.info('Getting public message with id: ', latestMessage.messageId);
|
|
||||||
|
|
||||||
const { data: publicMessageData } = await axios.get(baseUrl, {
|
|
||||||
params: {
|
|
||||||
Action: 'GetPublicMessage',
|
|
||||||
User: user,
|
|
||||||
Password: password,
|
|
||||||
MessageId: latestMessage.messageId,
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/xml',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (WRITE_XML_TO_FILE) {
|
|
||||||
fs.writeFileSync('public-messages-list-response.xml', publicMessageData);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parser = new XMLParser({ ignoreAttributes: false });
|
|
||||||
const parsed: IMedipostPublicMessageDataParsed =
|
|
||||||
parser.parse(publicMessageData);
|
|
||||||
|
|
||||||
if (parsed.ANSWER?.CODE && parsed.ANSWER?.CODE !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to get public message (id: ${latestMessage.messageId})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingAnalysisGroups = await getAnalysisGroups();
|
|
||||||
|
|
||||||
// SAVE PUBLIC MESSAGE DATA
|
|
||||||
|
|
||||||
const providers = toArray(parsed?.Saadetis?.Teenused.Teostaja);
|
|
||||||
const analysisGroups = providers.flatMap((provider) =>
|
|
||||||
toArray(provider.UuringuGrupp),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!parsed || !analysisGroups.length) {
|
|
||||||
console.info('No analysis groups data received');
|
|
||||||
await createNoDataReceivedEntry();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const codes: ICode[] = [];
|
|
||||||
for (const analysisGroup of analysisGroups) {
|
|
||||||
const existingAnalysisGroup = existingAnalysisGroups?.find(
|
|
||||||
({ original_id }) => original_id === analysisGroup.UuringuGruppId,
|
|
||||||
);
|
|
||||||
let groupExistingAnalysisElements: AnalysisElement[] = [];
|
|
||||||
let analysisGroupId: number;
|
|
||||||
if (existingAnalysisGroup) {
|
|
||||||
console.info(
|
|
||||||
`Analysis group '${analysisGroup.UuringuGruppNimi}' already exists, only creating new analysis elements`,
|
|
||||||
);
|
|
||||||
groupExistingAnalysisElements = await getAnalysisElements({
|
|
||||||
analysisGroupId: existingAnalysisGroup.id,
|
|
||||||
});
|
|
||||||
analysisGroupId = existingAnalysisGroup.id;
|
|
||||||
} else {
|
|
||||||
analysisGroupId = await createAnalysisGroup({
|
|
||||||
id: analysisGroup.UuringuGruppId,
|
|
||||||
name: analysisGroup.UuringuGruppNimi,
|
|
||||||
order: analysisGroup.UuringuGruppJarjekord,
|
|
||||||
});
|
|
||||||
|
|
||||||
const analysisGroupCodes = toArray(analysisGroup.Kood);
|
|
||||||
codes.push(
|
|
||||||
...analysisGroupCodes.map((kood) => ({
|
|
||||||
hk_code: kood.HkKood,
|
|
||||||
hk_code_multiplier: kood.HkKoodiKordaja,
|
|
||||||
coefficient: kood.Koefitsient,
|
|
||||||
price: kood.Hind,
|
|
||||||
analysis_group_id: analysisGroupId,
|
|
||||||
analysis_element_id: null,
|
|
||||||
analysis_id: null,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const analysisGroupItems = toArray(analysisGroup.Uuring);
|
|
||||||
|
|
||||||
for (const item of analysisGroupItems) {
|
|
||||||
const analysisElement = item.UuringuElement!;
|
|
||||||
const isExistingAnalysisElement = groupExistingAnalysisElements.find(
|
|
||||||
({ analysis_id_original }) =>
|
|
||||||
analysis_id_original === analysisElement.UuringId,
|
|
||||||
);
|
|
||||||
if (isExistingAnalysisElement) {
|
|
||||||
console.info(
|
|
||||||
`Analysis element '${analysisElement.UuringNimi}' already exists`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertedAnalysisElementId = await createAnalysisElement({
|
|
||||||
analysisElement: analysisElement!,
|
|
||||||
analysisGroupId,
|
|
||||||
materialGroups: toArray(item.MaterjalideGrupp),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Array.isArray(analysisElement.UuringuElement)) {
|
|
||||||
for (const nestedAnalysisElement of analysisElement.UuringuElement) {
|
|
||||||
await createAnalysisElement({
|
|
||||||
analysisElement: nestedAnalysisElement,
|
|
||||||
analysisGroupId,
|
|
||||||
materialGroups: toArray(item.MaterjalideGrupp),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (analysisElement.Kood) {
|
|
||||||
const analysisElementCodes = toArray(analysisElement.Kood);
|
|
||||||
codes.push(
|
|
||||||
...analysisElementCodes.map((kood) => ({
|
|
||||||
hk_code: kood.HkKood,
|
|
||||||
hk_code_multiplier: kood.HkKoodiKordaja,
|
|
||||||
coefficient: kood.Koefitsient,
|
|
||||||
price: kood.Hind,
|
|
||||||
analysis_group_id: null,
|
|
||||||
analysis_element_id: insertedAnalysisElementId,
|
|
||||||
analysis_id: null,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const analyses = analysisElement.UuringuElement;
|
|
||||||
if (analyses?.length) {
|
|
||||||
const existingAnalyses = await getAnalyses({
|
|
||||||
originalIds: analyses.map(({ UuringId }) => UuringId),
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const analysis of analyses) {
|
|
||||||
const isExistingAnalysis = existingAnalyses.find(
|
|
||||||
({ analysis_id_original }) =>
|
|
||||||
analysis_id_original === analysis.UuringId,
|
|
||||||
);
|
|
||||||
if (isExistingAnalysis) {
|
|
||||||
console.info(`Analysis '${analysis.UuringNimi}' already exists`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertedAnalysisId = await createAnalysis(
|
|
||||||
analysis,
|
|
||||||
analysisGroupId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (analysis.Kood) {
|
|
||||||
const analysisCodes = toArray(analysis.Kood);
|
|
||||||
|
|
||||||
codes.push(
|
|
||||||
...analysisCodes.map((kood) => ({
|
|
||||||
hk_code: kood.HkKood,
|
|
||||||
hk_code_multiplier: kood.HkKoodiKordaja,
|
|
||||||
coefficient: kood.Koefitsient,
|
|
||||||
price: kood.Hind,
|
|
||||||
analysis_group_id: null,
|
|
||||||
analysis_element_id: null,
|
|
||||||
analysis_id: insertedAnalysisId,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info('Inserting codes');
|
|
||||||
await createCodes(codes);
|
|
||||||
|
|
||||||
console.info('Inserting sync entry');
|
|
||||||
await createSyncSuccessEntry();
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
||||||
await createSyncFailEntry(
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
message: errorMessage,
|
|
||||||
stack: e instanceof Error ? e.stack : undefined,
|
|
||||||
name: e instanceof Error ? e.name : 'Unknown',
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
console.error('Sync failed:', e);
|
|
||||||
throw new Error(
|
|
||||||
`Failed to sync public message data, error: ${errorMessage}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import MedipostPrivateMessageSync from '~/lib/services/medipost/medipostPrivateMessageSync.service';
|
|
||||||
|
|
||||||
type ProcessedMessage = {
|
|
||||||
messageId: string;
|
|
||||||
hasAnalysisResponse: boolean;
|
|
||||||
hasPartialAnalysisResponse: boolean;
|
|
||||||
hasFullAnalysisResponse: boolean;
|
|
||||||
medusaOrderId: string | undefined;
|
|
||||||
analysisOrderId: number | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GroupedResults = {
|
|
||||||
processed: Pick<ProcessedMessage, 'messageId' | 'analysisOrderId'>[];
|
|
||||||
waitingForResults: Pick<ProcessedMessage, 'messageId' | 'analysisOrderId'>[];
|
|
||||||
};
|
|
||||||
|
|
||||||
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 sync.handleNextPrivateMessage({ excludedMessageIds });
|
|
||||||
|
|
||||||
const { messageId } = result;
|
|
||||||
if (!messageId) {
|
|
||||||
console.info('No more messages to process');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
processedMessages.push(result as ProcessedMessage);
|
|
||||||
if (!excludedMessageIds.includes(messageId)) {
|
|
||||||
excludedMessageIds.push(messageId);
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupedResults = processedMessages.reduce((acc, result) => {
|
|
||||||
if (result.analysisOrderId) {
|
|
||||||
if (result.hasAnalysisResponse) {
|
|
||||||
if (!acc.processed) {
|
|
||||||
acc.processed = [];
|
|
||||||
}
|
|
||||||
acc.processed.push({
|
|
||||||
messageId: result.messageId,
|
|
||||||
analysisOrderId: result.analysisOrderId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (!acc.waitingForResults) {
|
|
||||||
acc.waitingForResults = [];
|
|
||||||
}
|
|
||||||
acc.waitingForResults.push({
|
|
||||||
messageId: result.messageId,
|
|
||||||
analysisOrderId: result.analysisOrderId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {} as GroupedResults);
|
|
||||||
console.info(
|
|
||||||
`Processed ${processedMessages.length} messages, results: ${JSON.stringify(groupedResults)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
|
||||||
|
|
||||||
import { logSyncResult } from '~/lib/services/audit.service';
|
|
||||||
import { SyncStatus } from '~/lib/types/audit';
|
|
||||||
import type {
|
|
||||||
ISearchLoadResponse,
|
|
||||||
P_JobTitleTranslation,
|
|
||||||
} from '~/lib/types/connected-online';
|
|
||||||
|
|
||||||
function createTranslationMap(translations: P_JobTitleTranslation[]) {
|
|
||||||
const result: Map<
|
|
||||||
number,
|
|
||||||
Map<number, { textEN: string; textRU: string; textET: string }>
|
|
||||||
> = new Map();
|
|
||||||
|
|
||||||
for (const translation of translations) {
|
|
||||||
const { ClinicID, TextET, TextEN, TextRU, SyncID } = translation;
|
|
||||||
|
|
||||||
if (!result.has(ClinicID)) {
|
|
||||||
result.set(ClinicID, new Map());
|
|
||||||
}
|
|
||||||
|
|
||||||
result.get(ClinicID)!.set(SyncID, {
|
|
||||||
textET: TextET,
|
|
||||||
textEN: TextEN,
|
|
||||||
textRU: TextRU,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSpokenLanguages(spokenLanguages?: string) {
|
|
||||||
if (!spokenLanguages || !spokenLanguages.length) return [];
|
|
||||||
return spokenLanguages.split(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function syncConnectedOnline() {
|
|
||||||
const isProd = !['test', 'localhost'].some((pathString) =>
|
|
||||||
process.env.NEXT_PUBLIC_SITE_URL?.includes(pathString),
|
|
||||||
);
|
|
||||||
|
|
||||||
const baseUrl = process.env.CONNECTED_ONLINE_URL;
|
|
||||||
|
|
||||||
if (!baseUrl) {
|
|
||||||
throw new Error('Could not access all necessary environment variables');
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = getSupabaseServerAdminClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const searchLoadResponse = await axios.post<{ d: string }>(
|
|
||||||
`${baseUrl}/Search_Load`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
|
||||||
},
|
|
||||||
param: "{'Value':'|et|-1'}", // get all available services in Estonian
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const responseData: ISearchLoadResponse = JSON.parse(
|
|
||||||
searchLoadResponse.data.d,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (responseData?.ErrorCode !== 0) {
|
|
||||||
throw new Error('Failed to get Connected Online data');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!responseData.Data.T_Lic?.length ||
|
|
||||||
!responseData.Data.T_Service?.length
|
|
||||||
) {
|
|
||||||
return supabase.schema('audit').from('sync_entries').insert({
|
|
||||||
operation: 'CONNECTED_ONLINE_SYNC',
|
|
||||||
comment: 'No clinic or service data received',
|
|
||||||
status: 'FAIL',
|
|
||||||
changed_by_role: 'service_role',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
const clinics = responseData.Data.T_Lic.filter(({ ID }) =>
|
|
||||||
isDemoClinic(ID),
|
|
||||||
);
|
|
||||||
const services = responseData.Data.T_Service.filter(({ ClinicID }) =>
|
|
||||||
isDemoClinic(ClinicID),
|
|
||||||
);
|
|
||||||
const serviceProviders = responseData.Data.T_Doctor.filter(({ ClinicID }) =>
|
|
||||||
isDemoClinic(ClinicID),
|
|
||||||
);
|
|
||||||
const jobTitleTranslations = createTranslationMap(
|
|
||||||
responseData.Data.P_JobTitleTranslations.filter(({ ClinicID }) =>
|
|
||||||
isDemoClinic(ClinicID),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mappedClinics = clinics.map((clinic) => {
|
|
||||||
return {
|
|
||||||
id: clinic.ID,
|
|
||||||
can_select_worker: !!clinic.OnlineCanSelectWorker,
|
|
||||||
email: clinic.Email || null,
|
|
||||||
name: clinic.Name,
|
|
||||||
personal_code_required: !!clinic.PersonalCodeRequired,
|
|
||||||
phone_number: clinic.Phone || null,
|
|
||||||
key: clinic.Key,
|
|
||||||
address: clinic.Address,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const mappedServices = services.map((service) => {
|
|
||||||
return {
|
|
||||||
id: service.ID,
|
|
||||||
clinic_id: service.ClinicID,
|
|
||||||
sync_id: Number(service.SyncID),
|
|
||||||
name: service.Name,
|
|
||||||
description: service.Description || null,
|
|
||||||
price: service.Price,
|
|
||||||
requires_payment: !!service.RequiresPayment,
|
|
||||||
duration: service.Duration,
|
|
||||||
neto_duration: service.NetoDuration,
|
|
||||||
display: service.Display,
|
|
||||||
price_periods: service.PricePeriods || null,
|
|
||||||
online_hide_duration: service.OnlineHideDuration,
|
|
||||||
online_hide_price: service.OnlineHidePrice,
|
|
||||||
code: service.Code,
|
|
||||||
has_free_codes: !!service.HasFreeCodes,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const mappedServiceProviders = serviceProviders.map((serviceProvider) => {
|
|
||||||
const jobTitleTranslation = serviceProvider.JobTitleID
|
|
||||||
? jobTitleTranslations
|
|
||||||
.get(serviceProvider.ClinicID)
|
|
||||||
?.get(serviceProvider.JobTitleID)
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
id: serviceProvider.ID,
|
|
||||||
prefix: serviceProvider.Prefix,
|
|
||||||
name: serviceProvider.Name,
|
|
||||||
spoken_languages: getSpokenLanguages(serviceProvider.SpokenLanguages),
|
|
||||||
job_title_et: jobTitleTranslation?.textET,
|
|
||||||
job_title_en: jobTitleTranslation?.textEN,
|
|
||||||
job_title_ru: jobTitleTranslation?.textRU,
|
|
||||||
job_title_id: serviceProvider.JobTitleID,
|
|
||||||
is_deleted: !!serviceProvider.Deleted,
|
|
||||||
clinic_id: serviceProvider.ClinicID,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const { error: providersError } = await supabase
|
|
||||||
.schema('medreport')
|
|
||||||
.from('connected_online_providers')
|
|
||||||
.upsert(mappedClinics);
|
|
||||||
|
|
||||||
if (providersError) {
|
|
||||||
return logSyncResult({
|
|
||||||
operation: 'CONNECTED_ONLINE_SYNC',
|
|
||||||
comment:
|
|
||||||
'Error saving connected online providers: ' +
|
|
||||||
JSON.stringify(providersError),
|
|
||||||
status: SyncStatus.Fail,
|
|
||||||
changed_by_role: 'service_role',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: servicesError } = await supabase
|
|
||||||
.schema('medreport')
|
|
||||||
.from('connected_online_services')
|
|
||||||
.upsert(mappedServices, {
|
|
||||||
onConflict: 'id',
|
|
||||||
ignoreDuplicates: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (servicesError) {
|
|
||||||
return logSyncResult({
|
|
||||||
operation: 'CONNECTED_ONLINE_SYNC',
|
|
||||||
comment:
|
|
||||||
'Error saving connected online services: ' +
|
|
||||||
JSON.stringify(servicesError),
|
|
||||||
status: SyncStatus.Fail,
|
|
||||||
changed_by_role: 'service_role',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: serviceProvidersError } = await supabase
|
|
||||||
.schema('medreport')
|
|
||||||
.from('connected_online_service_providers')
|
|
||||||
.upsert(mappedServiceProviders, {
|
|
||||||
onConflict: 'id',
|
|
||||||
ignoreDuplicates: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (serviceProvidersError) {
|
|
||||||
return logSyncResult({
|
|
||||||
operation: 'CONNECTED_ONLINE_SYNC',
|
|
||||||
comment:
|
|
||||||
'Error saving service providers: ' +
|
|
||||||
JSON.stringify(serviceProvidersError),
|
|
||||||
status: SyncStatus.Fail,
|
|
||||||
changed_by_role: 'service_role',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const mappedClinic of mappedClinics) {
|
|
||||||
const defaultLoadResponse = await axios.post<{ d: string }>(
|
|
||||||
`${baseUrl}/Default_Load`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
|
||||||
},
|
|
||||||
param: `{'Value':'${mappedClinic.key}|et'}`,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultLoadResponseData = JSON.parse(defaultLoadResponse.data.d);
|
|
||||||
|
|
||||||
if (defaultLoadResponseData?.ErrorCode !== 0) {
|
|
||||||
throw new Error('Failed to get Connected Online location data');
|
|
||||||
}
|
|
||||||
|
|
||||||
const clinicLocations: {
|
|
||||||
SyncID: number;
|
|
||||||
Address: string;
|
|
||||||
Name: string;
|
|
||||||
}[] = defaultLoadResponseData.Data.T_SelectableLocation;
|
|
||||||
|
|
||||||
if (clinicLocations?.length) {
|
|
||||||
const mappedLocations = clinicLocations.map(
|
|
||||||
({ SyncID, Address, Name }) => ({
|
|
||||||
address: Address,
|
|
||||||
clinic_id: mappedClinic.id,
|
|
||||||
sync_id: SyncID,
|
|
||||||
name: Name,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await supabase
|
|
||||||
.schema('medreport')
|
|
||||||
.from('connected_online_locations')
|
|
||||||
.insert(mappedLocations)
|
|
||||||
.throwOnError();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await logSyncResult({
|
|
||||||
operation: 'CONNECTED_ONLINE_SYNC',
|
|
||||||
status: SyncStatus.Success,
|
|
||||||
changed_by_role: 'service_role',
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
await logSyncResult({
|
|
||||||
operation: 'CONNECTED_ONLINE_SYNC',
|
|
||||||
status: SyncStatus.Fail,
|
|
||||||
comment: JSON.stringify(e),
|
|
||||||
changed_by_role: 'service_role',
|
|
||||||
});
|
|
||||||
throw new Error(
|
|
||||||
`Failed to sync Connected Online data, error: ${JSON.stringify(e)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
export default function validateApiKey(request: NextRequest) {
|
|
||||||
const envApiKey = process.env.JOBS_API_TOKEN;
|
|
||||||
const requestApiKey = request.headers.get('x-jobs-api-key');
|
|
||||||
if (requestApiKey !== envApiKey) {
|
|
||||||
throw new Error('Unauthorized');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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';
|
|
||||||
import { sendOrderToMedipost } from '~/lib/services/medipost/medipostPrivateMessage.service';
|
|
||||||
import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service';
|
|
||||||
|
|
||||||
import loadEnv from '../handler/load-env';
|
|
||||||
import validateApiKey from '../handler/validate-api-key';
|
|
||||||
|
|
||||||
export const POST = async (request: NextRequest) => {
|
|
||||||
const logger = await getLogger();
|
|
||||||
const ctx = {
|
|
||||||
api: '/job/medipost-retry-dispatch',
|
|
||||||
};
|
|
||||||
loadEnv();
|
|
||||||
|
|
||||||
const { medusaOrderId } = await request.json();
|
|
||||||
|
|
||||||
try {
|
|
||||||
validateApiKey(request);
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tries = await getMedipostDispatchTries(medusaOrderId);
|
|
||||||
if (tries >= 3) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: 'Order has been retried too many times',
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const medusaOrder = await retrieveOrder(medusaOrderId);
|
|
||||||
const orderedAnalysisElements = await getOrderedAnalysisIds({
|
|
||||||
medusaOrder,
|
|
||||||
});
|
|
||||||
await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements });
|
|
||||||
logger.info(ctx, 'Successfully sent order to medipost');
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: 'Successfully sent order to medipost',
|
|
||||||
},
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ ...ctx, error }, 'Error sending order to medipost');
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: 'Failed to send order to medipost',
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import {
|
|
||||||
NotificationAction,
|
|
||||||
createNotificationLog,
|
|
||||||
} from '~/lib/services/audit/notificationEntries.service';
|
|
||||||
|
|
||||||
import loadEnv from '../handler/load-env';
|
|
||||||
import sendOpenJobsEmails from '../handler/send-open-jobs-emails';
|
|
||||||
import validateApiKey from '../handler/validate-api-key';
|
|
||||||
|
|
||||||
export const POST = async (request: NextRequest) => {
|
|
||||||
loadEnv();
|
|
||||||
|
|
||||||
try {
|
|
||||||
validateApiKey(request);
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const doctors = await sendOpenJobsEmails();
|
|
||||||
const doctorIds = doctors?.join(', ') ?? '-';
|
|
||||||
console.info(
|
|
||||||
`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(
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
'Successfully sent out open job notification emails to doctors.',
|
|
||||||
},
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(
|
|
||||||
'Error sending out open job notification emails to doctors.',
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
await createNotificationLog({
|
|
||||||
action: NotificationAction.DOCTOR_NEW_JOBS,
|
|
||||||
status: 'FAIL',
|
|
||||||
comment: e instanceof Error ? e.message : 'Unknown error',
|
|
||||||
});
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: 'Failed to send out open job notification emails to doctors.',
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import loadEnv from '../handler/load-env';
|
|
||||||
import syncAnalysisGroupsStore from '../handler/sync-analysis-groups-store';
|
|
||||||
import validateApiKey from '../handler/validate-api-key';
|
|
||||||
|
|
||||||
export const POST = async (request: NextRequest) => {
|
|
||||||
loadEnv();
|
|
||||||
|
|
||||||
try {
|
|
||||||
validateApiKey(request);
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await syncAnalysisGroupsStore();
|
|
||||||
console.info('Successfully synced analysis groups store');
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: 'Successfully synced analysis groups store',
|
|
||||||
},
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error syncing analysis groups store', e);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: 'Failed to sync analysis groups store',
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import loadEnv from '../handler/load-env';
|
|
||||||
import syncAnalysisGroups from '../handler/sync-analysis-groups';
|
|
||||||
import validateApiKey from '../handler/validate-api-key';
|
|
||||||
|
|
||||||
export const POST = async (request: NextRequest) => {
|
|
||||||
loadEnv();
|
|
||||||
|
|
||||||
try {
|
|
||||||
validateApiKey(request);
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await syncAnalysisGroups();
|
|
||||||
console.info('Successfully synced analysis groups');
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: 'Successfully synced analysis groups',
|
|
||||||
},
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error syncing analysis groups', e);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: 'Failed to sync analysis groups',
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import loadEnv from '../handler/load-env';
|
|
||||||
import syncAnalysisResults from '../handler/sync-analysis-results';
|
|
||||||
import validateApiKey from '../handler/validate-api-key';
|
|
||||||
|
|
||||||
export const POST = async (request: NextRequest) => {
|
|
||||||
loadEnv();
|
|
||||||
|
|
||||||
try {
|
|
||||||
validateApiKey(request);
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await syncAnalysisResults();
|
|
||||||
console.info('Successfully synced analysis results');
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: 'Successfully synced analysis results',
|
|
||||||
},
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error syncing analysis results', e);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: 'Failed to sync analysis results',
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import loadEnv from '../handler/load-env';
|
|
||||||
import syncConnectedOnline from '../handler/sync-connected-online';
|
|
||||||
import validateApiKey from '../handler/validate-api-key';
|
|
||||||
|
|
||||||
export const POST = async (request: NextRequest) => {
|
|
||||||
loadEnv();
|
|
||||||
|
|
||||||
try {
|
|
||||||
validateApiKey(request);
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await syncConnectedOnline();
|
|
||||||
console.info('Successfully synced connected-online');
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: 'Successfully synced connected-online',
|
|
||||||
},
|
|
||||||
{ status: 200 },
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error syncing connected-online', e);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: 'Failed to sync connected-online',
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { retrieveOrder } from '@lib/data';
|
|
||||||
|
|
||||||
import { getAccountAdmin } from '~/lib/services/account.service';
|
|
||||||
import {
|
|
||||||
composeOrderTestResponseXML,
|
|
||||||
sendPrivateMessageTestResponse,
|
|
||||||
} from '~/lib/services/medipost/medipostTest.service';
|
|
||||||
import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service';
|
|
||||||
import { getAnalysisOrdersAdmin } from '~/lib/services/order.service';
|
|
||||||
|
|
||||||
import loadEnv from '../handler/load-env';
|
|
||||||
import validateApiKey from '../handler/validate-api-key';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
loadEnv();
|
|
||||||
|
|
||||||
try {
|
|
||||||
validateApiKey(request);
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const analysisOrders = await getAnalysisOrdersAdmin({
|
|
||||||
orderStatus: 'PROCESSING',
|
|
||||||
});
|
|
||||||
|
|
||||||
console.error(
|
|
||||||
`Sending test responses for ${analysisOrders.length} analysis orders`,
|
|
||||||
);
|
|
||||||
for (const medreportOrder of analysisOrders) {
|
|
||||||
const medusaOrderId = medreportOrder.medusa_order_id;
|
|
||||||
const medusaOrder = await retrieveOrder(medusaOrderId);
|
|
||||||
|
|
||||||
const account = await getAccountAdmin({
|
|
||||||
primaryOwnerUserId: medreportOrder.user_id,
|
|
||||||
});
|
|
||||||
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({
|
|
||||||
medusaOrder,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.info(
|
|
||||||
`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`,
|
|
||||||
);
|
|
||||||
const idsToSend = orderedAnalysisElementsIds;
|
|
||||||
const messageXml = await composeOrderTestResponseXML({
|
|
||||||
person: {
|
|
||||||
idCode: account.personal_code!,
|
|
||||||
firstName: account.name ?? '',
|
|
||||||
lastName: account.last_name ?? '',
|
|
||||||
phone: account.phone ?? '',
|
|
||||||
},
|
|
||||||
orderedAnalysisElementsIds: idsToSend
|
|
||||||
.map(({ analysisElementId }) => analysisElementId)
|
|
||||||
.filter(Boolean) as number[],
|
|
||||||
orderedAnalysesIds: idsToSend
|
|
||||||
.map(({ analysisId }) => analysisId)
|
|
||||||
.filter(Boolean) as number[],
|
|
||||||
orderId: medreportOrder.id,
|
|
||||||
orderCreatedAt: new Date(medreportOrder.created_at),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendPrivateMessageTestResponse({ messageXml });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error sending private message test response: ', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -39,6 +38,9 @@ export const POST = enhanceRouteHandler(
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const namespace = 'montonio.verify-token';
|
const namespace = 'montonio.verify-token';
|
||||||
|
|
||||||
|
const activeCartId = request.cookies.get('_medusa_cart_id')?.value;
|
||||||
|
console.info('cart id', activeCartId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { token } = BodySchema.parse(body);
|
const { token } = BodySchema.parse(body);
|
||||||
|
|
||||||
@@ -59,6 +61,12 @@ export const POST = enhanceRouteHandler(
|
|||||||
algorithms: ['HS256'],
|
algorithms: ['HS256'],
|
||||||
}) as MontonioOrderToken;
|
}) as MontonioOrderToken;
|
||||||
|
|
||||||
|
const [, cartId] = decoded.merchantReferenceDisplay.split(':');
|
||||||
|
console.info('active cart id parsed', {cartId, activeCartId, decoded:decoded.merchantReferenceDisplay});
|
||||||
|
if (cartId !== activeCartId) {
|
||||||
|
throw new Error('Invalid cart id');
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
name: namespace,
|
name: namespace,
|
||||||
@@ -95,4 +103,4 @@ export const POST = enhanceRouteHandler(
|
|||||||
{
|
{
|
||||||
auth: false,
|
auth: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { retrieveOrder } from '@lib/data';
|
|
||||||
|
|
||||||
import { getAccountAdmin } from '~/lib/services/account.service';
|
|
||||||
import { upsertMedipostActionLog } from '~/lib/services/medipost/medipostMessageBase.service';
|
|
||||||
import {
|
|
||||||
composeOrderTestResponseXML,
|
|
||||||
sendPrivateMessageTestResponse,
|
|
||||||
} from '~/lib/services/medipost/medipostTest.service';
|
|
||||||
import { getOrderedAnalysisIds } from '~/lib/services/medusaOrder.service';
|
|
||||||
import { getAnalysisOrder } from '~/lib/services/order.service';
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
// const isDev = process.env.NODE_ENV === 'development';
|
|
||||||
// if (!isDev) {
|
|
||||||
// return NextResponse.json({ error: 'This endpoint is only available in development mode' }, { status: 403 });
|
|
||||||
// }
|
|
||||||
|
|
||||||
const { medusaOrderId } = await request.json();
|
|
||||||
|
|
||||||
const medusaOrder = await retrieveOrder(medusaOrderId);
|
|
||||||
const analysisOrder = await getAnalysisOrder({ medusaOrderId });
|
|
||||||
|
|
||||||
const account = await getAccountAdmin({
|
|
||||||
primaryOwnerUserId: analysisOrder.user_id,
|
|
||||||
});
|
|
||||||
const orderedAnalysisElementsIds = await getOrderedAnalysisIds({
|
|
||||||
medusaOrder,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.info(
|
|
||||||
`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`,
|
|
||||||
);
|
|
||||||
const messageXml = await composeOrderTestResponseXML({
|
|
||||||
person: {
|
|
||||||
idCode: account.personal_code!,
|
|
||||||
firstName: account.name ?? '',
|
|
||||||
lastName: account.last_name ?? '',
|
|
||||||
phone: account.phone ?? '',
|
|
||||||
},
|
|
||||||
orderedAnalysisElementsIds: orderedAnalysisElementsIds
|
|
||||||
.map(({ analysisElementId }) => analysisElementId)
|
|
||||||
.filter(Boolean) as number[],
|
|
||||||
orderedAnalysesIds: orderedAnalysisElementsIds
|
|
||||||
.map(({ analysisId }) => analysisId)
|
|
||||||
.filter(Boolean) as number[],
|
|
||||||
orderId: analysisOrder.id,
|
|
||||||
orderCreatedAt: new Date(analysisOrder.created_at),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await upsertMedipostActionLog({
|
|
||||||
action: 'send_fake_analysis_results_to_medipost',
|
|
||||||
xml: messageXml,
|
|
||||||
medusaOrderId,
|
|
||||||
medipostPrivateMessageId: `fake-response-${Date.now()}`,
|
|
||||||
});
|
|
||||||
await sendPrivateMessageTestResponse({ messageXml });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error sending private message test response: ', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,11 @@ import Link from 'next/link';
|
|||||||
import type { AuthError } from '@supabase/supabase-js';
|
import type { AuthError } from '@supabase/supabase-js';
|
||||||
|
|
||||||
import { ResendAuthLinkForm } from '@kit/auth/resend-email-link';
|
import { ResendAuthLinkForm } from '@kit/auth/resend-email-link';
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
interface AuthCallbackErrorPageProps {
|
interface AuthCallbackErrorPageProps {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||||
import { AppLogo } from '@kit/shared/components/app-logo';
|
|
||||||
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
|
||||||
function AuthLayout({ children }: React.PropsWithChildren) {
|
function AuthLayout({ children }: React.PropsWithChildren) {
|
||||||
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||||
|
|||||||
@@ -1,68 +1,18 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
import { createAccountsApi } from '@/packages/features/accounts/src/server/api';
|
import { createAuthCallbackService } from '@kit/supabase/auth';
|
||||||
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
|
||||||
import {
|
|
||||||
createAuthCallbackService,
|
|
||||||
getErrorURLParameters,
|
|
||||||
} from '@kit/supabase/auth';
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
const ERROR_PATH = '/auth/callback/error';
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
|
||||||
const redirectOnError = (searchParams?: string) => {
|
|
||||||
return redirect(`${ERROR_PATH}${searchParams ? `?${searchParams}` : ''}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
|
|
||||||
const error = searchParams.get('error');
|
|
||||||
if (error) {
|
|
||||||
const { searchParams } = getErrorURLParameters({ error });
|
|
||||||
return redirectOnError(searchParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authCode = searchParams.get('code');
|
|
||||||
if (!authCode) {
|
|
||||||
return redirectOnError();
|
|
||||||
}
|
|
||||||
|
|
||||||
let redirectPath = searchParams.get('next') || pathsConfig.app.home;
|
|
||||||
// if we have an invite token, we redirect to the join team page
|
|
||||||
// instead of the default next url. This is because the user is trying
|
|
||||||
// to join a team and we want to make sure they are redirected to the
|
|
||||||
// correct page.
|
|
||||||
const inviteToken = searchParams.get('invite_token');
|
|
||||||
if (inviteToken) {
|
|
||||||
const urlParams = new URLSearchParams({
|
|
||||||
invite_token: inviteToken,
|
|
||||||
email: searchParams.get('email') ?? '',
|
|
||||||
});
|
|
||||||
|
|
||||||
redirectPath = `${pathsConfig.app.joinTeam}?${urlParams.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const service = createAuthCallbackService(getSupabaseServerClient());
|
const service = createAuthCallbackService(getSupabaseServerClient());
|
||||||
const oauthResult = await service.exchangeCodeForSession(authCode);
|
|
||||||
|
|
||||||
if (oauthResult.requiresMultiFactorAuthentication) {
|
const { nextPath } = await service.exchangeCodeForSession(request, {
|
||||||
redirect(pathsConfig.auth.verifyMfa);
|
joinTeamPath: pathsConfig.app.joinTeam,
|
||||||
}
|
redirectPath: pathsConfig.app.home,
|
||||||
|
});
|
||||||
|
|
||||||
if (!('isSuccess' in oauthResult)) {
|
return redirect(nextPath);
|
||||||
return redirectOnError(oauthResult.searchParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = createAccountsApi(getSupabaseServerClient());
|
|
||||||
|
|
||||||
const account = await api.getPersonalAccountByUserId(oauthResult.user.id);
|
|
||||||
|
|
||||||
if (!account.email || !account.name || !account.last_name) {
|
|
||||||
return redirect(pathsConfig.auth.updateAccount);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect(redirectPath);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||||
import { AppLogo } from '@kit/shared/components/app-logo';
|
|
||||||
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
|
||||||
function AuthLayout({ children }: React.PropsWithChildren) {
|
function AuthLayout({ children }: React.PropsWithChildren) {
|
||||||
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
|
||||||
import { createAuthCallbackService } from '@kit/supabase/auth';
|
import { createAuthCallbackService } from '@kit/supabase/auth';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const service = createAuthCallbackService(getSupabaseServerClient());
|
const service = createAuthCallbackService(getSupabaseServerClient());
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import pathsConfig from '@/config/paths.config';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
|
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
|
||||||
import { SuccessNotification } from '@kit/notifications/components';
|
import { SuccessNotification } from '@kit/notifications/components';
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
|
||||||
|
|
||||||
const MembershipConfirmationNotification: React.FC<{
|
const MembershipConfirmationNotification: React.FC<{
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
async function SiteLayout(props: React.PropsWithChildren) {
|
async function SiteLayout(props: React.PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}>
|
<div className={'flex min-h-[100vh] flex-col items-center justify-center'}>
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
import pathsConfig from '@/config/paths.config';
|
||||||
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
import {
|
|
||||||
PageViewAction,
|
|
||||||
createPageViewLog,
|
|
||||||
} from '~/lib/services/audit/pageView.service';
|
|
||||||
|
|
||||||
import MembershipConfirmationNotification from './_components/membership-confirmation-notification';
|
import MembershipConfirmationNotification from './_components/membership-confirmation-notification';
|
||||||
|
|
||||||
async function MembershipConfirmation() {
|
async function UpdateAccountSuccess() {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -21,12 +18,8 @@ async function MembershipConfirmation() {
|
|||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
redirect(pathsConfig.app.home);
|
redirect(pathsConfig.app.home);
|
||||||
}
|
}
|
||||||
await createPageViewLog({
|
|
||||||
accountId: user.id,
|
|
||||||
action: PageViewAction.REGISTRATION_SUCCESS,
|
|
||||||
});
|
|
||||||
|
|
||||||
return <MembershipConfirmationNotification userId={user.id} />;
|
return <MembershipConfirmationNotification userId={user.id} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n(MembershipConfirmation);
|
export default withI18n(UpdateAccountSuccess);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||||
import { AppLogo } from '@kit/shared/components/app-logo';
|
|
||||||
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
|
||||||
function AuthLayout({ children }: React.PropsWithChildren) {
|
function AuthLayout({ children }: React.PropsWithChildren) {
|
||||||
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { PasswordResetRequestContainer } from '@kit/auth/password-reset';
|
import { PasswordResetRequestContainer } from '@kit/auth/password-reset';
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Heading } from '@kit/ui/heading';
|
import { Heading } from '@kit/ui/heading';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Providers, SignInMethodsContainer } from '@kit/auth/sign-in';
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import { Heading } from '@kit/ui/heading';
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
|
||||||
|
|
||||||
export default function PasswordOption({
|
|
||||||
inviteToken,
|
|
||||||
returnPath,
|
|
||||||
providers,
|
|
||||||
}: {
|
|
||||||
inviteToken?: string;
|
|
||||||
returnPath?: string;
|
|
||||||
providers: Providers;
|
|
||||||
}) {
|
|
||||||
const signUpPath =
|
|
||||||
pathsConfig.auth.signUp +
|
|
||||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
|
||||||
|
|
||||||
const paths = {
|
|
||||||
callback: pathsConfig.auth.callback,
|
|
||||||
returnPath: returnPath ?? pathsConfig.app.home,
|
|
||||||
joinTeam: pathsConfig.app.joinTeam,
|
|
||||||
updateAccount: pathsConfig.auth.updateAccount,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={'flex flex-col items-center gap-1'}>
|
|
||||||
<Heading level={4} className={'tracking-tight'}>
|
|
||||||
<Trans i18nKey={'auth:signInHeading'} />
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<p className={'text-muted-foreground text-sm'}>
|
|
||||||
<Trans i18nKey={'auth:signInSubheading'} />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SignInMethodsContainer
|
|
||||||
inviteToken={inviteToken}
|
|
||||||
paths={paths}
|
|
||||||
providers={providers}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={'flex justify-center'}>
|
|
||||||
<Button asChild variant={'link'} size={'sm'}>
|
|
||||||
<Link href={signUpPath} prefetch={true}>
|
|
||||||
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import Loading from '@/app/home/loading';
|
|
||||||
import { getSupabaseBrowserClient } from '@/packages/supabase/src/clients/browser-client';
|
|
||||||
|
|
||||||
export function SignInPageClientRedirect() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function signIn() {
|
|
||||||
const { data, error } =
|
|
||||||
await getSupabaseBrowserClient().auth.signInWithOAuth({
|
|
||||||
provider: 'keycloak',
|
|
||||||
options: {
|
|
||||||
redirectTo: `${window.location.origin}/auth/callback`,
|
|
||||||
queryParams: {
|
|
||||||
prompt: 'login',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('OAuth error', error);
|
|
||||||
router.push('/');
|
|
||||||
} else if (data.url) {
|
|
||||||
router.push(data.url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
signIn();
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return <Loading />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||||
import { AppLogo } from '@kit/shared/components/app-logo';
|
|
||||||
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
|
||||||
function AuthLayout({ children }: React.PropsWithChildren) {
|
function AuthLayout({ children }: React.PropsWithChildren) {
|
||||||
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { getServerAuthConfig, pathsConfig } from '@kit/shared/config';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { register } from 'module';
|
||||||
|
|
||||||
|
import { SignInMethodsContainer } from '@kit/auth/sign-in';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Heading } from '@kit/ui/heading';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import authConfig from '~/config/auth.config';
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
import PasswordOption from './components/PasswordOption';
|
|
||||||
import { SignInPageClientRedirect } from './components/SignInPageClientRedirect';
|
|
||||||
|
|
||||||
interface SignInPageProps {
|
interface SignInPageProps {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
invite_token?: string;
|
invite_token?: string;
|
||||||
@@ -22,26 +28,47 @@ export const generateMetadata = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function SignInPage({ searchParams }: SignInPageProps) {
|
async function SignInPage({ searchParams }: SignInPageProps) {
|
||||||
const { invite_token: inviteToken, next: returnPath = pathsConfig.app.home } =
|
const { invite_token: inviteToken, next = pathsConfig.app.home } =
|
||||||
await searchParams;
|
await searchParams;
|
||||||
|
|
||||||
const authConfig = await getServerAuthConfig();
|
const signUpPath =
|
||||||
|
pathsConfig.auth.signUp +
|
||||||
|
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||||
|
|
||||||
if (authConfig.providers.password) {
|
const paths = {
|
||||||
return (
|
callback: pathsConfig.auth.callback,
|
||||||
<PasswordOption
|
returnPath: next ?? pathsConfig.app.home,
|
||||||
|
joinTeam: pathsConfig.app.joinTeam,
|
||||||
|
updateAccount: pathsConfig.auth.updateAccount,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={'flex flex-col items-center gap-1'}>
|
||||||
|
<Heading level={4} className={'tracking-tight'}>
|
||||||
|
<Trans i18nKey={'auth:signInHeading'} />
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<p className={'text-muted-foreground text-sm'}>
|
||||||
|
<Trans i18nKey={'auth:signInSubheading'} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SignInMethodsContainer
|
||||||
inviteToken={inviteToken}
|
inviteToken={inviteToken}
|
||||||
returnPath={returnPath}
|
paths={paths}
|
||||||
providers={authConfig.providers}
|
providers={authConfig.providers}
|
||||||
/>
|
/>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authConfig.providers.oAuth.includes('keycloak')) {
|
<div className={'flex justify-center'}>
|
||||||
return <SignInPageClientRedirect />;
|
<Button asChild variant={'link'} size={'sm'}>
|
||||||
}
|
<Link href={signUpPath} prefetch={true}>
|
||||||
|
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
|
||||||
return null;
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n(SignInPage);
|
export default withI18n(SignInPage);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||||
import { AppLogo } from '@kit/shared/components/app-logo';
|
|
||||||
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
|
||||||
function AuthLayout({ children }: React.PropsWithChildren) {
|
function AuthLayout({ children }: React.PropsWithChildren) {
|
||||||
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
|
import { SignUpMethodsContainer } from '@kit/auth/sign-up';
|
||||||
import { getServerAuthConfig, pathsConfig } from '@kit/shared/config';
|
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Heading } from '@kit/ui/heading';
|
import { Heading } from '@kit/ui/heading';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import authConfig from '~/config/auth.config';
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
@@ -37,12 +37,6 @@ async function SignUpPage({ searchParams }: Props) {
|
|||||||
pathsConfig.auth.signIn +
|
pathsConfig.auth.signIn +
|
||||||
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
(inviteToken ? `?invite_token=${inviteToken}` : '');
|
||||||
|
|
||||||
const authConfig = await getServerAuthConfig();
|
|
||||||
|
|
||||||
if (!authConfig.providers.password) {
|
|
||||||
return redirect('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={'flex flex-col items-center gap-1'}>
|
<div className={'flex flex-col items-center gap-1'}>
|
||||||
@@ -56,7 +50,8 @@ async function SignUpPage({ searchParams }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SignUpMethodsContainer
|
<SignUpMethodsContainer
|
||||||
authConfig={authConfig}
|
providers={authConfig.providers}
|
||||||
|
displayTermsCheckbox={authConfig.displayTermsCheckbox}
|
||||||
inviteToken={inviteToken}
|
inviteToken={inviteToken}
|
||||||
paths={paths}
|
paths={paths}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { pathsConfig } from '@/packages/shared/src/config';
|
|
||||||
import { ExternalLink } from '@/public/assets/external-link';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import { Checkbox } from '@kit/ui/checkbox';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@kit/ui/form';
|
|
||||||
import { Input } from '@kit/ui/input';
|
|
||||||
import { toast } from '@kit/ui/sonner';
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
|
||||||
|
|
||||||
import { UpdateAccountSchemaClient } from '../_lib/schemas/update-account.schema';
|
|
||||||
import { onUpdateAccount } from '../_lib/server/update-account';
|
|
||||||
|
|
||||||
type UpdateAccountFormValues = z.infer<
|
|
||||||
ReturnType<typeof UpdateAccountSchemaClient>
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function UpdateAccountForm({
|
|
||||||
defaultValues,
|
|
||||||
isEmailUser,
|
|
||||||
}: {
|
|
||||||
defaultValues: UpdateAccountFormValues;
|
|
||||||
isEmailUser: boolean;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const { t } = useTranslation('account');
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(UpdateAccountSchemaClient({ isEmailUser })),
|
|
||||||
mode: 'onChange',
|
|
||||||
defaultValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { firstName, lastName, personalCode, email, userConsent } =
|
|
||||||
defaultValues;
|
|
||||||
|
|
||||||
const defaultValues_weight =
|
|
||||||
'weight' in defaultValues ? defaultValues.weight : null;
|
|
||||||
const defaultValues_height =
|
|
||||||
'height' in defaultValues ? defaultValues.height : null;
|
|
||||||
|
|
||||||
const hasFirstName = !!firstName;
|
|
||||||
const hasLastName = !!lastName;
|
|
||||||
const hasPersonalCode = !!personalCode;
|
|
||||||
const hasEmail = !!email;
|
|
||||||
|
|
||||||
const onUpdateAccountOptions = async (values: UpdateAccountFormValues) => {
|
|
||||||
const loading = toast.loading(t('updateAccount.updateAccountLoading'));
|
|
||||||
try {
|
|
||||||
const response = await onUpdateAccount({
|
|
||||||
firstName: values.firstName || firstName,
|
|
||||||
lastName: values.lastName || lastName,
|
|
||||||
personalCode: values.personalCode || personalCode,
|
|
||||||
email: values.email || email,
|
|
||||||
phone: values.phone,
|
|
||||||
weight: ((('weight' in values && values.weight) ??
|
|
||||||
defaultValues_weight) ||
|
|
||||||
null) as number,
|
|
||||||
height: ((('height' in values && values.height) ??
|
|
||||||
defaultValues_height) ||
|
|
||||||
null) as number,
|
|
||||||
userConsent: values.userConsent ?? userConsent,
|
|
||||||
city: values.city,
|
|
||||||
});
|
|
||||||
if (!response) {
|
|
||||||
throw new Error('Failed to update account');
|
|
||||||
}
|
|
||||||
toast.dismiss(loading);
|
|
||||||
toast.success(t('updateAccount.updateAccountSuccess'));
|
|
||||||
|
|
||||||
if (response.hasUnseenMembershipConfirmation) {
|
|
||||||
router.push(pathsConfig.auth.membershipConfirmation);
|
|
||||||
} else {
|
|
||||||
router.push(pathsConfig.app.selectPackage);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.info('promiseresult error', error);
|
|
||||||
toast.error(t('updateAccount.updateAccountError'));
|
|
||||||
toast.dismiss(loading);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
className="flex flex-col gap-6 px-6 pt-10 text-left"
|
|
||||||
onSubmit={form.handleSubmit(onUpdateAccountOptions)}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
name="firstName"
|
|
||||||
disabled={hasFirstName && !isEmailUser}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans i18nKey={'common:formField:firstName'} />
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} autoFocus={!hasFirstName} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
name="lastName"
|
|
||||||
disabled={hasLastName && !isEmailUser}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans i18nKey={'common:formField:lastName'} />
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} autoFocus={hasFirstName && !hasLastName} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
name="personalCode"
|
|
||||||
disabled={hasPersonalCode && !isEmailUser}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans i18nKey={'common:formField:personalCode'} />
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
name="email"
|
|
||||||
disabled={hasEmail}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans i18nKey={'common:formField:email'} />
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
name="phone"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans i18nKey={'common:formField:phone'} />
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<>
|
|
||||||
<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="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="userConsent"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<div className="flex flex-row items-center gap-2 pb-1">
|
|
||||||
<FormControl>
|
|
||||||
<Checkbox
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans i18nKey={'account:updateAccount:userConsentLabel'} />
|
|
||||||
</FormLabel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={t('account:updateAccount:userConsentUrlPath')}
|
|
||||||
className="flex flex-row items-center gap-2 text-sm hover:underline"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<ExternalLink />
|
|
||||||
<Trans i18nKey={'account:updateAccount:userConsentUrlTitle'} />
|
|
||||||
</Link>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button disabled={form.formState.isSubmitting} type="submit">
|
|
||||||
<Trans i18nKey={'account:updateAccount:button'} />
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import Isikukood from 'isikukood';
|
|
||||||
import parsePhoneNumber from 'libphonenumber-js/min';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const updateAccountSchema = {
|
|
||||||
firstName: z
|
|
||||||
.string({
|
|
||||||
error: 'First name is required',
|
|
||||||
})
|
|
||||||
.nonempty(),
|
|
||||||
lastName: z
|
|
||||||
.string({
|
|
||||||
error: 'Last name is required',
|
|
||||||
})
|
|
||||||
.nonempty({
|
|
||||||
error: 'common:formFieldError.stringNonEmpty',
|
|
||||||
}),
|
|
||||||
personalCode: z.string().refine(
|
|
||||||
(val) => {
|
|
||||||
try {
|
|
||||||
return new Isikukood(val).validate();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: 'common:formFieldError.invalidPersonalCode',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
email: z.string().email({
|
|
||||||
message: 'Email is required',
|
|
||||||
}),
|
|
||||||
phone: z
|
|
||||||
.string({
|
|
||||||
error: 'error:invalidPhone',
|
|
||||||
})
|
|
||||||
.nonempty()
|
|
||||||
.refine(
|
|
||||||
(phone) => {
|
|
||||||
try {
|
|
||||||
const phoneNumber = parsePhoneNumber(phone);
|
|
||||||
return (
|
|
||||||
!!phoneNumber &&
|
|
||||||
phoneNumber.isValid() &&
|
|
||||||
phoneNumber.country === 'EE'
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: 'common:formFieldError.invalidPhoneNumber',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
city: z.string().optional(),
|
|
||||||
weight: z
|
|
||||||
.number({
|
|
||||||
error: (issue) =>
|
|
||||||
issue.input === undefined
|
|
||||||
? 'Weight is required'
|
|
||||||
: 'Weight must be a number',
|
|
||||||
})
|
|
||||||
.gt(0, { message: 'Weight must be greater than 0' }),
|
|
||||||
|
|
||||||
height: z
|
|
||||||
.number({
|
|
||||||
error: (issue) =>
|
|
||||||
issue.input === undefined
|
|
||||||
? 'Height is required'
|
|
||||||
: 'Height must be a number',
|
|
||||||
})
|
|
||||||
.gt(0, { message: 'Height must be greater than 0' }),
|
|
||||||
userConsent: z.boolean().refine((val) => val === true, {
|
|
||||||
message: 'Must be true',
|
|
||||||
}),
|
|
||||||
} as const;
|
|
||||||
export const UpdateAccountSchemaServer = z.object({
|
|
||||||
firstName: updateAccountSchema.firstName,
|
|
||||||
lastName: updateAccountSchema.lastName,
|
|
||||||
personalCode: updateAccountSchema.personalCode,
|
|
||||||
email: updateAccountSchema.email,
|
|
||||||
phone: updateAccountSchema.phone,
|
|
||||||
city: updateAccountSchema.city,
|
|
||||||
weight: updateAccountSchema.weight.nullable(),
|
|
||||||
height: updateAccountSchema.height.nullable(),
|
|
||||||
userConsent: updateAccountSchema.userConsent,
|
|
||||||
});
|
|
||||||
export const UpdateAccountSchemaClient = ({
|
|
||||||
isEmailUser,
|
|
||||||
}: {
|
|
||||||
isEmailUser: boolean;
|
|
||||||
}) =>
|
|
||||||
z.object({
|
|
||||||
firstName: updateAccountSchema.firstName,
|
|
||||||
lastName: updateAccountSchema.lastName,
|
|
||||||
personalCode: updateAccountSchema.personalCode,
|
|
||||||
email: updateAccountSchema.email,
|
|
||||||
phone: updateAccountSchema.phone,
|
|
||||||
...(isEmailUser
|
|
||||||
? {
|
|
||||||
city: z.string().optional(),
|
|
||||||
weight: z.number().optional(),
|
|
||||||
height: z.number().optional(),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
city: updateAccountSchema.city,
|
|
||||||
weight: updateAccountSchema.weight,
|
|
||||||
height: updateAccountSchema.height,
|
|
||||||
}),
|
|
||||||
userConsent: updateAccountSchema.userConsent,
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import { updateCustomer } from '@lib/data/customer';
|
|
||||||
|
|
||||||
import { AccountSubmitData, createAuthApi } from '@kit/auth/api';
|
|
||||||
import { enhanceAction } from '@kit/next/actions';
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
||||||
|
|
||||||
import { UpdateAccountSchemaServer } from '../schemas/update-account.schema';
|
|
||||||
|
|
||||||
export const onUpdateAccount = enhanceAction(
|
|
||||||
async (params: AccountSubmitData) => {
|
|
||||||
const client = getSupabaseServerClient();
|
|
||||||
const api = createAuthApi(client);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.updateAccount(params);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (err instanceof Error) {
|
|
||||||
console.warn('On update account error: ' + err.message);
|
|
||||||
}
|
|
||||||
console.warn('On update account error: ', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateCustomer({
|
|
||||||
first_name: params.firstName,
|
|
||||||
last_name: params.lastName,
|
|
||||||
phone: params.phone,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to update Medusa customer', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUnseenMembershipConfirmation =
|
|
||||||
await api.hasUnseenMembershipConfirmation();
|
|
||||||
return {
|
|
||||||
hasUnseenMembershipConfirmation,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{
|
|
||||||
schema: UpdateAccountSchemaServer,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,48 +1,30 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
|
import { BackButton } from '@/components/back-button';
|
||||||
|
import { MedReportLogo } from '@/components/med-report-logo';
|
||||||
|
import pathsConfig from '@/config/paths.config';
|
||||||
import { signOutAction } from '@/lib/actions/sign-out';
|
import { signOutAction } from '@/lib/actions/sign-out';
|
||||||
|
import { UpdateAccountForm } from '@/packages/features/auth/src/components/update-account-form';
|
||||||
|
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client';
|
||||||
|
|
||||||
import { BackButton } from '@kit/shared/components/back-button';
|
|
||||||
import { MedReportLogo } from '@kit/shared/components/med-report-logo';
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
import { toTitleCase } from '~/lib/utils';
|
|
||||||
|
|
||||||
import { UpdateAccountForm } from './_components/update-account-form';
|
|
||||||
|
|
||||||
async function UpdateAccount() {
|
async function UpdateAccount() {
|
||||||
const { account, user } = await loadCurrentUserAccount();
|
const client = getSupabaseServerClient();
|
||||||
|
|
||||||
const isKeycloakUser = user?.app_metadata?.provider === 'keycloak';
|
const {
|
||||||
const isEmailUser = user?.app_metadata?.provider === 'email';
|
data: { user },
|
||||||
|
} = await client.auth.getUser();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect(pathsConfig.auth.signIn);
|
redirect(pathsConfig.auth.signIn);
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultValues = {
|
|
||||||
firstName: account?.name ? toTitleCase(account.name) : '',
|
|
||||||
lastName: account?.last_name ? toTitleCase(account.last_name) : '',
|
|
||||||
personalCode: account?.personal_code ?? '',
|
|
||||||
email: (() => {
|
|
||||||
if (isKeycloakUser) {
|
|
||||||
return account?.email ?? '';
|
|
||||||
}
|
|
||||||
return account?.email ?? user?.email ?? '';
|
|
||||||
})(),
|
|
||||||
phone: account?.phone ?? '+372',
|
|
||||||
city: account?.city ?? '',
|
|
||||||
weight: account?.accountParams?.weight ?? null,
|
|
||||||
height: account?.accountParams?.height ?? null,
|
|
||||||
userConsent: account?.has_consent_personal_data ?? false,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-border xs:w-3/5 flex w-9/10 flex-row overflow-hidden rounded-3xl border md:w-[988px]">
|
<div className="border-border flex max-w-5xl flex-row overflow-hidden rounded-3xl border">
|
||||||
<div className="xs:px-6 relative flex w-full flex-col pt-7 pb-22 text-center md:w-1/2 md:px-12">
|
<div className="relative flex w-1/2 min-w-md flex-col px-12 pt-7 pb-22 text-center">
|
||||||
<BackButton onBack={signOutAction} />
|
<BackButton onBack={signOutAction} />
|
||||||
<MedReportLogo />
|
<MedReportLogo />
|
||||||
<h1 className="pt-8">
|
<h1 className="pt-8">
|
||||||
@@ -51,12 +33,9 @@ async function UpdateAccount() {
|
|||||||
<p className="text-muted-foreground pt-1 text-sm">
|
<p className="text-muted-foreground pt-1 text-sm">
|
||||||
<Trans i18nKey={'account:updateAccount:description'} />
|
<Trans i18nKey={'account:updateAccount:description'} />
|
||||||
</p>
|
</p>
|
||||||
<UpdateAccountForm
|
<UpdateAccountForm user={user} />
|
||||||
defaultValues={defaultValues}
|
|
||||||
isEmailUser={isEmailUser}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden w-1/2 bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat md:block"></div>
|
<div className="w-1/2 min-w-[460px] bg-[url(/assets/med-report-logo-big.png)] bg-cover bg-center bg-no-repeat"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||||
import { AppLogo } from '@kit/shared/components/app-logo';
|
|
||||||
|
import { AppLogo } from '~/components/app-logo';
|
||||||
|
|
||||||
function AuthLayout({ children }: React.PropsWithChildren) {
|
function AuthLayout({ children }: React.PropsWithChildren) {
|
||||||
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
return <AuthLayoutShell Logo={AppLogo}>{children}</AuthLayoutShell>;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { MultiFactorChallengeContainer } from '@kit/auth/mfa';
|
import { MultiFactorChallengeContainer } from '@kit/auth/mfa';
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
|
||||||
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
|
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
@@ -40,11 +40,15 @@ async function VerifyPage(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextPath = (await props.searchParams).next;
|
const nextPath = (await props.searchParams).next;
|
||||||
const redirectPath =
|
const redirectPath = nextPath ?? pathsConfig.app.home;
|
||||||
!!nextPath && nextPath.length > 0 ? nextPath : pathsConfig.app.home;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultiFactorChallengeContainer userId={user.id} paths={{ redirectPath }} />
|
<MultiFactorChallengeContainer
|
||||||
|
userId={user.id}
|
||||||
|
paths={{
|
||||||
|
redirectPath,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { ReactElement, ReactNode, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { Info } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
|
||||||
import { cn } from '@kit/ui/utils';
|
|
||||||
|
|
||||||
import { AnalysisElement } from '~/lib/services/analysis-element.service';
|
|
||||||
import { NestedAnalysisElement } from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema';
|
|
||||||
|
|
||||||
import AnalysisLevelBar, {
|
|
||||||
AnalysisLevelBarSkeleton,
|
|
||||||
AnalysisResultLevel,
|
|
||||||
} from './analysis-level-bar';
|
|
||||||
|
|
||||||
export type AnalysisResultForDisplay = {
|
|
||||||
norm_status?: number | null;
|
|
||||||
response_value?: number | null;
|
|
||||||
unit?: string | null;
|
|
||||||
norm_lower_included?: boolean | null;
|
|
||||||
norm_upper_included?: boolean | null;
|
|
||||||
norm_lower?: number | null;
|
|
||||||
norm_upper?: number | null;
|
|
||||||
response_time?: string | null;
|
|
||||||
nestedElements?: NestedAnalysisElement[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const AnalysisDoctor = ({
|
|
||||||
analysisElement,
|
|
||||||
results,
|
|
||||||
startIcon,
|
|
||||||
endIcon,
|
|
||||||
isCancelled,
|
|
||||||
}: {
|
|
||||||
analysisElement: Pick<AnalysisElement, 'analysis_name_lab'>;
|
|
||||||
results?: AnalysisResultForDisplay;
|
|
||||||
isCancelled?: boolean;
|
|
||||||
startIcon?: ReactElement | null;
|
|
||||||
endIcon?: ReactNode | null;
|
|
||||||
}) => {
|
|
||||||
const name = analysisElement.analysis_name_lab || '';
|
|
||||||
const status = results?.norm_status;
|
|
||||||
const value = results?.response_value || 0;
|
|
||||||
const unit = results?.unit || '';
|
|
||||||
const normLower = results?.norm_lower ?? null;
|
|
||||||
const normUpper = results?.norm_upper ?? null;
|
|
||||||
|
|
||||||
const [showTooltip, setShowTooltip] = useState(false);
|
|
||||||
const analysisResultLevel = useMemo(() => {
|
|
||||||
if (!results || status === null || status === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (status) {
|
|
||||||
case 1:
|
|
||||||
return AnalysisResultLevel.WARNING;
|
|
||||||
case 2:
|
|
||||||
return AnalysisResultLevel.CRITICAL;
|
|
||||||
case 0:
|
|
||||||
default:
|
|
||||||
return AnalysisResultLevel.NORMAL;
|
|
||||||
}
|
|
||||||
}, [results, status]);
|
|
||||||
|
|
||||||
const normRangeText = useMemo(() => {
|
|
||||||
if (normLower === null && normUpper === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return `${normLower ?? '...'} - ${normUpper ?? '...'}`;
|
|
||||||
}, [normLower, normUpper]);
|
|
||||||
|
|
||||||
const nestedElements = results?.nestedElements ?? null;
|
|
||||||
const hasNestedElements =
|
|
||||||
Array.isArray(nestedElements) && nestedElements.length > 0;
|
|
||||||
|
|
||||||
const isAnalysisLevelBarHidden = isCancelled || !results || hasNestedElements;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-border rounded-lg border px-5">
|
|
||||||
<div className="flex flex-col items-center justify-between gap-2 py-3 sm:h-[65px] sm:flex-row sm:gap-0">
|
|
||||||
<div className="flex items-center gap-2 font-semibold">
|
|
||||||
{startIcon || <div className="w-4" />}
|
|
||||||
{name}
|
|
||||||
{results?.response_time && (
|
|
||||||
<div
|
|
||||||
className="group/tooltip relative"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowTooltip(!showTooltip);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => setShowTooltip(false)}
|
|
||||||
>
|
|
||||||
<Info className="hover" />{' '}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 rounded border bg-white p-4 text-sm whitespace-nowrap group-hover/tooltip:block',
|
|
||||||
{ block: showTooltip },
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Trans i18nKey="analysis-results:analysisDate" />
|
|
||||||
{': '}
|
|
||||||
{format(new Date(results.response_time), 'dd.MM.yyyy HH:mm')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isAnalysisLevelBarHidden ? null : (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-3 sm:ml-auto">
|
|
||||||
<div className="font-semibold">{value}</div>
|
|
||||||
<div className="text-muted-foreground text-sm">{unit}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
|
|
||||||
{normRangeText}
|
|
||||||
<div>
|
|
||||||
<Trans i18nKey="analysis-results:results.range.normal" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AnalysisLevelBar
|
|
||||||
results={results}
|
|
||||||
level={analysisResultLevel}
|
|
||||||
normRangeText={normRangeText}
|
|
||||||
/>
|
|
||||||
{endIcon || <div className="mx-2 w-4" />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{(() => {
|
|
||||||
// If parent has nested elements, don't show anything
|
|
||||||
if (hasNestedElements) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// If we're showing the level bar, don't show waiting
|
|
||||||
if (!isAnalysisLevelBarHidden) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// If cancelled, don't show waiting
|
|
||||||
if (isCancelled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Otherwise, show waiting for results
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-3 sm:ml-auto">
|
|
||||||
<div className="font-semibold">
|
|
||||||
<Trans i18nKey="analysis-results:waitingForResults" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mx-8 w-[60px]"></div>
|
|
||||||
<AnalysisLevelBarSkeleton />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AnalysisDoctor;
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
'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);
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { ArrowDown } from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '@kit/ui/utils';
|
|
||||||
|
|
||||||
import { AnalysisResultForDisplay } from './analysis-doctor';
|
|
||||||
|
|
||||||
export enum AnalysisResultLevel {
|
|
||||||
NORMAL = 'NORMAL',
|
|
||||||
WARNING = 'WARNING',
|
|
||||||
CRITICAL = 'CRITICAL',
|
|
||||||
}
|
|
||||||
|
|
||||||
const Level = ({
|
|
||||||
isActive = false,
|
|
||||||
color,
|
|
||||||
isFirst = false,
|
|
||||||
isLast = false,
|
|
||||||
arrowLocation,
|
|
||||||
normRangeText,
|
|
||||||
}: {
|
|
||||||
isActive?: boolean;
|
|
||||||
color: 'destructive' | 'success' | 'warning' | 'gray-200';
|
|
||||||
isFirst?: boolean;
|
|
||||||
isLast?: boolean;
|
|
||||||
arrowLocation?: number;
|
|
||||||
normRangeText?: string | null;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(`bg-${color} relative h-3 flex-1`, {
|
|
||||||
'opacity-60': !isActive,
|
|
||||||
'rounded-l-lg': isFirst,
|
|
||||||
'rounded-r-lg': isLast,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{isActive && (
|
|
||||||
<div
|
|
||||||
className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]"
|
|
||||||
{...(arrowLocation
|
|
||||||
? {
|
|
||||||
style: {
|
|
||||||
left: `${arrowLocation}%`,
|
|
||||||
...(arrowLocation > 92.5 && { left: '92.5%' }),
|
|
||||||
...(arrowLocation < 7.5 && { left: '7.5%' }),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {})}
|
|
||||||
>
|
|
||||||
<ArrowDown strokeWidth={2} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{color === 'success' && typeof normRangeText === 'string' && (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-muted-foreground absolute bottom-[-18px] left-3/8 text-xs font-bold whitespace-nowrap',
|
|
||||||
{
|
|
||||||
'opacity-60': isActive,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{normRangeText}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AnalysisLevelBarSkeleton = () => {
|
|
||||||
return (
|
|
||||||
<div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
|
|
||||||
<Level color="gray-200" isFirst isLast />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AnalysisLevelBar = ({
|
|
||||||
level,
|
|
||||||
results,
|
|
||||||
normRangeText,
|
|
||||||
}: {
|
|
||||||
level: AnalysisResultLevel | null;
|
|
||||||
results: AnalysisResultForDisplay;
|
|
||||||
normRangeText: string | null;
|
|
||||||
}) => {
|
|
||||||
const {
|
|
||||||
norm_lower: lower,
|
|
||||||
norm_upper: upper,
|
|
||||||
response_value: value,
|
|
||||||
} = results;
|
|
||||||
|
|
||||||
// Calculate arrow position based on value within normal range
|
|
||||||
const arrowLocation = useMemo(() => {
|
|
||||||
// If no response value, center the arrow
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no normal ranges defined, center the arrow
|
|
||||||
if (lower === null && upper === null) {
|
|
||||||
return 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If only upper bound exists
|
|
||||||
if (lower === null && upper !== null) {
|
|
||||||
if (value <= upper!) {
|
|
||||||
return Math.min(75, (value / upper!) * 75); // Show in left 75% of normal range
|
|
||||||
}
|
|
||||||
return 100; // Beyond upper bound
|
|
||||||
}
|
|
||||||
|
|
||||||
// If only lower bound exists
|
|
||||||
if (upper === null && lower !== null) {
|
|
||||||
if (value >= lower!) {
|
|
||||||
// Value is in normal range (above lower bound)
|
|
||||||
// Position proportionally in the normal range section
|
|
||||||
const normalizedPosition = Math.min(
|
|
||||||
(value - lower!) / (lower! * 0.5),
|
|
||||||
1,
|
|
||||||
); // Use 50% of lower as scale
|
|
||||||
return normalizedPosition * 100;
|
|
||||||
}
|
|
||||||
// Value is below lower bound - position in the "below normal" section
|
|
||||||
const belowPosition = Math.max(0, Math.min(1, value / lower!));
|
|
||||||
return belowPosition * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both bounds exist
|
|
||||||
if (lower !== null && upper !== null) {
|
|
||||||
if (value < lower!) {
|
|
||||||
return 0; // Below normal range
|
|
||||||
}
|
|
||||||
if (value > upper!) {
|
|
||||||
return 100; // Above normal range
|
|
||||||
}
|
|
||||||
// Within normal range
|
|
||||||
return ((value - lower!) / (upper! - lower!)) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 50; // Fallback
|
|
||||||
}, [value, upper, lower]);
|
|
||||||
|
|
||||||
// Determine level states based on normStatus
|
|
||||||
const isNormal = level === AnalysisResultLevel.NORMAL;
|
|
||||||
const isWarning = level === AnalysisResultLevel.WARNING;
|
|
||||||
const isCritical = level === AnalysisResultLevel.CRITICAL;
|
|
||||||
const isPending = level === null;
|
|
||||||
|
|
||||||
// Show appropriate levels based on available norm bounds
|
|
||||||
const hasLowerBound = lower !== null;
|
|
||||||
|
|
||||||
// Calculate level configuration (must be called before any returns)
|
|
||||||
const [first, second, third] = useMemo(() => {
|
|
||||||
const [warning, normal, critical] = [
|
|
||||||
{
|
|
||||||
isActive: isWarning,
|
|
||||||
color: 'warning',
|
|
||||||
...(isWarning ? { arrowLocation } : {}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isActive: isNormal,
|
|
||||||
color: 'success',
|
|
||||||
normRangeText,
|
|
||||||
...(isNormal ? { arrowLocation } : {}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isActive: isCritical,
|
|
||||||
color: 'destructive',
|
|
||||||
isLast: true,
|
|
||||||
...(isCritical ? { arrowLocation } : {}),
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
if (!hasLowerBound) {
|
|
||||||
return [{ ...normal, isFirst: true }, warning, critical] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{ ...warning, isFirst: true },
|
|
||||||
normal,
|
|
||||||
{ ...critical, isLast: true },
|
|
||||||
] as const;
|
|
||||||
}, [
|
|
||||||
arrowLocation,
|
|
||||||
normRangeText,
|
|
||||||
isNormal,
|
|
||||||
isWarning,
|
|
||||||
isCritical,
|
|
||||||
hasLowerBound,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// If pending results, show gray bar
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="w-60% mt-4 flex h-3 max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
|
|
||||||
<Level color="gray-200" isFirst isLast />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-3 gap-1',
|
|
||||||
'mt-4 sm:mt-0',
|
|
||||||
'w-[60%] sm:w-[35%]',
|
|
||||||
'min-w-[50vw] sm:min-w-auto',
|
|
||||||
'max-w-[360px]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Level {...first} />
|
|
||||||
<Level {...second} />
|
|
||||||
<Level {...third} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AnalysisLevelBar;
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { capitalize } from 'lodash';
|
|
||||||
|
|
||||||
import {
|
|
||||||
getDOBWithAgeStringFromPersonalCode,
|
|
||||||
getResultSetName,
|
|
||||||
} from '@kit/doctor/lib/helpers';
|
|
||||||
import {
|
|
||||||
AnalysisResponse,
|
|
||||||
DoctorFeedback,
|
|
||||||
Order,
|
|
||||||
Patient,
|
|
||||||
} from '@kit/doctor/schema/doctor-analysis-detail-view.schema';
|
|
||||||
import { useCurrentLocaleLanguageNames } from '@kit/shared/hooks';
|
|
||||||
import { getFullName } from '@kit/shared/utils';
|
|
||||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
|
||||||
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 { data: user } = useUser();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [recommendedAnalyses, setRecommendedAnalyses] = useState<string[]>(
|
|
||||||
recommendations ?? [],
|
|
||||||
);
|
|
||||||
const isRecommendationsEdited = useMemo(() => {
|
|
||||||
if (recommendedAnalyses.length !== recommendations?.length) return true;
|
|
||||||
const sa = new Set(recommendedAnalyses),
|
|
||||||
sb = new Set(recommendations);
|
|
||||||
if (sa.size !== sb.size) return true;
|
|
||||||
for (const v of sa) if (!sb.has(v)) return true;
|
|
||||||
return false;
|
|
||||||
}, [recommendations, recommendedAnalyses]);
|
|
||||||
|
|
||||||
const languageNames = useCurrentLocaleLanguageNames();
|
|
||||||
|
|
||||||
const isInProgress = !!(
|
|
||||||
!!feedback?.status &&
|
|
||||||
feedback?.doctor_user_id &&
|
|
||||||
feedback?.status !== 'COMPLETED'
|
|
||||||
);
|
|
||||||
const isCurrentDoctorJob =
|
|
||||||
!!feedback?.doctor_user_id && feedback?.doctor_user_id === user?.id;
|
|
||||||
|
|
||||||
if (!patient || !order || !analyses) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="xs:flex xs:justify-between">
|
|
||||||
<h3>
|
|
||||||
<Trans
|
|
||||||
i18nKey={getResultSetName(
|
|
||||||
order.title,
|
|
||||||
order.isPackage,
|
|
||||||
Object.keys(analyses)?.length,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</h3>
|
|
||||||
<div className="xs:flex hidden">
|
|
||||||
<DoctorJobSelect
|
|
||||||
analysisOrderId={order.analysisOrderId}
|
|
||||||
userId={patient.userId}
|
|
||||||
doctorUserId={feedback?.doctor_user_id}
|
|
||||||
isRemovable={isCurrentDoctorJob && isInProgress}
|
|
||||||
onJobUpdate={() =>
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (query) => query.queryKey.includes('doctor-jobs'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="xs:grid-cols-2 grid">
|
|
||||||
<div className="font-bold">
|
|
||||||
<Trans i18nKey="doctor:name" />
|
|
||||||
</div>
|
|
||||||
<div>{getFullName(patient.firstName, patient.lastName)}</div>
|
|
||||||
<div className="font-bold">
|
|
||||||
<Trans i18nKey="doctor:personalCode" />
|
|
||||||
</div>
|
|
||||||
<div>{patient.personalCode ?? ''}</div>
|
|
||||||
<div className="font-bold">
|
|
||||||
<Trans i18nKey="doctor:dobAndAge" />
|
|
||||||
</div>
|
|
||||||
<div>{getDOBWithAgeStringFromPersonalCode(patient.personalCode)}</div>
|
|
||||||
<div className="font-bold">
|
|
||||||
<Trans i18nKey="doctor:height" />
|
|
||||||
</div>
|
|
||||||
<div>{patient.height}</div>
|
|
||||||
<div className="font-bold">
|
|
||||||
<Trans i18nKey="doctor:weight" />
|
|
||||||
</div>
|
|
||||||
<div>{patient.weight}</div>
|
|
||||||
<div className="font-bold">
|
|
||||||
<Trans i18nKey="doctor:bmi" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{patient?.weight && patient?.height
|
|
||||||
? bmiFromMetric(patient.weight, patient.height)
|
|
||||||
: '-'}
|
|
||||||
</div>
|
|
||||||
<div className="font-bold">
|
|
||||||
<Trans i18nKey="doctor:smoking" />
|
|
||||||
</div>
|
|
||||||
<div>-</div>
|
|
||||||
<div className="font-bold">
|
|
||||||
<Trans i18nKey="doctor:phone" />
|
|
||||||
</div>
|
|
||||||
<div>{patient.phone}</div>
|
|
||||||
<div className="font-bold">
|
|
||||||
<Trans i18nKey="doctor:email" />
|
|
||||||
</div>
|
|
||||||
<div>{patient.email}</div>
|
|
||||||
<div className="font-bold">
|
|
||||||
<Trans i18nKey="common:language" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{capitalize(languageNames.of(patient.preferred_locale ?? 'et'))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="xs:hidden block">
|
|
||||||
<DoctorJobSelect
|
|
||||||
className="w-full"
|
|
||||||
analysisOrderId={order.analysisOrderId}
|
|
||||||
userId={patient.userId}
|
|
||||||
doctorUserId={feedback?.doctor_user_id}
|
|
||||||
isRemovable={isCurrentDoctorJob && isInProgress}
|
|
||||||
onJobUpdate={() =>
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (query) => query.queryKey.includes('doctor-jobs'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h3>
|
|
||||||
<Trans i18nKey="doctor:results" />
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{analyses.map((analysisData) => {
|
|
||||||
return (
|
|
||||||
<DoctorAnalysisWrapper
|
|
||||||
key={analysisData.id}
|
|
||||||
analysisData={analysisData}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{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}
|
|
||||||
/>
|
|
||||||
<DoctorRecommendedAnalyses
|
|
||||||
recommendedAnalyses={recommendedAnalyses}
|
|
||||||
availableAnalyses={availableAnalyses}
|
|
||||||
setRecommendedAnalyses={setRecommendedAnalyses}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { CaretDownIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import { AnalysisResponse } from '@kit/doctor/schema/doctor-analysis-detail-view.schema';
|
|
||||||
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
|
|
||||||
import { formatDate } from '@kit/shared/utils';
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@kit/ui/collapsible';
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
|
||||||
|
|
||||||
import AnalysisDoctor from './analysis-doctor';
|
|
||||||
|
|
||||||
export default function DoctorAnalysisWrapper({
|
|
||||||
analysisData,
|
|
||||||
}: {
|
|
||||||
analysisData: AnalysisResponse;
|
|
||||||
}) {
|
|
||||||
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 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>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
getOpenResponsesAction,
|
|
||||||
getOtherResponsesAction,
|
|
||||||
getUserDoneResponsesAction,
|
|
||||||
getUserInProgressResponsesAction,
|
|
||||||
} from '@kit/doctor/actions/table-data-fetching-actions';
|
|
||||||
|
|
||||||
import ResultsTableWrapper from './results-table-wrapper';
|
|
||||||
|
|
||||||
export default function DoctorDashboard() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ResultsTableWrapper
|
|
||||||
titleKey="doctor:openReviews"
|
|
||||||
action={getOpenResponsesAction}
|
|
||||||
queryKey="doctor-open-jobs"
|
|
||||||
/>
|
|
||||||
<ResultsTableWrapper
|
|
||||||
titleKey="doctor:myReviews"
|
|
||||||
action={getUserInProgressResponsesAction}
|
|
||||||
queryKey="doctor-in-progress-jobs"
|
|
||||||
/>
|
|
||||||
<ResultsTableWrapper
|
|
||||||
titleKey="doctor:completedReviews"
|
|
||||||
action={getUserDoneResponsesAction}
|
|
||||||
queryKey="doctor-done-jobs"
|
|
||||||
/>
|
|
||||||
<ResultsTableWrapper
|
|
||||||
titleKey="doctor:otherReviews"
|
|
||||||
action={getOtherResponsesAction}
|
|
||||||
queryKey="doctor-other-jobs"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { notFound } from 'next/navigation';
|
|
||||||
|
|
||||||
import { isDoctor } from '@kit/doctor/lib/server/utils/is-doctor';
|
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
||||||
|
|
||||||
type LayoutOrPageComponent<Params> = React.ComponentType<Params>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DoctorGuard is a server component wrapper that checks if the user is a doctor before rendering the component.
|
|
||||||
* If the user is not a doctor, we redirect to a 404.
|
|
||||||
* @param Component - The Page or Layout component to wrap
|
|
||||||
*/
|
|
||||||
export function DoctorGuard<Params extends object>(
|
|
||||||
Component: LayoutOrPageComponent<Params>,
|
|
||||||
) {
|
|
||||||
return async function DoctorGuardServerComponentWrapper(params: Params) {
|
|
||||||
const client = getSupabaseServerClient();
|
|
||||||
const isUserDoctor = await isDoctor(client);
|
|
||||||
|
|
||||||
// if the user is not a super-admin, we redirect to a 404
|
|
||||||
if (!isUserDoctor) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Component {...params} />;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useTransition } from 'react';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { LoaderCircle } from 'lucide-react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
selectJobAction,
|
|
||||||
unselectJobAction,
|
|
||||||
} from '@kit/doctor/actions/doctor-server-actions';
|
|
||||||
import { ErrorReason } from '@kit/doctor/schema/error.type';
|
|
||||||
import { Button, ButtonProps } from '@kit/ui/button';
|
|
||||||
import { toast } from '@kit/ui/sonner';
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
|
||||||
import { cn } from '@kit/ui/utils';
|
|
||||||
|
|
||||||
export default function DoctorJobSelect({
|
|
||||||
className,
|
|
||||||
size = 'sm',
|
|
||||||
doctorUserId,
|
|
||||||
doctorName,
|
|
||||||
analysisOrderId,
|
|
||||||
userId,
|
|
||||||
isRemovable,
|
|
||||||
onJobUpdate,
|
|
||||||
linkTo,
|
|
||||||
}: {
|
|
||||||
className?: string;
|
|
||||||
size?: ButtonProps['size'];
|
|
||||||
doctorUserId?: string | null;
|
|
||||||
doctorName?: string;
|
|
||||||
analysisOrderId: number;
|
|
||||||
userId: string;
|
|
||||||
isRemovable?: boolean;
|
|
||||||
linkTo?: string;
|
|
||||||
onJobUpdate: () => void;
|
|
||||||
}) {
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleSelectJob = () => {
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await selectJobAction({
|
|
||||||
analysisOrderId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result?.success) {
|
|
||||||
onJobUpdate();
|
|
||||||
linkTo && router.push(linkTo);
|
|
||||||
} else {
|
|
||||||
toast.error(
|
|
||||||
<Trans
|
|
||||||
i18nKey={`doctor:error.${result.reason ?? ErrorReason.UNKNOWN}`}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
if (result.reason === ErrorReason.JOB_ASSIGNED) {
|
|
||||||
onJobUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnselectJob = () => {
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await unselectJobAction({
|
|
||||||
analysisOrderId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result?.success) {
|
|
||||||
onJobUpdate();
|
|
||||||
} else {
|
|
||||||
toast.error(
|
|
||||||
<Trans
|
|
||||||
i18nKey={`doctor:error.${result.reason ?? ErrorReason.UNKNOWN}`}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isRemovable) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={cn('w-16', className)}
|
|
||||||
size={size}
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleUnselectJob}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trans i18nKey="doctor:unselectJob" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!doctorUserId) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={cn('w-16', className)}
|
|
||||||
size={size}
|
|
||||||
onClick={handleSelectJob}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trans i18nKey="doctor:selectJob" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{doctorName}</>;
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
'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;
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
|
|
||||||
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
|
|
||||||
import { LayoutDashboard } from 'lucide-react';
|
|
||||||
|
|
||||||
import { AppLogo } from '@kit/shared/components/app-logo';
|
|
||||||
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarFooter,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
useSidebar,
|
|
||||||
} from '@kit/ui/shadcn-sidebar';
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
|
||||||
|
|
||||||
export function DoctorSidebar({
|
|
||||||
accounts,
|
|
||||||
}: {
|
|
||||||
accounts: UserWorkspace['accounts'];
|
|
||||||
}) {
|
|
||||||
const path = usePathname();
|
|
||||||
const { open } = useSidebar();
|
|
||||||
return (
|
|
||||||
<Sidebar collapsible="icon">
|
|
||||||
<SidebarHeader className={'m-2'}>
|
|
||||||
<AppLogo
|
|
||||||
href={pathsConfig.app.home}
|
|
||||||
className="max-w-full"
|
|
||||||
compact={!open}
|
|
||||||
/>
|
|
||||||
</SidebarHeader>
|
|
||||||
|
|
||||||
<SidebarContent>
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel>
|
|
||||||
<Trans i18nKey="common:doctor" />
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={path === pathsConfig.app.doctor}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link className={'flex gap-2.5'} href={pathsConfig.app.doctor}>
|
|
||||||
<LayoutDashboard className={'h-4'} />
|
|
||||||
<Trans i18nKey={'doctor:sidebar.dashboard'} />
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={path === pathsConfig.app.openJobs}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
className={'flex gap-2.5'}
|
|
||||||
href={pathsConfig.app.openJobs}
|
|
||||||
>
|
|
||||||
<LayoutDashboard className={'h-4'} />
|
|
||||||
<Trans i18nKey={'doctor:sidebar.openReviews'} />
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={path === pathsConfig.app.myJobs}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link className={'flex gap-2.5'} href={pathsConfig.app.myJobs}>
|
|
||||||
<LayoutDashboard className={'h-4'} />
|
|
||||||
<Trans i18nKey={'doctor:sidebar.myReviews'} />
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
<SidebarMenuButton
|
|
||||||
isActive={path === pathsConfig.app.completedJobs}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
className={'flex gap-2.5'}
|
|
||||||
href={pathsConfig.app.completedJobs}
|
|
||||||
>
|
|
||||||
<LayoutDashboard className={'h-4'} />
|
|
||||||
<Trans i18nKey={'doctor:sidebar.completedReviews'} />
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
|
||||||
|
|
||||||
<SidebarFooter>
|
|
||||||
<ProfileAccountDropdownContainer accounts={accounts} />
|
|
||||||
</SidebarFooter>
|
|
||||||
</Sidebar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Menu } from 'lucide-react';
|
|
||||||
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@kit/ui/dropdown-menu';
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
|
||||||
|
|
||||||
export function DoctorMobileNavigation() {
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger>
|
|
||||||
<Menu className={'h-8 w-8'} />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Link href={pathsConfig.app.home}>
|
|
||||||
<Trans i18nKey={'common:routes.home'} />
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Link href={pathsConfig.app.doctor}>
|
|
||||||
<Trans i18nKey={'doctor:sidebar.dashboard'} />
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Link href={pathsConfig.app.openJobs}>
|
|
||||||
<Trans i18nKey={'doctor:sidebar.openReviews'} />
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Link href={pathsConfig.app.myJobs}>
|
|
||||||
<Trans i18nKey={'doctor:sidebar.myReviews'} />
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Link href={pathsConfig.app.completedJobs}>
|
|
||||||
<Trans i18nKey={'doctor:sidebar.completedReviews'} />
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
'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);
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
'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);
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
import {
|
|
||||||
PaginatedData,
|
|
||||||
ResponseTable,
|
|
||||||
ServerActionResponse,
|
|
||||||
} from '@kit/doctor/schema/doctor-analysis.schema';
|
|
||||||
import TableSkeleton from '@kit/shared/components/table-skeleton';
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
|
||||||
import { cn } from '@kit/ui/utils';
|
|
||||||
|
|
||||||
import ResultsTable from './results-table';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
|
||||||
|
|
||||||
export default function ResultsTableWrapper({
|
|
||||||
titleKey,
|
|
||||||
action,
|
|
||||||
queryKey,
|
|
||||||
}: {
|
|
||||||
titleKey: string;
|
|
||||||
action: ({
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
}: {
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
}) => Promise<ServerActionResponse<PaginatedData<ResponseTable>>>;
|
|
||||||
queryKey: string;
|
|
||||||
}) {
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: jobs,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
isFetching,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: [queryKey, 'doctor-jobs', page],
|
|
||||||
queryFn: async () => await action({ page: page, pageSize: PAGE_SIZE }),
|
|
||||||
placeholderData: (previousData) => previousData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const onJobUpdate = () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
predicate: (query) => query.queryKey.includes('doctor-jobs'),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const createPageChangeHandler = (setPage: (page: number) => void) => {
|
|
||||||
return async ({ page }: { page: number; pageSize: number }) => {
|
|
||||||
setPage(page);
|
|
||||||
return { success: true, data: null };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3>
|
|
||||||
<Trans i18nKey={titleKey} />
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
|
||||||
className={`transition-opacity duration-200 ${
|
|
||||||
isFetching ? 'opacity-50' : 'opacity-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<TableSkeleton />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3>
|
|
||||||
<Trans i18nKey={titleKey} />
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<div className="text-lg text-red-600">
|
|
||||||
<Trans i18nKey="common:genericServerError" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h3>
|
|
||||||
<Trans i18nKey={titleKey} />
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
|
||||||
className={cn('opacity-100 transition-opacity duration-200', {
|
|
||||||
'opacity-50': isFetching,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<ResultsTable
|
|
||||||
results={jobs?.data?.data || []}
|
|
||||||
pagination={jobs?.data?.pagination}
|
|
||||||
fetchAction={createPageChangeHandler(setPage)}
|
|
||||||
onJobUpdate={onJobUpdate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useTransition } from 'react';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { capitalize } from 'lodash';
|
|
||||||
import { Eye } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getResultSetName } from '@kit/doctor/lib/helpers';
|
|
||||||
import { ResponseTable } from '@kit/doctor/schema/doctor-analysis.schema';
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
|
||||||
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 { toast } from '@kit/ui/sonner';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@kit/ui/table';
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
|
||||||
|
|
||||||
import DoctorJobSelect from './doctor-job-select';
|
|
||||||
|
|
||||||
export default function ResultsTable({
|
|
||||||
results = [],
|
|
||||||
pagination = {
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
totalCount: 0,
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
fetchAction,
|
|
||||||
onJobUpdate,
|
|
||||||
}: {
|
|
||||||
results: ResponseTable[] | null;
|
|
||||||
pagination?: {
|
|
||||||
currentPage: number;
|
|
||||||
totalPages: number;
|
|
||||||
totalCount: number;
|
|
||||||
pageSize: number;
|
|
||||||
};
|
|
||||||
fetchAction: ({
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
}: {
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
}) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
data: null;
|
|
||||||
}>;
|
|
||||||
onJobUpdate: () => void;
|
|
||||||
}) {
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const { data: currentUser } = useUser();
|
|
||||||
|
|
||||||
const languageNames = useCurrentLocaleLanguageNames();
|
|
||||||
|
|
||||||
const fetchPage = async (page: number) => {
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await fetchAction({
|
|
||||||
page,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
if (!result.success) {
|
|
||||||
toast.error('common.genericServerError');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextPage = () => {
|
|
||||||
if (pagination.currentPage < pagination.totalPages) {
|
|
||||||
fetchPage(pagination.currentPage + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrevPage = () => {
|
|
||||||
if (pagination.currentPage > 1) {
|
|
||||||
fetchPage(pagination.currentPage - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJobUpdate = () => {
|
|
||||||
onJobUpdate();
|
|
||||||
fetchPage(pagination.currentPage);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!results?.length) {
|
|
||||||
return (
|
|
||||||
<p>
|
|
||||||
<Trans i18nKey="common:noData" />.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Table className="border-separate rounded-lg border">
|
|
||||||
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-6"></TableHead>
|
|
||||||
<TableHead className="w-20">
|
|
||||||
<Trans i18nKey="doctor:resultsTable.patientName" />
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="w-20">
|
|
||||||
<Trans i18nKey="doctor:resultsTable.serviceName" />
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="w-20">
|
|
||||||
<Trans i18nKey="doctor:resultsTable.orderNr" />
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="w-20">
|
|
||||||
<Trans i18nKey="doctor:resultsTable.time" />
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="w-20">
|
|
||||||
<Trans i18nKey="doctor:resultsTable.resultsStatus" />
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="w-20">
|
|
||||||
<Trans i18nKey="doctor:resultsTable.language" />
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="w-20">
|
|
||||||
<Trans i18nKey="doctor:resultsTable.assignedTo" />
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{results
|
|
||||||
?.sort((a, b) =>
|
|
||||||
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
|
|
||||||
)
|
|
||||||
.map((result) => {
|
|
||||||
const isCompleted = result.feedback?.status === 'COMPLETED';
|
|
||||||
const isCurrentDoctorJob =
|
|
||||||
!!result.doctor?.primary_owner_user_id &&
|
|
||||||
result.doctor?.primary_owner_user_id === currentUser?.id;
|
|
||||||
|
|
||||||
const resultsReceived = result.elements.length || 0;
|
|
||||||
const elementsInOrder = Array.from(
|
|
||||||
new Set(result.analysis_order_id.analysis_element_ids),
|
|
||||||
)?.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow key={result.order_number}>
|
|
||||||
<TableCell className="text-center">
|
|
||||||
<Link
|
|
||||||
href={`/${pathsConfig.app.analysisDetails}/${result.id}`}
|
|
||||||
>
|
|
||||||
<Eye />
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{getFullName(
|
|
||||||
result.patient?.name,
|
|
||||||
result.patient?.last_name,
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Trans
|
|
||||||
i18nKey={getResultSetName(
|
|
||||||
result.order?.title ?? '-',
|
|
||||||
result.order?.isPackage,
|
|
||||||
result.elements?.length || 0,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{result.order_number}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{result.firstSampleGivenAt
|
|
||||||
? format(result.firstSampleGivenAt, 'dd.MM.yyyy HH:mm')
|
|
||||||
: '-'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Trans
|
|
||||||
i18nKey={
|
|
||||||
resultsReceived >= elementsInOrder
|
|
||||||
? 'doctor:resultsTable.responsesReceived'
|
|
||||||
: 'doctor:resultsTable.waitingForNr'
|
|
||||||
}
|
|
||||||
values={{
|
|
||||||
nr: elementsInOrder - resultsReceived,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{capitalize(
|
|
||||||
languageNames.of(
|
|
||||||
result?.patient?.preferred_locale ?? 'et',
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<DoctorJobSelect
|
|
||||||
doctorUserId={result.doctor?.primary_owner_user_id}
|
|
||||||
doctorName={getFullName(
|
|
||||||
result.doctor?.name,
|
|
||||||
result.doctor?.last_name,
|
|
||||||
)}
|
|
||||||
analysisOrderId={result.analysis_order_id?.id}
|
|
||||||
userId={result.patient?.id}
|
|
||||||
isRemovable={!isCompleted && isCurrentDoctorJob}
|
|
||||||
onJobUpdate={handleJobUpdate}
|
|
||||||
linkTo={`/${pathsConfig.app.analysisDetails}/${result.id}`}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
|
||||||
<Button
|
|
||||||
onClick={handlePrevPage}
|
|
||||||
disabled={pagination.currentPage === 1 || isPending}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<Trans i18nKey="common:previous" />
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-gray-600">
|
|
||||||
<Trans
|
|
||||||
i18nKey={'common:pageOfPages'}
|
|
||||||
values={{
|
|
||||||
page: pagination.currentPage,
|
|
||||||
total: pagination.totalPages,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
onClick={handleNextPage}
|
|
||||||
disabled={
|
|
||||||
pagination.currentPage === pagination.totalPages || isPending
|
|
||||||
}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<Trans i18nKey="common:next" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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 AnalysisFallback from '../../_components/analysis-fallback';
|
|
||||||
import { DoctorGuard } from '../../_components/doctor-guard';
|
|
||||||
import PrepareAiParameters from '../../_components/prepare-ai-parameters';
|
|
||||||
|
|
||||||
async function AnalysisPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{
|
|
||||||
id: string;
|
|
||||||
}>;
|
|
||||||
}) {
|
|
||||||
const { id: analysisOrderId } = await params;
|
|
||||||
const analysisResultDetails = await loadResult(Number(analysisOrderId));
|
|
||||||
|
|
||||||
if (!analysisResultDetails) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (analysisResultDetails) {
|
|
||||||
await createDoctorPageViewLog({
|
|
||||||
action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS,
|
|
||||||
recordKey: analysisOrderId,
|
|
||||||
dataOwnerUserId: analysisResultDetails.patient.userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader />
|
|
||||||
<PageBody className="px-12">
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<AnalysisFallback
|
|
||||||
progress={33}
|
|
||||||
progressTextKey="doctor:loadParameters"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PrepareAiParameters analysisResultDetails={analysisResultDetails} />
|
|
||||||
</Suspense>
|
|
||||||
</PageBody>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DoctorGuard(withI18n(AnalysisPage));
|
|
||||||
const loadResult = cache(getAnalysisResultsForDoctor);
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { getUserDoneResponsesAction } from '@kit/doctor/actions/table-data-fetching-actions';
|
|
||||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
|
||||||
|
|
||||||
import {
|
|
||||||
DoctorPageViewAction,
|
|
||||||
createDoctorPageViewLog,
|
|
||||||
} from '~/lib/services/audit/doctorPageView.service';
|
|
||||||
|
|
||||||
import { DoctorGuard } from '../_components/doctor-guard';
|
|
||||||
import ResultsTableWrapper from '../_components/results-table-wrapper';
|
|
||||||
|
|
||||||
async function CompletedJobsPage() {
|
|
||||||
await createDoctorPageViewLog({
|
|
||||||
action: DoctorPageViewAction.VIEW_DONE_JOBS,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader />
|
|
||||||
<PageBody className="px-12">
|
|
||||||
<ResultsTableWrapper
|
|
||||||
titleKey="doctor:completedReviews"
|
|
||||||
action={getUserDoneResponsesAction}
|
|
||||||
queryKey="doctor-done-jobs"
|
|
||||||
/>
|
|
||||||
</PageBody>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DoctorGuard(CompletedJobsPage);
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { use } from 'react';
|
|
||||||
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
|
||||||
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
|
|
||||||
|
|
||||||
import { loadUserWorkspace } from '../home/(user)/_lib/server/load-user-workspace';
|
|
||||||
import { DoctorSidebar } from './_components/doctor-sidebar';
|
|
||||||
import { DoctorMobileNavigation } from './_components/mobile-navigation';
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: `Doctor`,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export default function DoctorLayout(props: React.PropsWithChildren) {
|
|
||||||
const state = use(getLayoutState());
|
|
||||||
const workspace = use(loadUserWorkspace());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarProvider defaultOpen={state.open}>
|
|
||||||
<Page style={'sidebar'}>
|
|
||||||
<PageNavigation>
|
|
||||||
<DoctorSidebar accounts={workspace.accounts} />
|
|
||||||
</PageNavigation>
|
|
||||||
|
|
||||||
<PageMobileNavigation>
|
|
||||||
<DoctorMobileNavigation />
|
|
||||||
</PageMobileNavigation>
|
|
||||||
|
|
||||||
{props.children}
|
|
||||||
</Page>
|
|
||||||
</SidebarProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getLayoutState() {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const sidebarOpenCookie = cookieStore.get('sidebar:state');
|
|
||||||
|
|
||||||
return {
|
|
||||||
open: sidebarOpenCookie?.value !== 'true',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
|
||||||
|
|
||||||
export default GlobalLoader;
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { getUserInProgressResponsesAction } from '@kit/doctor/actions/table-data-fetching-actions';
|
|
||||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
|
||||||
|
|
||||||
import {
|
|
||||||
DoctorPageViewAction,
|
|
||||||
createDoctorPageViewLog,
|
|
||||||
} from '~/lib/services/audit/doctorPageView.service';
|
|
||||||
|
|
||||||
import { DoctorGuard } from '../_components/doctor-guard';
|
|
||||||
import ResultsTableWrapper from '../_components/results-table-wrapper';
|
|
||||||
|
|
||||||
async function MyReviewsPage() {
|
|
||||||
await createDoctorPageViewLog({
|
|
||||||
action: DoctorPageViewAction.VIEW_OWN_JOBS,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader />
|
|
||||||
<PageBody className="px-12">
|
|
||||||
<ResultsTableWrapper
|
|
||||||
titleKey="doctor:myReviews"
|
|
||||||
action={getUserInProgressResponsesAction}
|
|
||||||
queryKey="doctor-in-progress-jobs"
|
|
||||||
/>
|
|
||||||
</PageBody>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DoctorGuard(MyReviewsPage);
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { getOpenResponsesAction } from '@kit/doctor/actions/table-data-fetching-actions';
|
|
||||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
|
||||||
|
|
||||||
import {
|
|
||||||
DoctorPageViewAction,
|
|
||||||
createDoctorPageViewLog,
|
|
||||||
} from '~/lib/services/audit/doctorPageView.service';
|
|
||||||
|
|
||||||
import { DoctorGuard } from '../_components/doctor-guard';
|
|
||||||
import ResultsTableWrapper from '../_components/results-table-wrapper';
|
|
||||||
|
|
||||||
async function OpenJobsPage() {
|
|
||||||
await createDoctorPageViewLog({
|
|
||||||
action: DoctorPageViewAction.VIEW_OPEN_JOBS,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader />
|
|
||||||
<PageBody className="px-12">
|
|
||||||
<ResultsTableWrapper
|
|
||||||
titleKey="doctor:openReviews"
|
|
||||||
action={getOpenResponsesAction}
|
|
||||||
queryKey="doctor-open-jobs"
|
|
||||||
/>
|
|
||||||
</PageBody>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DoctorGuard(OpenJobsPage);
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
|
||||||
|
|
||||||
import {
|
|
||||||
DoctorPageViewAction,
|
|
||||||
createDoctorPageViewLog,
|
|
||||||
} from '~/lib/services/audit/doctorPageView.service';
|
|
||||||
|
|
||||||
import DoctorDashboard from './_components/doctor-dashboard';
|
|
||||||
import { DoctorGuard } from './_components/doctor-guard';
|
|
||||||
|
|
||||||
async function DoctorPage() {
|
|
||||||
await createDoctorPageViewLog({
|
|
||||||
action: DoctorPageViewAction.VIEW_DASHBOARD,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader />
|
|
||||||
<PageBody className="px-12">
|
|
||||||
<DoctorDashboard />
|
|
||||||
</PageBody>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DoctorGuard(DoctorPage);
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { ArrowLeft, MessageCircle } from 'lucide-react';
|
import { ArrowLeft, MessageCircle } from 'lucide-react';
|
||||||
@@ -21,22 +20,6 @@ const ErrorPage = ({
|
|||||||
}) => {
|
}) => {
|
||||||
useCaptureException(error);
|
useCaptureException(error);
|
||||||
|
|
||||||
// Ignore next.js internal transient navigation errors that occur during auth state changes
|
|
||||||
const isTransientNavigationError =
|
|
||||||
error?.message?.includes('Error in input stream') ||
|
|
||||||
error?.message?.includes('AbortError') ||
|
|
||||||
(error?.name === 'ChunkLoadError');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isTransientNavigationError && typeof window !== 'undefined') {
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
}, [isTransientNavigationError]);
|
|
||||||
|
|
||||||
if (isTransientNavigationError) {
|
|
||||||
return <div />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex h-screen flex-1 flex-col'}>
|
<div className={'flex h-screen flex-1 flex-col'}>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { ArrowLeft, MessageCircle } from 'lucide-react';
|
import { ArrowLeft, MessageCircle } from 'lucide-react';
|
||||||
|
|
||||||
import { useCaptureException } from '@kit/monitoring/hooks';
|
import { useCaptureException } from '@kit/monitoring/hooks';
|
||||||
import { RootProviders } from '@kit/shared/components/root-providers';
|
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Heading } from '@kit/ui/heading';
|
import { Heading } from '@kit/ui/heading';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
||||||
|
import { RootProviders } from '~/components/root-providers';
|
||||||
|
|
||||||
const GlobalErrorPage = ({
|
const GlobalErrorPage = ({
|
||||||
error,
|
error,
|
||||||
@@ -21,22 +20,6 @@ const GlobalErrorPage = ({
|
|||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
useCaptureException(error);
|
useCaptureException(error);
|
||||||
|
|
||||||
// Ignore next.js internal transient navigation errors that occur during auth state changes
|
|
||||||
const isTransientNavigationError =
|
|
||||||
error?.message?.includes('Error in input stream') ||
|
|
||||||
error?.message?.includes('AbortError') ||
|
|
||||||
(error?.name === 'ChunkLoadError');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isTransientNavigationError && typeof window !== 'undefined') {
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
}, [isTransientNavigationError]);
|
|
||||||
|
|
||||||
if (isTransientNavigationError) {
|
|
||||||
return <div />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
const HealthPage = () => {
|
|
||||||
const commit = process.env.NEXT_PUBLIC_COMMIT_HASH;
|
|
||||||
|
|
||||||
return <div>{commit}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HealthPage;
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
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 { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account';
|
|
||||||
import { loadUserAnalysis } from '~/home/(user)/_lib/server/load-user-analysis';
|
|
||||||
import {
|
|
||||||
PageViewAction,
|
|
||||||
createPageViewLog,
|
|
||||||
} from '~/lib/services/audit/pageView.service';
|
|
||||||
|
|
||||||
import Analysis from '../_components/analysis';
|
|
||||||
|
|
||||||
export default 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(),
|
|
||||||
loadUserAnalysis(Number(analysisOrderId)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!account?.id) {
|
|
||||||
return redirect('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
await createPageViewLog({
|
|
||||||
accountId: account.id,
|
|
||||||
action: PageViewAction.VIEW_ANALYSIS_RESULTS,
|
|
||||||
});
|
|
||||||
|
|
||||||
await notificationsApi.dismissNotification(
|
|
||||||
`/home/analysis-results/${analysisOrderId}`,
|
|
||||||
'link',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!analysisResponse) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title={<Trans i18nKey="analysis-results:pageTitle" />}
|
|
||||||
description={<Trans i18nKey="analysis-results:descriptionEmpty" />}
|
|
||||||
/>
|
|
||||||
<PageBody className="gap-4"></PageBody>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderedAnalysisElements = analysisResponse.orderedAnalysisElements;
|
|
||||||
const hasOrderedAnalysisElements = orderedAnalysisElements.length > 0;
|
|
||||||
const isPartialStatus =
|
|
||||||
analysisResponse.order.status === 'PARTIAL_ANALYSIS_RESPONSE';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title={<Trans i18nKey="analysis-results:pageTitle" />}
|
|
||||||
description={
|
|
||||||
hasOrderedAnalysisElements ? (
|
|
||||||
isPartialStatus ? (
|
|
||||||
<Trans i18nKey="analysis-results:descriptionPartial" />
|
|
||||||
) : (
|
|
||||||
<Trans i18nKey="analysis-results:description" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Trans i18nKey="analysis-results:descriptionEmpty" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href={pathsConfig.app.orderAnalysisPackage}>
|
|
||||||
<Trans i18nKey="analysis-results:orderNewAnalysis" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</PageHeader>
|
|
||||||
<PageBody className="gap-4 pt-4">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<h5 className="break-all">
|
|
||||||
<Trans
|
|
||||||
i18nKey="analysis-results:orderTitle"
|
|
||||||
values={{ orderNumber: analysisResponse.order.medusaOrderId }}
|
|
||||||
/>
|
|
||||||
</h5>
|
|
||||||
<h6>
|
|
||||||
<Trans i18nKey={`orders:status.${analysisResponse.order.status}`} />
|
|
||||||
<ButtonTooltip
|
|
||||||
content={
|
|
||||||
<Trans
|
|
||||||
i18nKey="analysis-results:orderCreatedAt"
|
|
||||||
values={{
|
|
||||||
createdAt: formatDateAndTime(analysisResponse.order.createdAt)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
className="ml-6"
|
|
||||||
/>
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
{analysisResponse?.summary?.value && (
|
|
||||||
<div>
|
|
||||||
<strong>
|
|
||||||
<Trans i18nKey="account:doctorAnalysisSummary" />
|
|
||||||
</strong>
|
|
||||||
<p>{analysisResponse.summary.value}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{orderedAnalysisElements ? (
|
|
||||||
orderedAnalysisElements.map((element) => (
|
|
||||||
<React.Fragment key={element.analysisIdOriginal}>
|
|
||||||
<Analysis element={element} />
|
|
||||||
{element.results?.nestedElements?.map(
|
|
||||||
(nestedElement, nestedIndex) => (
|
|
||||||
<Analysis
|
|
||||||
key={`nested-${nestedElement.analysisElementOriginalId}-${nestedIndex}`}
|
|
||||||
nestedElement={nestedElement}
|
|
||||||
isNestedElement
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
<Trans i18nKey="analysis-results:noAnalysisElements" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PageBody>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import type { AnalysisResultDetailsElementResults } from '@/packages/features/user-analyses/src/types/analysis-results';
|
|
||||||
import { AnalysisResultLevel } from '@/packages/features/user-analyses/src/types/analysis-results';
|
|
||||||
import { ArrowDown } from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '@kit/ui/utils';
|
|
||||||
|
|
||||||
type AnalysisResultLevelBarResults = Pick<
|
|
||||||
AnalysisResultDetailsElementResults,
|
|
||||||
'normLower' | 'normUpper' | 'responseValue'
|
|
||||||
>;
|
|
||||||
|
|
||||||
const Level = ({
|
|
||||||
isActive = false,
|
|
||||||
color,
|
|
||||||
isFirst = false,
|
|
||||||
isLast = false,
|
|
||||||
arrowLocation,
|
|
||||||
normRangeText,
|
|
||||||
}: {
|
|
||||||
isActive?: boolean;
|
|
||||||
color: 'destructive' | 'success' | 'warning' | 'gray-200';
|
|
||||||
isFirst?: boolean;
|
|
||||||
isLast?: boolean;
|
|
||||||
arrowLocation?: number;
|
|
||||||
normRangeText?: string | null;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(`bg-${color} relative h-3 flex-1`, {
|
|
||||||
'opacity-60': !isActive,
|
|
||||||
'rounded-l-lg': isFirst,
|
|
||||||
'rounded-r-lg': isLast,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{isActive && (
|
|
||||||
<div
|
|
||||||
className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]"
|
|
||||||
{...(arrowLocation
|
|
||||||
? {
|
|
||||||
style: {
|
|
||||||
left: `${arrowLocation}%`,
|
|
||||||
...(arrowLocation > 92.5 && { left: '92.5%' }),
|
|
||||||
...(arrowLocation < 7.5 && { left: '7.5%' }),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {})}
|
|
||||||
>
|
|
||||||
<ArrowDown strokeWidth={2} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{color === 'success' && typeof normRangeText === 'string' && (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-muted-foreground absolute bottom-[-18px] left-3/8 text-xs font-bold whitespace-nowrap',
|
|
||||||
{
|
|
||||||
'opacity-60': isActive,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{normRangeText}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AnalysisLevelBar = ({
|
|
||||||
level,
|
|
||||||
results: { normLower: lower, normUpper: upper, responseValue: value },
|
|
||||||
normRangeText,
|
|
||||||
}: {
|
|
||||||
level: AnalysisResultLevel;
|
|
||||||
results: AnalysisResultLevelBarResults;
|
|
||||||
normRangeText: string | null;
|
|
||||||
}) => {
|
|
||||||
// Calculate arrow position based on value within normal range
|
|
||||||
const arrowLocation = useMemo(() => {
|
|
||||||
// If no response value, center the arrow
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no normal ranges defined, center the arrow
|
|
||||||
if (lower === null && upper === null) {
|
|
||||||
return 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If only upper bound exists
|
|
||||||
if (lower === null && upper !== null) {
|
|
||||||
if (value <= upper) {
|
|
||||||
return Math.min(75, (value / upper) * 75); // Show in left 75% of normal range
|
|
||||||
}
|
|
||||||
return 100; // Beyond upper bound
|
|
||||||
}
|
|
||||||
|
|
||||||
// If only lower bound exists
|
|
||||||
if (upper === null && lower !== null) {
|
|
||||||
if (value >= lower) {
|
|
||||||
// Value is in normal range (above lower bound)
|
|
||||||
// Position proportionally in the normal range section
|
|
||||||
const normalizedPosition = Math.min((value - lower) / (lower * 0.5), 1); // Use 50% of lower as scale
|
|
||||||
return normalizedPosition * 100;
|
|
||||||
}
|
|
||||||
// Value is below lower bound - position in the "below normal" section
|
|
||||||
const belowPosition = Math.max(0, Math.min(1, value / lower));
|
|
||||||
return belowPosition * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both bounds exist
|
|
||||||
if (lower !== null && upper !== null) {
|
|
||||||
if (value < lower) {
|
|
||||||
return 0; // Below normal range
|
|
||||||
}
|
|
||||||
if (value > upper) {
|
|
||||||
return 100; // Above normal range
|
|
||||||
}
|
|
||||||
// Within normal range
|
|
||||||
return ((value - lower) / (upper - lower)) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 50; // Fallback
|
|
||||||
}, [value, upper, lower]);
|
|
||||||
|
|
||||||
// Determine level states based on normStatus
|
|
||||||
const isNormal = level === AnalysisResultLevel.NORMAL;
|
|
||||||
const isWarning = level === AnalysisResultLevel.WARNING;
|
|
||||||
const isCritical = level === AnalysisResultLevel.CRITICAL;
|
|
||||||
const isPending = level === null;
|
|
||||||
|
|
||||||
// If pending results, show gray bar
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="w-60% mt-4 flex h-3 max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
|
|
||||||
<Level color="gray-200" isFirst isLast />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show appropriate levels based on available norm bounds
|
|
||||||
const hasLowerBound = lower !== null;
|
|
||||||
const hasUpperBound = upper !== null;
|
|
||||||
|
|
||||||
// Determine which section the value falls into
|
|
||||||
const isValueBelowLower = hasLowerBound && value !== null && value < lower!;
|
|
||||||
const isValueAboveUpper = hasUpperBound && value !== null && value > upper!;
|
|
||||||
const isValueInNormalRange = !isValueBelowLower && !isValueAboveUpper;
|
|
||||||
|
|
||||||
const [first, second, third] = useMemo(() => {
|
|
||||||
const [warning, normal, critical] = [
|
|
||||||
{
|
|
||||||
isActive: isWarning,
|
|
||||||
color: 'warning',
|
|
||||||
...(isWarning ? { arrowLocation } : {}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isActive: isNormal,
|
|
||||||
color: 'success',
|
|
||||||
normRangeText,
|
|
||||||
...(isNormal ? { arrowLocation } : {}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isActive: isCritical,
|
|
||||||
color: 'destructive',
|
|
||||||
isLast: true,
|
|
||||||
...(isCritical ? { arrowLocation } : {}),
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
if (!hasLowerBound) {
|
|
||||||
return [{ ...normal, isFirst: true }, warning, critical] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{ ...warning, isFirst: true },
|
|
||||||
normal,
|
|
||||||
{ ...critical, isLast: true },
|
|
||||||
] as const;
|
|
||||||
}, [
|
|
||||||
isValueBelowLower,
|
|
||||||
isValueAboveUpper,
|
|
||||||
isValueInNormalRange,
|
|
||||||
arrowLocation,
|
|
||||||
normRangeText,
|
|
||||||
isNormal,
|
|
||||||
isWarning,
|
|
||||||
isCritical,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-3 gap-1',
|
|
||||||
'mt-4 sm:mt-0',
|
|
||||||
'w-[60%] sm:w-[35%]',
|
|
||||||
'min-w-[50vw] sm:min-w-auto',
|
|
||||||
'max-w-[360px]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Level {...first} />
|
|
||||||
<Level {...second} />
|
|
||||||
<Level {...third} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AnalysisLevelBar;
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AnalysisResultDetailsElement,
|
|
||||||
AnalysisResultsDetailsElementNested,
|
|
||||||
} from '@/packages/features/user-analyses/src/types/analysis-results';
|
|
||||||
import { AnalysisResultLevel } from '@/packages/features/user-analyses/src/types/analysis-results';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { Info } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
|
||||||
import { cn } from '@kit/ui/utils';
|
|
||||||
|
|
||||||
import AnalysisLevelBar from './analysis-level-bar';
|
|
||||||
|
|
||||||
const Analysis = ({
|
|
||||||
element: elementOriginal,
|
|
||||||
nestedElement,
|
|
||||||
isNestedElement = false,
|
|
||||||
}: {
|
|
||||||
element?: AnalysisResultDetailsElement;
|
|
||||||
nestedElement?: AnalysisResultsDetailsElementNested;
|
|
||||||
isNestedElement?: boolean;
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const element = (() => {
|
|
||||||
if (isNestedElement) {
|
|
||||||
return nestedElement!;
|
|
||||||
}
|
|
||||||
return elementOriginal!;
|
|
||||||
})();
|
|
||||||
const results: AnalysisResultDetailsElement['results'] = useMemo(() => {
|
|
||||||
if (isNestedElement) {
|
|
||||||
const nestedElement = element as AnalysisResultsDetailsElementNested;
|
|
||||||
return {
|
|
||||||
analysisElementOriginalId: nestedElement.analysisElementOriginalId,
|
|
||||||
normLower: nestedElement.normLower,
|
|
||||||
normUpper: nestedElement.normUpper,
|
|
||||||
normStatus: nestedElement.normStatus,
|
|
||||||
responseTime: nestedElement.responseTime,
|
|
||||||
responseValue: nestedElement.responseValue,
|
|
||||||
responseValueIsNegative: nestedElement.responseValueIsNegative,
|
|
||||||
responseValueIsWithinNorm: nestedElement.responseValueIsWithinNorm,
|
|
||||||
normLowerIncluded: nestedElement.normLowerIncluded,
|
|
||||||
normUpperIncluded: nestedElement.normUpperIncluded,
|
|
||||||
unit: nestedElement.unit,
|
|
||||||
status: nestedElement.status,
|
|
||||||
nestedElements: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return (element as AnalysisResultDetailsElement).results;
|
|
||||||
}, [element, isNestedElement]);
|
|
||||||
|
|
||||||
const name = element.analysisName || '';
|
|
||||||
|
|
||||||
const hasIsWithinNorm = results?.responseValueIsWithinNorm !== null;
|
|
||||||
const hasIsNegative = results?.responseValueIsNegative !== null;
|
|
||||||
|
|
||||||
const value = (() => {
|
|
||||||
if (!results) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
responseValue,
|
|
||||||
responseValueIsNegative,
|
|
||||||
responseValueIsWithinNorm,
|
|
||||||
} = results;
|
|
||||||
if (responseValue === null || responseValue === undefined) {
|
|
||||||
if (hasIsNegative) {
|
|
||||||
if (responseValueIsNegative) {
|
|
||||||
return t('analysis-results:results.value.negative');
|
|
||||||
}
|
|
||||||
return t('analysis-results:results.value.positive');
|
|
||||||
}
|
|
||||||
if (hasIsWithinNorm) {
|
|
||||||
if (responseValueIsWithinNorm) {
|
|
||||||
return t('analysis-results:results.value.isWithinNorm');
|
|
||||||
}
|
|
||||||
return t('analysis-results:results.value.isNotWithinNorm');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseValue;
|
|
||||||
})();
|
|
||||||
const unit = results?.unit || '';
|
|
||||||
const normLower = results?.normLower ?? null;
|
|
||||||
const normUpper = results?.normUpper ?? null;
|
|
||||||
const normStatus = results?.normStatus ?? null;
|
|
||||||
|
|
||||||
const [showTooltip, setShowTooltip] = useState(false);
|
|
||||||
const analysisResultLevel = useMemo(() => {
|
|
||||||
if (normStatus === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (normStatus) {
|
|
||||||
case 1:
|
|
||||||
return AnalysisResultLevel.WARNING;
|
|
||||||
case 2:
|
|
||||||
return AnalysisResultLevel.CRITICAL;
|
|
||||||
case 0:
|
|
||||||
default:
|
|
||||||
return AnalysisResultLevel.NORMAL;
|
|
||||||
}
|
|
||||||
}, [normStatus]);
|
|
||||||
|
|
||||||
const isCancelled = Number(results?.status) === 5;
|
|
||||||
const nestedElements = results?.nestedElements ?? null;
|
|
||||||
const hasNestedElements =
|
|
||||||
Array.isArray(nestedElements) && nestedElements.length > 0;
|
|
||||||
|
|
||||||
const normRangeText = (() => {
|
|
||||||
if (normLower === null && normUpper === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return `${normLower ?? '...'} - ${normUpper ?? '...'}`;
|
|
||||||
})();
|
|
||||||
const hasTextualResponse = hasIsNegative || hasIsWithinNorm;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn('border-border rounded-lg border px-5', {
|
|
||||||
'ml-8': isNestedElement,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-between gap-2 pt-3 pb-6 sm:h-[65px] sm:flex-row sm:gap-0 sm:py-3">
|
|
||||||
<div
|
|
||||||
className={cn('flex items-center gap-2 font-semibold', {
|
|
||||||
'font-bold': isNestedElement,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
{results?.responseTime && (
|
|
||||||
<div
|
|
||||||
className="group/tooltip relative"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowTooltip(!showTooltip);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => setShowTooltip(false)}
|
|
||||||
>
|
|
||||||
<Info className="hover" />{' '}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 rounded border bg-white p-4 text-sm whitespace-nowrap group-hover/tooltip:block',
|
|
||||||
{ block: showTooltip },
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Trans i18nKey="analysis-results:analysisDate" />
|
|
||||||
{': '}
|
|
||||||
{format(new Date(results.responseTime), 'dd.MM.yyyy HH:mm')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isCancelled && (
|
|
||||||
<div className="text-sm font-semibold text-red-600">
|
|
||||||
<Trans i18nKey="analysis-results:cancelled" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isCancelled || !results || hasNestedElements ? null : (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-3 sm:ml-auto">
|
|
||||||
<div
|
|
||||||
className={cn('font-semibold', {
|
|
||||||
'text-yellow-600':
|
|
||||||
hasTextualResponse &&
|
|
||||||
analysisResultLevel === AnalysisResultLevel.WARNING,
|
|
||||||
'text-red-600':
|
|
||||||
hasTextualResponse &&
|
|
||||||
analysisResultLevel === AnalysisResultLevel.CRITICAL,
|
|
||||||
'text-green-600':
|
|
||||||
hasTextualResponse &&
|
|
||||||
analysisResultLevel === AnalysisResultLevel.NORMAL,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground text-sm">{unit}</div>
|
|
||||||
</div>
|
|
||||||
{!hasTextualResponse && (
|
|
||||||
<>
|
|
||||||
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
|
|
||||||
{normRangeText}
|
|
||||||
<div>
|
|
||||||
<Trans i18nKey="analysis-results:results.range.normal" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AnalysisLevelBar
|
|
||||||
results={results}
|
|
||||||
level={analysisResultLevel!}
|
|
||||||
normRangeText={normRangeText}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Analysis;
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
|
|
||||||
import Modal from '@modules/common/components/modal';
|
|
||||||
|
|
||||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
|
||||||
import { Button } from '@kit/ui/shadcn/button';
|
|
||||||
import { Trans } from '@kit/ui/trans';
|
|
||||||
|
|
||||||
import Analysis from '../_components/analysis';
|
|
||||||
import { analysisResponses } from './test-responses';
|
|
||||||
|
|
||||||
export default function AnalysisResultsPage() {
|
|
||||||
const [openBlocks, setOpenBlocks] = useState<number[]>([]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader />
|
|
||||||
<PageBody className="gap-4">
|
|
||||||
<div className="mt-8 flex flex-col justify-between gap-4 sm:flex-row sm:items-center sm:gap-0">
|
|
||||||
<div>
|
|
||||||
<h2>Analüüsi tulemused demo</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{analysisResponses.map(({ id, orderedAnalysisElements }, index) => {
|
|
||||||
const isOpen = openBlocks.includes(id);
|
|
||||||
const closeModal = () =>
|
|
||||||
setOpenBlocks(openBlocks.filter((block) => block !== id));
|
|
||||||
return (
|
|
||||||
<div key={index} className="flex flex-col gap-2 py-4">
|
|
||||||
<div className="flex flex-col gap-2 pb-4">
|
|
||||||
<h3>AnalysisOrderId: {id}</h3>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h5>OrderedAnalysisElements</h5>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
if (isOpen) {
|
|
||||||
closeModal();
|
|
||||||
} else {
|
|
||||||
setOpenBlocks([...openBlocks, id]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-fit"
|
|
||||||
color={isOpen ? 'orange' : 'grey'}
|
|
||||||
>
|
|
||||||
{isOpen ? 'Close' : 'Open'}
|
|
||||||
</Button>
|
|
||||||
{isOpen && (
|
|
||||||
<Modal isOpen={isOpen} close={closeModal} size="large">
|
|
||||||
<div className="overflow-y-auto">
|
|
||||||
<p>NormiStaatus</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
0 - testi väärtus jääb normaalväärtuste piirkonda
|
|
||||||
või on määramata,
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
1 - testi väärtus jääb hoiatava (tähelepanu
|
|
||||||
suunava) märkega piirkonda,
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
2 - testi väärtus on normaalväärtuste piirkonnast
|
|
||||||
väljas või kõrgendatud tähelepanu nõudvas
|
|
||||||
piirkonnas.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>UuringOlek</p>
|
|
||||||
<ul>
|
|
||||||
<li>1 - Järjekorras,</li>
|
|
||||||
<li>2 - Ootel,</li>
|
|
||||||
<li>3 - Töös,</li>
|
|
||||||
<li>4 - Lõpetatud,</li>
|
|
||||||
<li>5 - Tagasi lükatud,</li>
|
|
||||||
<li>6 - Tühistatud,</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<pre className="bg-muted rounded-md p-4 text-sm">
|
|
||||||
{JSON.stringify(orderedAnalysisElements, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{orderedAnalysisElements ? (
|
|
||||||
orderedAnalysisElements.map((element, index) => (
|
|
||||||
<Analysis key={index} element={element} />
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
<Trans i18nKey="analysis-results:noAnalysisElements" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</PageBody>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user