Compare commits
48 Commits
5cf29447b3
...
keycloak
| Author | SHA1 | Date | |
|---|---|---|---|
| bc61db07b2 | |||
| 3d58e5aa84 | |||
| 8ecca096f2 | |||
| 84c8dcc792 | |||
| 40ffbf29a5 | |||
| b046b6ab16 | |||
|
|
8cb9d7552c | ||
|
|
cdb638f046 | ||
|
|
a587b222b9 | ||
|
|
612673ddf9 | ||
|
|
3230dd7608 | ||
|
|
e4d7c874cc | ||
|
|
b7926f79a9 | ||
|
|
9d62a2d86f | ||
|
|
3498406a0c | ||
|
|
56a832b96b | ||
|
|
568104eaff | ||
|
|
2eac3a6836 | ||
| 2df366c14a | |||
|
|
6ce60eacc7 | ||
|
|
6e76e75e85 | ||
| 71c3e2ef1e | |||
| d83319a094 | |||
|
|
505ef0d91b | ||
|
|
fce4355be8 | ||
|
|
da9658ad7a | ||
|
|
e023d54a2a | ||
|
|
bdaacbe78a | ||
|
|
5479f310d7 | ||
| 815b877b5b | |||
| 0c28f9681b | |||
| 70b85dc967 | |||
| 71f5a25632 | |||
| d072226a5c | |||
| da7f574234 | |||
| b3505c1627 | |||
|
|
ad28352fc8 | ||
| b931035c3b | |||
| f723633646 | |||
| 6e6ad13b52 | |||
|
|
31bc4b6cff | ||
| 3ddc0a2716 | |||
| 49eeaa1876 | |||
| 2ffad84100 | |||
| b4985afdf0 | |||
| a37c4cad9c | |||
| 47ab39172e | |||
| d760f86632 |
2
.env
2
.env
@@ -13,7 +13,7 @@ NEXT_PUBLIC_THEME_COLOR="#ffffff"
|
|||||||
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
|
NEXT_PUBLIC_THEME_COLOR_DARK="#0a0a0a"
|
||||||
|
|
||||||
# AUTH
|
# AUTH
|
||||||
NEXT_PUBLIC_AUTH_PASSWORD=true
|
NEXT_PUBLIC_AUTH_PASSWORD=false
|
||||||
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
|
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
|
||||||
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
|
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
|
||||||
|
|
||||||
|
|||||||
@@ -36,3 +36,57 @@ MONTONIO_API_URL=https://sandbox-stargate.montonio.com
|
|||||||
|
|
||||||
# JOBS
|
# JOBS
|
||||||
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197
|
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197
|
||||||
|
|
||||||
|
# MEDIPOST
|
||||||
|
|
||||||
|
MEDIPOST_URL=https://meditest.medisoft.ee:7443/Medipost/MedipostServlet
|
||||||
|
MEDIPOST_USER=trvurgtst
|
||||||
|
MEDIPOST_PASSWORD=SRB48HZMV
|
||||||
|
MEDIPOST_RECIPIENT=trvurgtst
|
||||||
|
MEDIPOST_MESSAGE_SENDER=trvurgtst
|
||||||
|
|
||||||
|
MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet
|
||||||
|
MEDIPOST_USER=medreport
|
||||||
|
MEDIPOST_PASSWORD=85MXFFDB7
|
||||||
|
MEDIPOST_RECIPIENT=HTI
|
||||||
|
MEDIPOST_MESSAGE_SENDER=medreport
|
||||||
|
|
||||||
|
### TEST.MEDREPORT.ee ###
|
||||||
|
|
||||||
|
DB_PASSWORD=T#u-$M7%RjbA@L@
|
||||||
|
|
||||||
|
#### MEDUSA
|
||||||
|
MEDUSA_BACKEND_URL=https://backoffice-test.medreport.ee
|
||||||
|
MEDUSA_BACKEND_PUBLIC_URL=https://backoffice-test.medreport.ee
|
||||||
|
MEDUSA_SECRET_API_KEY=sk_fdb1808fbabf62979cc46316aa997378ffbb87882883e8f5c3ee47cee39dcac5
|
||||||
|
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_827a2ab863021cb67993f1d81078f81bfce4b4e0da642d8c0f5398ded9d8fd32
|
||||||
|
|
||||||
|
#### SUPABASE
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NjUyODEyMywiZXhwIjoyMDYyMTA0MTIzfQ.KVcnkZ21Pd0XkJho23dZqFHawVTLQqfvF7l2RxsELLk
|
||||||
|
|
||||||
|
#######
|
||||||
|
|
||||||
|
### LOCAL ###
|
||||||
|
|
||||||
|
#### MEDUSA
|
||||||
|
MEDUSA_BACKEND_URL=http://localhost:9000
|
||||||
|
MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000
|
||||||
|
#MEDUSA_BACKEND_URL=http://5.181.51.38:9000
|
||||||
|
#MEDUSA_BACKEND_PUBLIC_URL=http://5.181.51.38:9000
|
||||||
|
MEDUSA_SECRET_API_KEY=sk_b332d525212ab4078ef73fb2b8232c3beebccc4a460e2c7abf6e187a458d60cf
|
||||||
|
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_e23a820689a07d55aa0a0ad187268559f5d6288ecb0768ff4520516285bdef84
|
||||||
|
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_0ec86252438b38ce18d5601f7877e4395d7e0a6afa8687dfea8d37af33015633
|
||||||
|
|
||||||
|
#### SUPABASE
|
||||||
|
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
|
||||||
|
|
||||||
|
#######
|
||||||
|
|
||||||
|
SUPABASE_AUTH_CLIENT_ID=supabase
|
||||||
|
SUPABASE_AUTH_KEYCLOAK_SECRET=Gl394GjizClhQl06KFeoFyZ7ZbPamG5I
|
||||||
|
SUPABASE_AUTH_KEYCLOAK_URL=http://localhost:8585/realms/medreport-sandbox
|
||||||
|
SUPABASE_AUTH_KEYCLOAK_CALLBACK_URL=http://localhost:3000/auth/callback
|
||||||
|
|||||||
15
.env.staging
Normal file
15
.env.staging
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 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://kaldvociniytdbbcxvqk.supabase.co
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImthbGR2b2Npbml5dGRiYmN4dnFrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTYzNjQ5OTYsImV4cCI6MjA3MTk0MDk5Nn0.eixihH2KGkJZolY9FiQDicJOo2kxvXrSe6gGUCrkLo0
|
||||||
|
|
||||||
|
NEXT_PUBLIC_SITE_URL=https://test.medreport.ee
|
||||||
|
|
||||||
|
# MONTONIO
|
||||||
|
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
||||||
@@ -20,3 +20,5 @@ EMAIL_PASSWORD=password
|
|||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf
|
||||||
|
|
||||||
CONTACT_EMAIL=test@makerkit.dev
|
CONTACT_EMAIL=test@makerkit.dev
|
||||||
|
|
||||||
|
SUPABASE_AUTH_KEYCLOAK_URL=https://keycloak.medreport.ee/realms/medreport-prod
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,3 +43,4 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
dump.sql
|
dump.sql
|
||||||
|
data.sql
|
||||||
11
Dockerfile
11
Dockerfile
@@ -11,6 +11,7 @@ COPY packages packages
|
|||||||
COPY tooling tooling
|
COPY tooling tooling
|
||||||
COPY .env .env
|
COPY .env .env
|
||||||
COPY .env.production .env.production
|
COPY .env.production .env.production
|
||||||
|
COPY .env.staging .env.staging
|
||||||
|
|
||||||
# Load env file and echo a specific variable
|
# Load env file and echo a specific variable
|
||||||
# RUN dotenv -e .env -- printenv | grep 'SUPABASE' || true
|
# RUN dotenv -e .env -- printenv | grep 'SUPABASE' || true
|
||||||
@@ -20,13 +21,10 @@ COPY . .
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# 🔍 Optional: Log key envs for debug
|
|
||||||
RUN echo "📄 .env.production contents:" && cat .env.production \
|
|
||||||
&& echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true
|
|
||||||
|
|
||||||
RUN set -a \
|
RUN set -a \
|
||||||
&& . .env \
|
&& . .env \
|
||||||
&& . .env.production \
|
&& . .env.production \
|
||||||
|
&& . .env.staging \
|
||||||
&& set +a \
|
&& set +a \
|
||||||
&& node check-env.js \
|
&& node check-env.js \
|
||||||
&& pnpm build
|
&& pnpm build
|
||||||
@@ -34,18 +32,21 @@ RUN set -a \
|
|||||||
|
|
||||||
# --- Stage 2: Runtime ---
|
# --- Stage 2: Runtime ---
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
ARG APP_ENV=production
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app ./
|
COPY --from=builder /app ./
|
||||||
|
|
||||||
|
RUN cp ".env.${APP_ENV}" .env.local
|
||||||
|
|
||||||
RUN npm install -g pnpm@9 \
|
RUN npm install -g pnpm@9 \
|
||||||
&& pnpm install --prod --frozen-lockfile
|
&& pnpm install --prod --frozen-lockfile
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# 🔍 Optional: Log key envs for debug
|
# 🔍 Optional: Log key envs for debug
|
||||||
RUN echo "📄 .env.production contents:" && cat .env.production \
|
RUN echo "📄 .env contents:" && cat .env.local \
|
||||||
&& echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true
|
&& echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ function AuthButtons() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={'flex gap-x-2.5'}>
|
<div className={'flex gap-x-2.5'}>
|
||||||
<Button className={'hidden md:block'} asChild variant={'ghost'}>
|
<Button className={'block'} asChild variant={'ghost'}>
|
||||||
<Link href={pathsConfig.auth.signIn}>
|
<Link href={pathsConfig.auth.signIn}>
|
||||||
<Trans i18nKey={'auth:signIn'} />
|
<Trans i18nKey={'auth:signIn'} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { SubmitButton } from '@kit/shared/components/ui/submit-button';
|
import { sendEmailFromTemplate } from '@/lib/services/mailer.service';
|
||||||
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';
|
||||||
@@ -39,7 +40,14 @@ const CompanyOfferForm = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sendCompanyOfferEmail(data, language)
|
sendEmailFromTemplate(
|
||||||
|
renderCompanyOfferEmail,
|
||||||
|
{
|
||||||
|
companyData: data,
|
||||||
|
language,
|
||||||
|
},
|
||||||
|
process.env.CONTACT_EMAIL!,
|
||||||
|
)
|
||||||
.then(() => router.push('/company-offer/success'))
|
.then(() => router.push('/company-offer/success'))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
23
app/api/job/handler/send-open-jobs-emails.ts
Normal file
23
app/api/job/handler/send-open-jobs-emails.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
const doctorAccounts = await getDoctorAccounts();
|
||||||
|
const doctorEmails: string[] = doctorAccounts
|
||||||
|
.map(({ email }) => email)
|
||||||
|
.filter((email): email is string => !!email);
|
||||||
|
|
||||||
|
await sendEmailFromTemplate(
|
||||||
|
renderNewJobsAvailableEmail,
|
||||||
|
{
|
||||||
|
language: 'et',
|
||||||
|
analysisResponseIds,
|
||||||
|
},
|
||||||
|
doctorEmails,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -164,10 +164,15 @@ export default async function syncAnalysisGroups() {
|
|||||||
console.info('Inserting sync entry');
|
console.info('Inserting sync entry');
|
||||||
await createSyncSuccessEntry();
|
await createSyncSuccessEntry();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await createSyncFailEntry(JSON.stringify(e));
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
console.error(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(
|
throw new Error(
|
||||||
`Failed to sync public message data, error: ${JSON.stringify(e)}`,
|
`Failed to sync public message data, error: ${errorMessage}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,19 +71,19 @@ export default async function syncConnectedOnline() {
|
|||||||
return {
|
return {
|
||||||
id: service.ID,
|
id: service.ID,
|
||||||
clinic_id: service.ClinicID,
|
clinic_id: service.ClinicID,
|
||||||
code: service.Code,
|
sync_id: service.SyncID,
|
||||||
description: service.Description || null,
|
|
||||||
display: service.Display,
|
|
||||||
duration: service.Duration,
|
|
||||||
has_free_codes: !!service.HasFreeCodes,
|
|
||||||
name: service.Name,
|
name: service.Name,
|
||||||
|
description: service.Description || null,
|
||||||
|
price: service.Price,
|
||||||
|
requires_payment: !!service.RequiresPayment,
|
||||||
|
duration: service.Duration,
|
||||||
neto_duration: service.NetoDuration,
|
neto_duration: service.NetoDuration,
|
||||||
|
display: service.Display,
|
||||||
|
price_periods: service.PricePeriods || null,
|
||||||
online_hide_duration: service.OnlineHideDuration,
|
online_hide_duration: service.OnlineHideDuration,
|
||||||
online_hide_price: service.OnlineHidePrice,
|
online_hide_price: service.OnlineHidePrice,
|
||||||
price: service.Price,
|
code: service.Code,
|
||||||
price_periods: service.PricePeriods || null,
|
has_free_codes: !!service.HasFreeCodes,
|
||||||
requires_payment: !!service.RequiresPayment,
|
|
||||||
sync_id: service.SyncID,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
53
app/api/job/send-open-jobs-emails/route.ts
Normal file
53
app/api/job/send-open-jobs-emails/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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 (e) {
|
||||||
|
return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendOpenJobsEmails();
|
||||||
|
console.info(
|
||||||
|
'Successfully sent out open job notification emails to doctors.',
|
||||||
|
);
|
||||||
|
await createNotificationLog({
|
||||||
|
action: NotificationAction.NEW_JOBS_ALERT,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'Successfully sent out open job notification emails to doctors.',
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(
|
||||||
|
'Error sending out open job notification emails to doctors.',
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
await createNotificationLog({
|
||||||
|
action: NotificationAction.NEW_JOBS_ALERT,
|
||||||
|
status: 'FAIL',
|
||||||
|
comment: e?.message,
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: 'Failed to send out open job notification emails to doctors.',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -41,8 +41,6 @@ export async function POST(request: NextRequest) {
|
|||||||
orderCreatedAt: new Date(medreportOrder.created_at),
|
orderCreatedAt: new Date(medreportOrder.created_at),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.info("SEND XML", messageXml);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendPrivateMessageTestResponse({ messageXml });
|
await sendPrivateMessageTestResponse({ messageXml });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getOrder } from "~/lib/services/order.service";
|
|||||||
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
|
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
|
||||||
import { retrieveOrder } from "@lib/data";
|
import { retrieveOrder } from "@lib/data";
|
||||||
import { getAccountAdmin } from "~/lib/services/account.service";
|
import { getAccountAdmin } from "~/lib/services/account.service";
|
||||||
import { getOrderedAnalysisElementsIds } from "~/lib/services/medipost.service";
|
import { createMedipostActionLog, getOrderedAnalysisElementsIds } from "~/lib/services/medipost.service";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
// const isDev = process.env.NODE_ENV === 'development';
|
// const isDev = process.env.NODE_ENV === 'development';
|
||||||
@@ -35,6 +35,11 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await createMedipostActionLog({
|
||||||
|
action: 'send_fake_analysis_results_to_medipost',
|
||||||
|
xml: messageXml,
|
||||||
|
medusaOrderId,
|
||||||
|
});
|
||||||
await sendPrivateMessageTestResponse({ messageXml });
|
await sendPrivateMessageTestResponse({ messageXml });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error sending private message test response: ", error);
|
console.error("Error sending private message test response: ", error);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { capitalize } from 'lodash';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions';
|
import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions';
|
||||||
@@ -22,6 +23,9 @@ import {
|
|||||||
doctorAnalysisFeedbackFormSchema,
|
doctorAnalysisFeedbackFormSchema,
|
||||||
} from '@kit/doctor/schema/doctor-analysis.schema';
|
} from '@kit/doctor/schema/doctor-analysis.schema';
|
||||||
import ConfirmationModal from '@kit/shared/components/confirmation-modal';
|
import ConfirmationModal from '@kit/shared/components/confirmation-modal';
|
||||||
|
import {
|
||||||
|
useCurrentLocaleLanguageNames
|
||||||
|
} from '@kit/shared/hooks';
|
||||||
import { getFullName } from '@kit/shared/utils';
|
import { getFullName } from '@kit/shared/utils';
|
||||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
@@ -57,6 +61,8 @@ export default function AnalysisView({
|
|||||||
|
|
||||||
const { data: user } = useUser();
|
const { data: user } = useUser();
|
||||||
|
|
||||||
|
const languageNames = useCurrentLocaleLanguageNames();
|
||||||
|
|
||||||
const isInProgress = !!(
|
const isInProgress = !!(
|
||||||
!!feedback?.status &&
|
!!feedback?.status &&
|
||||||
feedback?.doctor_user_id &&
|
feedback?.doctor_user_id &&
|
||||||
@@ -191,6 +197,12 @@ export default function AnalysisView({
|
|||||||
<Trans i18nKey="doctor:email" />
|
<Trans i18nKey="doctor:email" />
|
||||||
</div>
|
</div>
|
||||||
<div>{patient.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>
|
||||||
<div className="xs:hidden block">
|
<div className="xs:hidden block">
|
||||||
<DoctorJobSelect
|
<DoctorJobSelect
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Eye } from 'lucide-react';
|
|||||||
import { getResultSetName } from '@kit/doctor/lib/helpers';
|
import { getResultSetName } from '@kit/doctor/lib/helpers';
|
||||||
import { ResponseTable } from '@kit/doctor/schema/doctor-analysis.schema';
|
import { ResponseTable } from '@kit/doctor/schema/doctor-analysis.schema';
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
import { useCurrentLocaleLanguageNames } from '@kit/shared/hooks';
|
||||||
import { getFullName } from '@kit/shared/utils';
|
import { getFullName } from '@kit/shared/utils';
|
||||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
@@ -23,7 +24,9 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@kit/ui/table';
|
} from '@kit/ui/table';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import DoctorJobSelect from './doctor-job-select';
|
import DoctorJobSelect from './doctor-job-select';
|
||||||
|
import { capitalize } from 'lodash';
|
||||||
|
|
||||||
export default function ResultsTable({
|
export default function ResultsTable({
|
||||||
results = [],
|
results = [],
|
||||||
@@ -58,6 +61,8 @@ export default function ResultsTable({
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const { data: currentUser } = useUser();
|
const { data: currentUser } = useUser();
|
||||||
|
|
||||||
|
const languageNames = useCurrentLocaleLanguageNames();
|
||||||
|
|
||||||
const fetchPage = async (page: number) => {
|
const fetchPage = async (page: number) => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await fetchAction({
|
const result = await fetchAction({
|
||||||
@@ -116,6 +121,9 @@ export default function ResultsTable({
|
|||||||
<TableHead className="w-20">
|
<TableHead className="w-20">
|
||||||
<Trans i18nKey="doctor:resultsTable.resultsStatus" />
|
<Trans i18nKey="doctor:resultsTable.resultsStatus" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead className="w-20">
|
||||||
|
<Trans i18nKey="doctor:resultsTable.language" />
|
||||||
|
</TableHead>
|
||||||
<TableHead className="w-20">
|
<TableHead className="w-20">
|
||||||
<Trans i18nKey="doctor:resultsTable.assignedTo" />
|
<Trans i18nKey="doctor:resultsTable.assignedTo" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -179,6 +187,11 @@ export default function ResultsTable({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{capitalize(
|
||||||
|
languageNames.of(result?.patient?.preferred_locale ?? 'et'),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<DoctorJobSelect
|
<DoctorJobSelect
|
||||||
doctorUserId={result.doctor?.primary_owner_user_id}
|
doctorUserId={result.doctor?.primary_owner_user_id}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ async function AnalysisPage({
|
|||||||
id: string;
|
id: string;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const { id: analysisResponseId } = await params;
|
const { id: analysisOrderId } = await params;
|
||||||
const analysisResultDetails = await loadResult(Number(analysisResponseId));
|
const analysisResultDetails = await loadResult(Number(analysisOrderId));
|
||||||
|
|
||||||
if (!analysisResultDetails) {
|
if (!analysisResultDetails) {
|
||||||
return null;
|
return null;
|
||||||
@@ -28,7 +28,7 @@ async function AnalysisPage({
|
|||||||
if (analysisResultDetails) {
|
if (analysisResultDetails) {
|
||||||
await createDoctorPageViewLog({
|
await createDoctorPageViewLog({
|
||||||
action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS,
|
action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS,
|
||||||
recordKey: analysisResponseId,
|
recordKey: analysisOrderId,
|
||||||
dataOwnerUserId: analysisResultDetails.patient.userId,
|
dataOwnerUserId: analysisResultDetails.patient.userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -50,3 +50,5 @@ async function AnalysisPage({
|
|||||||
|
|
||||||
export default DoctorGuard(AnalysisPage);
|
export default DoctorGuard(AnalysisPage);
|
||||||
const loadResult = cache(getAnalysisResultsForDoctor);
|
const loadResult = cache(getAnalysisResultsForDoctor);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
107
app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx
Normal file
107
app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
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 { 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 account = await loadCurrentUserAccount();
|
||||||
|
|
||||||
|
const { id: analysisResponseId } = await params;
|
||||||
|
|
||||||
|
const analysisResponse = await loadUserAnalysis(Number(analysisResponseId));
|
||||||
|
|
||||||
|
if (!account?.id || !analysisResponse) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createPageViewLog({
|
||||||
|
accountId: account.id,
|
||||||
|
action: PageViewAction.VIEW_ANALYSIS_RESULTS,
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
<h4>
|
||||||
|
<Trans i18nKey="analysis-results:pageTitle" />
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{analysisResponse?.elements &&
|
||||||
|
analysisResponse.elements?.length > 0 ? (
|
||||||
|
<Trans i18nKey="analysis-results:description" />
|
||||||
|
) : (
|
||||||
|
<Trans i18nKey="analysis-results:descriptionEmpty" />
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={pathsConfig.app.orderAnalysisPackage}>
|
||||||
|
<Trans i18nKey="analysis-results:orderNewAnalysis" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h4>
|
||||||
|
<Trans
|
||||||
|
i18nKey="analysis-results:orderTitle"
|
||||||
|
values={{ orderNumber: analysisResponse.order.medusa_order_id }}
|
||||||
|
/>
|
||||||
|
</h4>
|
||||||
|
<h5>
|
||||||
|
<Trans
|
||||||
|
i18nKey={`orders:status.${analysisResponse.order.status}`}
|
||||||
|
/>
|
||||||
|
<ButtonTooltip
|
||||||
|
content={`${analysisResponse.order.created_at ? new Date(analysisResponse?.order?.created_at).toLocaleString() : ''}`}
|
||||||
|
className="ml-6"
|
||||||
|
/>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
{analysisResponse?.summary?.value && (
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
<Trans i18nKey="account:doctorAnalysisSummary" />
|
||||||
|
</strong>
|
||||||
|
<p>{analysisResponse.summary.value}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{analysisResponse.elements ? (
|
||||||
|
analysisResponse.elements.map((element, index) => (
|
||||||
|
<Analysis
|
||||||
|
key={index}
|
||||||
|
analysisElement={{ analysis_name_lab: element.analysis_name }}
|
||||||
|
results={element}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
<Trans i18nKey="analysis-results:noAnalysisElements" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
import { loadCurrentUserAccount } from '@/app/home/(user)/_lib/server/load-user-account';
|
|
||||||
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
|
|
||||||
import { withI18n } from '@/lib/i18n/with-i18n';
|
|
||||||
|
|
||||||
import { Trans } from '@kit/ui/makerkit/trans';
|
|
||||||
import { PageBody } from '@kit/ui/page';
|
|
||||||
import { Button } from '@kit/ui/shadcn/button';
|
|
||||||
|
|
||||||
import { pathsConfig } from '@kit/shared/config';
|
|
||||||
|
|
||||||
import { getAnalysisElements } from '~/lib/services/analysis-element.service';
|
|
||||||
import {
|
|
||||||
PageViewAction,
|
|
||||||
createPageViewLog,
|
|
||||||
} from '~/lib/services/audit/pageView.service';
|
|
||||||
import { AnalysisOrder, getAnalysisOrders } from '~/lib/services/order.service';
|
|
||||||
import { ButtonTooltip } from '@kit/shared/components/ui/button-tooltip';
|
|
||||||
|
|
||||||
import { loadUserAnalysis } from '../../_lib/server/load-user-analysis';
|
|
||||||
import Analysis from './_components/analysis';
|
|
||||||
|
|
||||||
export const generateMetadata = async () => {
|
|
||||||
const i18n = await createI18nServerInstance();
|
|
||||||
const title = i18n.t('analysis-results:pageTitle');
|
|
||||||
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
async function AnalysisResultsPage() {
|
|
||||||
const account = await loadCurrentUserAccount();
|
|
||||||
if (!account) {
|
|
||||||
throw new Error('Account not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const analysisResponses = await loadUserAnalysis();
|
|
||||||
const analysisResponseElements = analysisResponses?.flatMap(
|
|
||||||
({ elements }) => elements,
|
|
||||||
);
|
|
||||||
|
|
||||||
const analysisOrders = await getAnalysisOrders().catch(() => null);
|
|
||||||
|
|
||||||
if (!analysisOrders) {
|
|
||||||
redirect(pathsConfig.auth.signIn);
|
|
||||||
}
|
|
||||||
|
|
||||||
await createPageViewLog({
|
|
||||||
accountId: account.id,
|
|
||||||
action: PageViewAction.VIEW_ANALYSIS_RESULTS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const getAnalysisElementIds = (analysisOrders: AnalysisOrder[]) => [
|
|
||||||
...new Set(analysisOrders?.flatMap((order) => order.analysis_element_ids).filter(Boolean) as number[]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const analysisElementIds = getAnalysisElementIds(analysisOrders);
|
|
||||||
const analysisElements = await getAnalysisElements({ ids: analysisElementIds });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageBody className="gap-4">
|
|
||||||
<div className="mt-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0">
|
|
||||||
<div>
|
|
||||||
<h4>
|
|
||||||
<Trans i18nKey="analysis-results:pageTitle" />
|
|
||||||
</h4>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{analysisResponses && analysisResponses.length > 0 ? (
|
|
||||||
<Trans i18nKey="analysis-results:description" />
|
|
||||||
) : (
|
|
||||||
<Trans i18nKey="analysis-results:descriptionEmpty" />
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href={pathsConfig.app.orderAnalysisPackage}>
|
|
||||||
<Trans i18nKey="analysis-results:orderNewAnalysis" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
{analysisOrders.length > 0 && analysisElements.length > 0 ? analysisOrders.map((analysisOrder) => {
|
|
||||||
const analysisResponse = analysisResponses?.find((response) => response.analysis_order_id === analysisOrder.id);
|
|
||||||
const analysisElementIds = getAnalysisElementIds([analysisOrder]);
|
|
||||||
const analysisElementsForOrder = analysisElements.filter((element) => analysisElementIds.includes(element.id));
|
|
||||||
return (
|
|
||||||
<div key={analysisOrder.id} className="flex flex-col gap-4">
|
|
||||||
<h4>
|
|
||||||
<Trans i18nKey="analysis-results:orderTitle" values={{ orderNumber: analysisOrder.medusa_order_id }} />
|
|
||||||
</h4>
|
|
||||||
<h5>
|
|
||||||
<Trans i18nKey={`orders:status.${analysisOrder.status}`} />
|
|
||||||
<ButtonTooltip
|
|
||||||
content={`${new Date(analysisOrder.created_at).toLocaleString()}`}
|
|
||||||
className="ml-6"
|
|
||||||
/>
|
|
||||||
</h5>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{analysisElementsForOrder.length > 0 ? analysisElementsForOrder.map((analysisElement) => {
|
|
||||||
const results = analysisResponse?.elements.some((element) => element.analysis_element_original_id === analysisElement.analysis_id_original)
|
|
||||||
&& analysisResponseElements?.find((element) => element.analysis_element_original_id === analysisElement.analysis_id_original);
|
|
||||||
if (!results) {
|
|
||||||
return (
|
|
||||||
<Analysis key={`${analysisOrder.id}-${analysisElement.id}`} analysisElement={analysisElement} isCancelled={analysisOrder.status === 'CANCELLED'}/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Analysis key={`${analysisOrder.id}-${analysisElement.id}`} analysisElement={analysisElement} results={results} />
|
|
||||||
);
|
|
||||||
}) : (
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
<Trans i18nKey="analysis-results:noAnalysisElements" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}) : (
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
<Trans i18nKey="analysis-results:noAnalysisOrders" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PageBody>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withI18n(AnalysisResultsPage);
|
|
||||||
41
app/home/(user)/(dashboard)/booking/[handle]/page.tsx
Normal file
41
app/home/(user)/(dashboard)/booking/[handle]/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { HomeLayoutPageHeader } from '@/app/home/(user)/_components/home-page-header';
|
||||||
|
import { loadCategory } from '@/app/home/(user)/_lib/server/load-category';
|
||||||
|
|
||||||
|
import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs';
|
||||||
|
import { PageBody } from '@kit/ui/page';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
export const generateMetadata = async () => {
|
||||||
|
const i18n = await createI18nServerInstance();
|
||||||
|
const title = i18n.t('booking:title');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function BookingHandlePage({ params }: { params: { handle: string } }) {
|
||||||
|
const handle = await params.handle;
|
||||||
|
const { category } = await loadCategory({ handle });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBreadcrumbs
|
||||||
|
values={{
|
||||||
|
[handle]: category?.name || handle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<HomeLayoutPageHeader
|
||||||
|
title={<Trans i18nKey={'booking:title'} />}
|
||||||
|
description={<Trans i18nKey={'booking:description'} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageBody></PageBody>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n(BookingHandlePage);
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
|
import { use } from 'react';
|
||||||
|
|
||||||
|
import { AppBreadcrumbs } from '@kit/ui/makerkit/app-breadcrumbs';
|
||||||
import { PageBody } from '@kit/ui/page';
|
import { PageBody } from '@kit/ui/page';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
|
|
||||||
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
|
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
|
||||||
import OrderCards from '../../_components/order-cards';
|
import OrderCards from '../../_components/order-cards';
|
||||||
|
import ServiceCategories from '../../_components/service-categories';
|
||||||
|
import { loadTtoServices } from '../../_lib/server/load-tto-services';
|
||||||
|
|
||||||
export const generateMetadata = async () => {
|
export const generateMetadata = async () => {
|
||||||
const i18n = await createI18nServerInstance();
|
const i18n = await createI18nServerInstance();
|
||||||
@@ -18,15 +22,30 @@ export const generateMetadata = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function BookingPage() {
|
function BookingPage() {
|
||||||
|
const { heroCategories, ttoCategories } = use(loadTtoServices());
|
||||||
|
|
||||||
|
if (!heroCategories.length && !ttoCategories.length) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBreadcrumbs />
|
||||||
|
<h3 className="mt-8">
|
||||||
|
<Trans i18nKey="booking:noCategories" />
|
||||||
|
</h3>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<AppBreadcrumbs />
|
||||||
<HomeLayoutPageHeader
|
<HomeLayoutPageHeader
|
||||||
title={<Trans i18nKey={'booking:title'} />}
|
title={<Trans i18nKey={'booking:title'} />}
|
||||||
description={<Trans i18nKey={'booking:description'} />}
|
description={<Trans i18nKey={'booking:description'} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PageBody>
|
<PageBody className="space-y-2">
|
||||||
<OrderCards />
|
<OrderCards heroCategories={heroCategories} />
|
||||||
|
<ServiceCategories categories={ttoCategories} />
|
||||||
</PageBody>
|
</PageBody>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,30 +1,28 @@
|
|||||||
import { Trans } from '@kit/ui/trans';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
} from '@kit/ui/card';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button } from '@kit/ui/button';
|
|
||||||
import { ChevronRight, HeartPulse } from 'lucide-react';
|
import { ChevronRight, HeartPulse } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Card, CardDescription, CardFooter, CardHeader } from '@kit/ui/card';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
export default function DashboardCards() {
|
export default function DashboardCards() {
|
||||||
return (
|
return (
|
||||||
<div className='flex gap-4 lg:px-4'>
|
<div className="flex gap-4 lg:px-4">
|
||||||
<Card
|
<Card
|
||||||
variant="gradient-success"
|
variant="gradient-success"
|
||||||
className="flex flex-col justify-between"
|
className="xs:w-1/2 sm:w-auto flex w-full flex-col justify-between"
|
||||||
>
|
>
|
||||||
<CardHeader className="flex-row">
|
<CardHeader className="flex-row">
|
||||||
<div
|
<div
|
||||||
className={'flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-primary\/10 mb-6'}
|
className={
|
||||||
|
'bg-primary/10 mb-6 flex size-8 items-center-safe justify-center-safe rounded-full text-white'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<HeartPulse className="size-4 fill-green-500" />
|
<HeartPulse className="size-4 fill-green-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className='ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-warning'>
|
<div className="bg-warning ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white">
|
||||||
<Link href='/home/order-analysis'>
|
<Link href="/home/order-analysis">
|
||||||
<Button size="icon" variant="outline" className="px-2 text-black">
|
<Button size="icon" variant="outline" className="px-2 text-black">
|
||||||
<ChevronRight className="size-4 stroke-2" />
|
<ChevronRight className="size-4 stroke-2" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -33,10 +31,10 @@ export default function DashboardCards() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter className="flex flex-col items-start gap-2">
|
<CardFooter className="flex flex-col items-start gap-2">
|
||||||
<h5>
|
<h5>
|
||||||
<Trans i18nKey='dashboard:heroCard.orderAnalysis.title' />
|
<Trans i18nKey="dashboard:heroCard.orderAnalysis.title" />
|
||||||
</h5>
|
</h5>
|
||||||
<CardDescription className="text-primary">
|
<CardDescription className="text-primary">
|
||||||
<Trans i18nKey='dashboard:heroCard.orderAnalysis.description' />
|
<Trans i18nKey="dashboard:heroCard.orderAnalysis.description" />
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function Dashboard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid auto-rows-fr grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5">
|
<div className="xs:grid-cols-2 grid auto-rows-fr gap-3 sm:grid-cols-4 lg:grid-cols-5">
|
||||||
{cards({
|
{cards({
|
||||||
gender: params?.gender,
|
gender: params?.gender,
|
||||||
age: params?.age,
|
age: params?.age,
|
||||||
@@ -233,8 +233,11 @@ export default function Dashboard({
|
|||||||
index,
|
index,
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between" key={index}>
|
<div
|
||||||
<div className="mr-4 flex flex-row items-center gap-4">
|
className="flex w-full justify-between gap-3 overflow-scroll"
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<div className="mr-4 flex min-w-fit flex-row items-center gap-4">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
|
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
|
||||||
@@ -243,7 +246,7 @@ export default function Dashboard({
|
|||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-fit">
|
||||||
<div className="inline-flex items-center gap-1 align-baseline text-sm font-medium">
|
<div className="inline-flex items-center gap-1 align-baseline text-sm font-medium">
|
||||||
{title}
|
{title}
|
||||||
<InfoTooltip content={tooltipContent} />
|
<InfoTooltip content={tooltipContent} />
|
||||||
@@ -253,16 +256,24 @@ export default function Dashboard({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid w-36 auto-rows-fr grid-cols-2 items-center gap-4">
|
<div className="grid w-36 auto-rows-fr grid-cols-2 items-center gap-4 min-w-fit">
|
||||||
<p className="text-sm font-medium"> {price}</p>
|
<p className="text-sm font-medium"> {price}</p>
|
||||||
{href ? (
|
{href ? (
|
||||||
<Link href={href}>
|
<Link href={href}>
|
||||||
<Button size="sm" variant="secondary">
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full min-w-fit"
|
||||||
|
>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Button size="sm" variant="secondary">
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full min-w-fit"
|
||||||
|
>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export async function HomeMenuNavigation(props: {
|
|||||||
})
|
})
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const cartItemsCount = props.cart?.items?.length ?? 0;
|
const cartQuantityTotal = props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
|
||||||
const hasCartItems = cartItemsCount > 0;
|
const hasCartItems = cartQuantityTotal > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex w-full flex-1 items-center justify-between gap-3'}>
|
<div className={'flex w-full flex-1 items-center justify-between gap-3'}>
|
||||||
@@ -64,7 +64,7 @@ export async function HomeMenuNavigation(props: {
|
|||||||
<ShoppingCart className="stroke-[1.5px]" />
|
<ShoppingCart className="stroke-[1.5px]" />
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="common:shoppingCartCount"
|
i18nKey="common:shoppingCartCount"
|
||||||
values={{ count: cartItemsCount }}
|
values={{ count: cartQuantityTotal }}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { StoreCart } from '@medusajs/types';
|
import { StoreCart } from '@medusajs/types';
|
||||||
import { LogOut, Menu, ShoppingCart } from 'lucide-react';
|
import { Cross, LogOut, Menu, Shield, ShoppingCart } from 'lucide-react';
|
||||||
|
|
||||||
|
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
|
||||||
|
import { ApplicationRoleEnum } from '@kit/accounts/types/accounts';
|
||||||
import {
|
import {
|
||||||
featureFlagsConfig,
|
pathsConfig,
|
||||||
personalAccountNavigationConfig,
|
personalAccountNavigationConfig,
|
||||||
} from '@kit/shared/config';
|
} from '@kit/shared/config';
|
||||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||||
@@ -15,7 +19,6 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@kit/ui/dropdown-menu';
|
} from '@kit/ui/dropdown-menu';
|
||||||
@@ -23,14 +26,16 @@ import { If } from '@kit/ui/if';
|
|||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
// home imports
|
// home imports
|
||||||
import { HomeAccountSelector } from '../_components/home-account-selector';
|
|
||||||
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
|
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
|
||||||
|
|
||||||
export function HomeMobileNavigation(props: {
|
export function HomeMobileNavigation(props: {
|
||||||
workspace: UserWorkspace;
|
workspace: UserWorkspace;
|
||||||
cart: StoreCart | null;
|
cart: StoreCart | null;
|
||||||
}) {
|
}) {
|
||||||
|
const user = props.workspace.user;
|
||||||
|
|
||||||
const signOut = useSignOut();
|
const signOut = useSignOut();
|
||||||
|
const { data: personalAccountData } = usePersonalAccountData(user.id);
|
||||||
|
|
||||||
const Links = personalAccountNavigationConfig.routes.map((item, index) => {
|
const Links = personalAccountNavigationConfig.routes.map((item, index) => {
|
||||||
if ('children' in item) {
|
if ('children' in item) {
|
||||||
@@ -51,8 +56,30 @@ export function HomeMobileNavigation(props: {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const cartItemsCount = props.cart?.items?.length ?? 0;
|
const hasTotpFactor = useMemo(() => {
|
||||||
const hasCartItems = cartItemsCount > 0;
|
const factors = user?.factors ?? [];
|
||||||
|
return factors.some(
|
||||||
|
(factor) => factor.factor_type === 'totp' && factor.status === 'verified',
|
||||||
|
);
|
||||||
|
}, [user?.factors]);
|
||||||
|
|
||||||
|
const isSuperAdmin = useMemo(() => {
|
||||||
|
const hasAdminRole =
|
||||||
|
personalAccountData?.application_role === ApplicationRoleEnum.SuperAdmin;
|
||||||
|
|
||||||
|
return hasAdminRole && hasTotpFactor;
|
||||||
|
}, [user, personalAccountData, hasTotpFactor]);
|
||||||
|
|
||||||
|
const isDoctor = useMemo(() => {
|
||||||
|
const hasDoctorRole =
|
||||||
|
personalAccountData?.application_role === ApplicationRoleEnum.Doctor;
|
||||||
|
|
||||||
|
return hasDoctorRole && hasTotpFactor;
|
||||||
|
}, [user, personalAccountData, hasTotpFactor]);
|
||||||
|
|
||||||
|
const cartQuantityTotal =
|
||||||
|
props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
|
||||||
|
const hasCartItems = cartQuantityTotal > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -61,29 +88,13 @@ export function HomeMobileNavigation(props: {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
|
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
|
||||||
<If condition={featureFlagsConfig.enableTeamAccounts}>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
<Trans i18nKey={'common:yourAccounts'} />
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
|
|
||||||
<HomeAccountSelector
|
|
||||||
userId={props.workspace.user.id}
|
|
||||||
accounts={props.workspace.accounts}
|
|
||||||
collisionPadding={0}
|
|
||||||
/>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</If>
|
|
||||||
|
|
||||||
<If condition={props.cart && hasCartItems}>
|
<If condition={props.cart && hasCartItems}>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownLink
|
<DropdownLink
|
||||||
path="/home/cart"
|
path="/home/cart"
|
||||||
label="common:shoppingCartCount"
|
label="common:shoppingCartCount"
|
||||||
Icon={<ShoppingCart className="stroke-[1.5px]" />}
|
Icon={<ShoppingCart className="stroke-[1.5px]" />}
|
||||||
labelOptions={{ count: cartItemsCount }}
|
labelOptions={{ count: cartQuantityTotal }}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -91,6 +102,41 @@ export function HomeMobileNavigation(props: {
|
|||||||
|
|
||||||
<DropdownMenuGroup>{Links}</DropdownMenuGroup>
|
<DropdownMenuGroup>{Links}</DropdownMenuGroup>
|
||||||
|
|
||||||
|
<If condition={isSuperAdmin}>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
className={
|
||||||
|
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
|
||||||
|
}
|
||||||
|
href={pathsConfig.app.admin}
|
||||||
|
>
|
||||||
|
<Shield className={'h-5'} />
|
||||||
|
|
||||||
|
<span>Super Admin</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</If>
|
||||||
|
|
||||||
|
<If condition={isDoctor}>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
className={
|
||||||
|
'flex h-full cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
|
||||||
|
}
|
||||||
|
href={pathsConfig.app.doctor}
|
||||||
|
>
|
||||||
|
<Cross className={'h-5'} />
|
||||||
|
|
||||||
|
<span>
|
||||||
|
<Trans i18nKey="common:doctor" />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</If>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
|
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { HeartPulse, Loader2, ShoppingCart } from 'lucide-react';
|
import { HeartPulse, Loader2, ShoppingCart } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -12,15 +13,17 @@ import {
|
|||||||
import { StoreProduct } from '@medusajs/types';
|
import { StoreProduct } from '@medusajs/types';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { handleAddToCart } from '~/lib/services/medusaCart.service';
|
import { handleAddToCart } from '~/lib/services/medusaCart.service';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
|
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
import { formatCurrency } from '@/packages/shared/src/utils';
|
||||||
|
|
||||||
export type OrderAnalysisCard = Pick<
|
export type OrderAnalysisCard = Pick<
|
||||||
StoreProduct, 'title' | 'description' | 'subtitle'
|
StoreProduct, 'title' | 'description' | 'subtitle'
|
||||||
> & {
|
> & {
|
||||||
isAvailable: boolean;
|
isAvailable: boolean;
|
||||||
variant: { id: string };
|
variant: { id: string };
|
||||||
|
price: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function OrderAnalysesCards({
|
export default function OrderAnalysesCards({
|
||||||
@@ -30,37 +33,47 @@ export default function OrderAnalysesCards({
|
|||||||
analyses: OrderAnalysisCard[];
|
analyses: OrderAnalysisCard[];
|
||||||
countryCode: string;
|
countryCode: string;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [isAddingToCart, setIsAddingToCart] = useState(false);
|
const { i18n: { language } } = useTranslation()
|
||||||
|
|
||||||
|
const [variantAddingToCart, setVariantAddingToCart] = useState<string | null>(null);
|
||||||
const handleSelect = async (variantId: string) => {
|
const handleSelect = async (variantId: string) => {
|
||||||
if (isAddingToCart) {
|
if (variantAddingToCart) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsAddingToCart(true);
|
setVariantAddingToCart(variantId);
|
||||||
try {
|
try {
|
||||||
await handleAddToCart({
|
await handleAddToCart({
|
||||||
selectedVariant: { id: variantId },
|
selectedVariant: { id: variantId },
|
||||||
countryCode,
|
countryCode,
|
||||||
});
|
});
|
||||||
setIsAddingToCart(false);
|
toast.success(<Trans i18nKey={'order-analysis:analysisAddedToCart'} />);
|
||||||
router.push('/home/cart');
|
setVariantAddingToCart(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setIsAddingToCart(false);
|
toast.error(<Trans i18nKey={'order-analysis:analysisAddToCartError'} />);
|
||||||
|
setVariantAddingToCart(null);
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-6 mt-4">
|
<div className="grid 2xs:grid-cols-3 gap-6 mt-4">
|
||||||
{analyses.map(({
|
{analyses.map(({
|
||||||
title,
|
title,
|
||||||
variant,
|
variant,
|
||||||
description,
|
description,
|
||||||
subtitle,
|
subtitle,
|
||||||
isAvailable,
|
isAvailable,
|
||||||
|
price,
|
||||||
}) => {
|
}) => {
|
||||||
|
const formattedPrice = typeof price === 'number'
|
||||||
|
? formatCurrency({
|
||||||
|
currencyCode: 'eur',
|
||||||
|
locale: language,
|
||||||
|
value: price,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={title}
|
key={title}
|
||||||
@@ -81,7 +94,7 @@ export default function OrderAnalysesCards({
|
|||||||
className="px-2 text-black"
|
className="px-2 text-black"
|
||||||
onClick={() => handleSelect(variant.id)}
|
onClick={() => handleSelect(variant.id)}
|
||||||
>
|
>
|
||||||
{isAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
|
{variantAddingToCart ? <Loader2 className="size-4 stroke-2 animate-spin" /> : <ShoppingCart className="size-4 stroke-2" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -92,7 +105,14 @@ export default function OrderAnalysesCards({
|
|||||||
{description && (
|
{description && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
<InfoTooltip content={`${description}`} />
|
<InfoTooltip
|
||||||
|
content={
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<span>{formattedPrice}</span>
|
||||||
|
<span>{description}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</h5>
|
</h5>
|
||||||
|
|||||||
@@ -1,74 +1,68 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import { ChevronRight, HeartPulse } from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { pathsConfig } from '@/packages/shared/src/config';
|
||||||
|
import { ComponentInstanceIcon } from '@radix-ui/react-icons';
|
||||||
|
import { ChevronRight, HeartPulse } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardProps,
|
|
||||||
CardFooter,
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardProps,
|
||||||
} from '@kit/ui/card';
|
} from '@kit/ui/card';
|
||||||
import { Trans } from '@kit/ui/trans';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const dummyCards = [
|
import { ServiceCategory } from './service-categories';
|
||||||
{
|
|
||||||
title: 'booking:analysisPackages.title',
|
|
||||||
description: 'booking:analysisPackages.description',
|
|
||||||
descriptionColor: 'text-primary',
|
|
||||||
icon: (
|
|
||||||
<Link href={'/home/order-analysis-package'}>
|
|
||||||
<Button size="icon" variant="outline" className="px-2 text-black">
|
|
||||||
<ChevronRight className="size-4 stroke-2" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
cardVariant: 'gradient-success' as CardProps['variant'],
|
|
||||||
iconBg: 'bg-warning',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function OrderCards() {
|
export default function OrderCards({
|
||||||
|
heroCategories,
|
||||||
|
}: {
|
||||||
|
heroCategories: ServiceCategory[];
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-6 mt-4">
|
<div className="xs:grid-cols-3 mt-4 grid grid-cols-1 gap-2">
|
||||||
{dummyCards.map(({
|
{heroCategories.map(({ name, description, color, handle }) => (
|
||||||
title,
|
|
||||||
description,
|
|
||||||
icon,
|
|
||||||
cardVariant,
|
|
||||||
descriptionColor,
|
|
||||||
iconBg,
|
|
||||||
}) => (
|
|
||||||
<Card
|
<Card
|
||||||
key={title}
|
key={name}
|
||||||
variant={cardVariant}
|
variant={`gradient-${color}` as CardProps['variant']}
|
||||||
className="flex flex-col justify-between"
|
className="flex flex-col justify-between"
|
||||||
>
|
>
|
||||||
<CardHeader className="items-end-safe">
|
<CardHeader className="relative flex flex-row justify-between">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
|
'flex size-8 items-center-safe justify-center-safe rounded-full',
|
||||||
iconBg,
|
`text-${color}`,
|
||||||
|
`bg-${color}/10`,
|
||||||
|
{
|
||||||
|
'bg-primary/10': color === 'success',
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon}
|
<ComponentInstanceIcon
|
||||||
|
className={cn('size-4', `fill-${color}`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-2 right-2 flex size-8 items-center-safe justify-center-safe rounded-xl text-white">
|
||||||
|
<Link
|
||||||
|
href={pathsConfig.app.bookingHandle.replace('[handle]', handle)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="px-2 text-black"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4 stroke-2" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter className="flex flex-col items-start gap-2">
|
<CardFooter className="mt-5 flex flex-col items-start gap-2">
|
||||||
<div
|
<h5>{name}</h5>
|
||||||
className={'flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-primary\/10 mb-6'}
|
<CardDescription>{description}</CardDescription>
|
||||||
>
|
|
||||||
<HeartPulse className="size-4 fill-green-500" />
|
|
||||||
</div>
|
|
||||||
<h5>
|
|
||||||
<Trans i18nKey={title} />
|
|
||||||
</h5>
|
|
||||||
<CardDescription className={descriptionColor}>
|
|
||||||
<Trans i18nKey={description} />
|
|
||||||
</CardDescription>
|
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
18
app/home/(user)/_components/orders/actions.ts
Normal file
18
app/home/(user)/_components/orders/actions.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { createPageViewLog, PageViewAction } from "~/lib/services/audit/pageView.service";
|
||||||
|
import { loadCurrentUserAccount } from "../../_lib/server/load-user-account";
|
||||||
|
|
||||||
|
export async function logAnalysisResultsNavigateAction(analysisOrderId: string) {
|
||||||
|
const account = await loadCurrentUserAccount();
|
||||||
|
if (!account) {
|
||||||
|
throw new Error('Account not found');
|
||||||
|
}
|
||||||
|
await createPageViewLog({
|
||||||
|
accountId: account.id,
|
||||||
|
action: PageViewAction.VIEW_ANALYSIS_RESULTS_FROM_ORDER,
|
||||||
|
extraData: {
|
||||||
|
analysisOrderId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,29 +1,49 @@
|
|||||||
import { Trans } from '@kit/ui/trans';
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { StoreOrderLineItem } from '@medusajs/types';
|
||||||
|
import { formatDate } from 'date-fns';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
|
||||||
|
import { pathsConfig } from '@kit/shared/config';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TableHeader,
|
|
||||||
TableCell,
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
} from '@kit/ui/table';
|
} from '@kit/ui/table';
|
||||||
import { StoreOrderLineItem } from "@medusajs/types";
|
import { Trans } from '@kit/ui/trans';
|
||||||
import { AnalysisOrder } from '~/lib/services/order.service';
|
|
||||||
import { formatDate } from 'date-fns';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Eye } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function OrderItemsTable({ items, title, analysisOrder }: {
|
import { AnalysisOrder } from '~/lib/services/order.service';
|
||||||
|
|
||||||
|
import { logAnalysisResultsNavigateAction } from './actions';
|
||||||
|
|
||||||
|
export default function OrderItemsTable({
|
||||||
|
items,
|
||||||
|
title,
|
||||||
|
analysisOrder,
|
||||||
|
}: {
|
||||||
items: StoreOrderLineItem[];
|
items: StoreOrderLineItem[];
|
||||||
title: string;
|
title: string;
|
||||||
analysisOrder: AnalysisOrder;
|
analysisOrder: AnalysisOrder;
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openAnalysisResults = async () => {
|
||||||
|
await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id);
|
||||||
|
router.push(`${pathsConfig.app.analysisResults}/${analysisOrder.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table className="rounded-lg border border-separate">
|
<Table className="border-separate rounded-lg border">
|
||||||
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
|
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="px-6">
|
<TableHead className="px-6">
|
||||||
@@ -35,13 +55,14 @@ export default function OrderItemsTable({ items, title, analysisOrder }: {
|
|||||||
<TableHead className="px-6">
|
<TableHead className="px-6">
|
||||||
<Trans i18nKey="orders:table.status" />
|
<Trans i18nKey="orders:table.status" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-6">
|
<TableHead className="px-6"></TableHead>
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items
|
{items
|
||||||
.sort((a, b) => (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1)
|
.sort((a, b) =>
|
||||||
|
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
|
||||||
|
)
|
||||||
.map((orderItem) => (
|
.map((orderItem) => (
|
||||||
<TableRow className="w-full" key={orderItem.id}>
|
<TableRow className="w-full" key={orderItem.id}>
|
||||||
<TableCell className="text-left w-[100%] px-6">
|
<TableCell className="text-left w-[100%] px-6">
|
||||||
@@ -54,24 +75,18 @@ export default function OrderItemsTable({ items, title, analysisOrder }: {
|
|||||||
{formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')}
|
{formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="px-6 min-w-[180px]">
|
<TableCell className="min-w-[180px] px-6">
|
||||||
<Trans i18nKey={`orders:status.${analysisOrder.status}`} />
|
<Trans i18nKey={`orders:status.${analysisOrder.status}`} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="text-right px-6">
|
<TableCell className="px-6 text-right">
|
||||||
<span className="flex gap-x-1 justify-end w-[30px]">
|
<Button size="sm" onClick={openAnalysisResults}>
|
||||||
<Link href={`/home/analysis-results`} className="flex items-center justify-between text-small-regular">
|
<Trans i18nKey="analysis-results:view" />
|
||||||
<button
|
</Button>
|
||||||
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer"
|
|
||||||
>
|
|
||||||
<Eye />
|
|
||||||
</button>
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
61
app/home/(user)/_components/service-categories.tsx
Normal file
61
app/home/(user)/_components/service-categories.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { createPath, pathsConfig } from '@/packages/shared/src/config';
|
||||||
|
import { ComponentInstanceIcon } from '@radix-ui/react-icons';
|
||||||
|
|
||||||
|
import { cn } from '@kit/ui/shadcn';
|
||||||
|
import { Card, CardDescription, CardTitle } from '@kit/ui/shadcn/card';
|
||||||
|
|
||||||
|
export interface ServiceCategory {
|
||||||
|
name: string;
|
||||||
|
handle: string;
|
||||||
|
color: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServiceCategories = ({
|
||||||
|
categories,
|
||||||
|
}: {
|
||||||
|
categories: ServiceCategory[];
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
|
||||||
|
{categories.map((category, index) => (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
className="flex cursor-pointer gap-2 p-4 shadow hover:shadow-md"
|
||||||
|
onClick={() => {
|
||||||
|
redirect(
|
||||||
|
pathsConfig.app.bookingHandle.replace(
|
||||||
|
'[handle]',
|
||||||
|
category.handle,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex size-8 items-center-safe justify-center-safe rounded-full',
|
||||||
|
`bg-${category.color}/10`,
|
||||||
|
`text-${category.color}`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ComponentInstanceIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="mb-2 text-lg font-semibold">{category.name}</h5>
|
||||||
|
<CardDescription className="">
|
||||||
|
{category.description}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServiceCategories;
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { cache } from 'react';
|
import { cache } from 'react';
|
||||||
|
|
||||||
import { listProductTypes } from "@lib/data/products";
|
|
||||||
import { listRegions } from '@lib/data/regions';
|
|
||||||
import { getProductCategories } from '@lib/data/categories';
|
import { getProductCategories } from '@lib/data/categories';
|
||||||
|
import { listProducts, listProductTypes } from '@lib/data/products';
|
||||||
|
import { listRegions } from '@lib/data/regions';
|
||||||
|
|
||||||
import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
|
import { OrderAnalysisCard } from '../../_components/order-analyses-cards';
|
||||||
|
|
||||||
async function countryCodesLoader() {
|
async function countryCodesLoader() {
|
||||||
@@ -14,7 +15,9 @@ async function countryCodesLoader() {
|
|||||||
export const loadCountryCodes = cache(countryCodesLoader);
|
export const loadCountryCodes = cache(countryCodesLoader);
|
||||||
|
|
||||||
async function productCategoriesLoader() {
|
async function productCategoriesLoader() {
|
||||||
const productCategories = await getProductCategories({ fields: "*products, *products.variants" });
|
const productCategories = await getProductCategories({
|
||||||
|
fields: '*products, *products.variants, is_active',
|
||||||
|
});
|
||||||
return productCategories.product_categories ?? [];
|
return productCategories.product_categories ?? [];
|
||||||
}
|
}
|
||||||
export const loadProductCategories = cache(productCategoriesLoader);
|
export const loadProductCategories = cache(productCategoriesLoader);
|
||||||
@@ -32,22 +35,39 @@ async function analysesLoader() {
|
|||||||
]);
|
]);
|
||||||
const countryCode = countryCodes[0]!;
|
const countryCode = countryCodes[0]!;
|
||||||
|
|
||||||
const category = productCategories.find(({ metadata }) => metadata?.page === 'order-analysis');
|
const category = productCategories.find(
|
||||||
|
({ metadata }) => metadata?.page === 'order-analysis',
|
||||||
|
);
|
||||||
|
const categoryProducts = category
|
||||||
|
? await listProducts({
|
||||||
|
countryCode,
|
||||||
|
queryParams: { limit: 100, category_id: category.id },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const serviceCategories = productCategories.filter(
|
||||||
|
({ parent_category }) => parent_category?.handle === 'tto-categories',
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
analyses: category?.products?.map<OrderAnalysisCard>(({ title, description, subtitle, variants, status, metadata }) => {
|
analyses:
|
||||||
const variant = variants![0]!;
|
categoryProducts?.response.products.map<OrderAnalysisCard>(
|
||||||
return {
|
({ title, description, subtitle, variants, status, metadata }) => {
|
||||||
title,
|
const variant = variants![0]!;
|
||||||
description,
|
return {
|
||||||
subtitle,
|
title,
|
||||||
variant: {
|
description,
|
||||||
id: variant.id,
|
subtitle,
|
||||||
|
variant: {
|
||||||
|
id: variant.id,
|
||||||
|
},
|
||||||
|
isAvailable:
|
||||||
|
status === 'published' && !!metadata?.analysisIdOriginal,
|
||||||
|
price: variant.calculated_price?.calculated_amount ?? null,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
isAvailable: status === 'published' && !!metadata?.analysisIdOriginal,
|
) ?? [],
|
||||||
};
|
|
||||||
}) ?? [],
|
|
||||||
countryCode,
|
countryCode,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
export const loadAnalyses = cache(analysesLoader);
|
export const loadAnalyses = cache(analysesLoader);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { listRegions } from '@lib/data/regions';
|
|||||||
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
|
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
|
||||||
import type { StoreProduct } from '@medusajs/types';
|
import type { StoreProduct } from '@medusajs/types';
|
||||||
import { loadCurrentUserAccount } from './load-user-account';
|
import { loadCurrentUserAccount } from './load-user-account';
|
||||||
import { AnalysisPackageWithVariant } from '~/components/select-analysis-package';
|
|
||||||
import { AccountWithParams } from '@/packages/features/accounts/src/server/api';
|
import { AccountWithParams } from '@/packages/features/accounts/src/server/api';
|
||||||
|
import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
|
||||||
|
|
||||||
async function countryCodesLoader() {
|
async function countryCodesLoader() {
|
||||||
const countryCodes = await listRegions().then((regions) =>
|
const countryCodes = await listRegions().then((regions) =>
|
||||||
@@ -38,8 +38,11 @@ function userSpecificVariantLoader({
|
|||||||
if (age >= 18 && age <= 29) {
|
if (age >= 18 && age <= 29) {
|
||||||
return '18-29';
|
return '18-29';
|
||||||
}
|
}
|
||||||
if (age >= 30 && age <= 49) {
|
if (age >= 30 && age <= 39) {
|
||||||
return '30-49';
|
return '30-39';
|
||||||
|
}
|
||||||
|
if (age >= 40 && age <= 49) {
|
||||||
|
return '40-49';
|
||||||
}
|
}
|
||||||
if (age >= 50 && age <= 59) {
|
if (age >= 50 && age <= 59) {
|
||||||
return '50-59';
|
return '50-59';
|
||||||
|
|||||||
31
app/home/(user)/_lib/server/load-category.ts
Normal file
31
app/home/(user)/_lib/server/load-category.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
import { getProductCategories } from '@lib/data';
|
||||||
|
|
||||||
|
import { ServiceCategory } from '../../_components/service-categories';
|
||||||
|
|
||||||
|
async function categoryLoader({
|
||||||
|
handle,
|
||||||
|
}: {
|
||||||
|
handle: string;
|
||||||
|
}): Promise<{ category: ServiceCategory | null }> {
|
||||||
|
const response = await getProductCategories({
|
||||||
|
handle,
|
||||||
|
fields: '*products, is_active, metadata',
|
||||||
|
});
|
||||||
|
|
||||||
|
const category = response.product_categories[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
category: {
|
||||||
|
color:
|
||||||
|
typeof category?.metadata?.color === 'string'
|
||||||
|
? category?.metadata?.color
|
||||||
|
: 'primary',
|
||||||
|
description: category?.description || '',
|
||||||
|
handle: category?.handle || '',
|
||||||
|
name: category?.name || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export const loadCategory = cache(categoryLoader);
|
||||||
49
app/home/(user)/_lib/server/load-tto-services.ts
Normal file
49
app/home/(user)/_lib/server/load-tto-services.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
import { getProductCategories } from '@lib/data';
|
||||||
|
|
||||||
|
import { ServiceCategory } from '../../_components/service-categories';
|
||||||
|
|
||||||
|
async function ttoServicesLoader() {
|
||||||
|
const response = await getProductCategories({
|
||||||
|
fields: '*products, is_active, metadata',
|
||||||
|
});
|
||||||
|
|
||||||
|
const heroCategories = response.product_categories?.filter(
|
||||||
|
({ parent_category, is_active, metadata }) =>
|
||||||
|
parent_category?.handle === 'tto-categories' &&
|
||||||
|
is_active &&
|
||||||
|
metadata?.isHero,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ttoCategories = response.product_categories?.filter(
|
||||||
|
({ parent_category, is_active, metadata }) =>
|
||||||
|
parent_category?.handle === 'tto-categories' &&
|
||||||
|
is_active &&
|
||||||
|
!metadata?.isHero,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
heroCategories:
|
||||||
|
heroCategories.map<ServiceCategory>(
|
||||||
|
({ name, handle, metadata, description }) => ({
|
||||||
|
name,
|
||||||
|
handle,
|
||||||
|
color:
|
||||||
|
typeof metadata?.color === 'string' ? metadata.color : 'primary',
|
||||||
|
description,
|
||||||
|
}),
|
||||||
|
) ?? [],
|
||||||
|
ttoCategories:
|
||||||
|
ttoCategories.map<ServiceCategory>(
|
||||||
|
({ name, handle, metadata, description }) => ({
|
||||||
|
name,
|
||||||
|
handle,
|
||||||
|
color:
|
||||||
|
typeof metadata?.color === 'string' ? metadata.color : 'primary',
|
||||||
|
description,
|
||||||
|
}),
|
||||||
|
) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export const loadTtoServices = cache(ttoServicesLoader);
|
||||||
22
app/home/(user)/_lib/server/load-user-analyses.ts
Normal file
22
app/home/(user)/_lib/server/load-user-analyses.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
import { createAccountsApi } from '@kit/accounts/api';
|
||||||
|
import { UserAnalysis } from '@kit/accounts/types/accounts';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalyses>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name loadUserAnalyses
|
||||||
|
* @description
|
||||||
|
* Load the user's analyses. It's a cached per-request function that fetches the user workspace data.
|
||||||
|
* It can be used across the server components to load the user workspace data.
|
||||||
|
*/
|
||||||
|
export const loadUserAnalyses = cache(analysesLoader);
|
||||||
|
|
||||||
|
async function analysesLoader(): Promise<UserAnalysis | null> {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const api = createAccountsApi(client);
|
||||||
|
|
||||||
|
return api.getUserAnalyses();
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { cache } from 'react';
|
import { cache } from 'react';
|
||||||
|
|
||||||
import { createAccountsApi } from '@kit/accounts/api';
|
import { createAccountsApi } from '@kit/accounts/api';
|
||||||
import { UserAnalysis } from '@kit/accounts/types/accounts';
|
import { AnalysisResultDetails } from '@kit/accounts/types/accounts';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>;
|
export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>;
|
||||||
@@ -9,14 +9,15 @@ export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>;
|
|||||||
/**
|
/**
|
||||||
* @name loadUserAnalysis
|
* @name loadUserAnalysis
|
||||||
* @description
|
* @description
|
||||||
* Load the user's analyses. It's a cached per-request function that fetches the user workspace data.
|
* Load the user's analysis based on id. It's a cached per-request function that fetches the user's analysis data.
|
||||||
* It can be used across the server components to load the user workspace data.
|
|
||||||
*/
|
*/
|
||||||
export const loadUserAnalysis = cache(analysisLoader);
|
export const loadUserAnalysis = cache(analysisLoader);
|
||||||
|
|
||||||
async function analysisLoader(): Promise<UserAnalysis | null> {
|
async function analysisLoader(
|
||||||
|
analysisOrderId: number,
|
||||||
|
): Promise<AnalysisResultDetails | null> {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const api = createAccountsApi(client);
|
const api = createAccountsApi(client);
|
||||||
|
|
||||||
return api.getUserAnalysis();
|
return api.getUserAnalysis(analysisOrderId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,3 +41,43 @@ export async function getAccountAdmin({
|
|||||||
|
|
||||||
return data as unknown as AccountWithMemberships;
|
return data as unknown as AccountWithMemberships;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDoctorAccounts() {
|
||||||
|
const { data } = await getSupabaseServerAdminClient()
|
||||||
|
.schema('medreport')
|
||||||
|
.from('accounts')
|
||||||
|
.select('id, email, name, last_name, preferred_locale')
|
||||||
|
.eq('is_personal_account', true)
|
||||||
|
.eq('application_role', 'doctor')
|
||||||
|
.throwOnError();
|
||||||
|
|
||||||
|
return data?.map(({ id, email, name, last_name, preferred_locale }) => ({
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
lastName: last_name,
|
||||||
|
preferredLocale: preferred_locale,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAssignedDoctorAccount(analysisOrderId: number) {
|
||||||
|
const { data: doctorUser } = await getSupabaseServerAdminClient()
|
||||||
|
.schema('medreport')
|
||||||
|
.from('doctor_analysis_feedback')
|
||||||
|
.select('doctor_user_id')
|
||||||
|
.eq('analysis_order_id', analysisOrderId)
|
||||||
|
.throwOnError();
|
||||||
|
|
||||||
|
const doctorData = doctorUser[0];
|
||||||
|
if (!doctorData || !doctorData.doctor_user_id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await getSupabaseServerAdminClient()
|
||||||
|
.schema('medreport')
|
||||||
|
.from('accounts')
|
||||||
|
.select('email')
|
||||||
|
.eq('primary_owner_user_id', doctorData.doctor_user_id);
|
||||||
|
|
||||||
|
return { email: data?.[0]?.email };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
|
|
||||||
export enum NotificationAction {
|
export enum NotificationAction {
|
||||||
DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED',
|
DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED',
|
||||||
|
NEW_JOBS_ALERT = 'NEW_JOBS_ALERT',
|
||||||
|
PATIENT_RESULTS_RECEIVED_ALERT = 'PATIENT_RESULTS_RECEIVED_ALERT',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createNotificationLog = async ({
|
export const createNotificationLog = async ({
|
||||||
@@ -17,7 +19,7 @@ export const createNotificationLog = async ({
|
|||||||
relatedRecordId?: string | number;
|
relatedRecordId?: string | number;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const supabase = getSupabaseServerClient();
|
const supabase = getSupabaseServerAdminClient();
|
||||||
|
|
||||||
await supabase
|
await supabase
|
||||||
.schema('audit')
|
.schema('audit')
|
||||||
@@ -30,6 +32,6 @@ export const createNotificationLog = async ({
|
|||||||
})
|
})
|
||||||
.throwOnError();
|
.throwOnError();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to insert doctor page view log', error);
|
console.error('Failed to insert doctor notification log', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|||||||
|
|
||||||
export enum PageViewAction {
|
export enum PageViewAction {
|
||||||
VIEW_ANALYSIS_RESULTS = 'VIEW_ANALYSIS_RESULTS',
|
VIEW_ANALYSIS_RESULTS = 'VIEW_ANALYSIS_RESULTS',
|
||||||
|
VIEW_ANALYSIS_RESULTS_FROM_ORDER = 'VIEW_ANALYSIS_RESULTS_FROM_ORDER',
|
||||||
REGISTRATION_SUCCESS = 'REGISTRATION_SUCCESS',
|
REGISTRATION_SUCCESS = 'REGISTRATION_SUCCESS',
|
||||||
VIEW_ORDER_ANALYSIS = 'VIEW_ORDER_ANALYSIS',
|
VIEW_ORDER_ANALYSIS = 'VIEW_ORDER_ANALYSIS',
|
||||||
VIEW_TEAM_ACCOUNT_DASHBOARD = 'VIEW_TEAM_ACCOUNT_DASHBOARD',
|
VIEW_TEAM_ACCOUNT_DASHBOARD = 'VIEW_TEAM_ACCOUNT_DASHBOARD',
|
||||||
@@ -10,9 +11,11 @@ export enum PageViewAction {
|
|||||||
export const createPageViewLog = async ({
|
export const createPageViewLog = async ({
|
||||||
accountId,
|
accountId,
|
||||||
action,
|
action,
|
||||||
|
extraData,
|
||||||
}: {
|
}: {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
action: PageViewAction;
|
action: PageViewAction;
|
||||||
|
extraData?: Record<string, any>;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const supabase = getSupabaseServerClient();
|
const supabase = getSupabaseServerClient();
|
||||||
@@ -34,6 +37,7 @@ export const createPageViewLog = async ({
|
|||||||
account_id: accountId,
|
account_id: accountId,
|
||||||
action,
|
action,
|
||||||
changed_by: user.id,
|
changed_by: user.id,
|
||||||
|
extra_data: extraData,
|
||||||
})
|
})
|
||||||
.throwOnError();
|
.throwOnError();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
32
lib/services/doctor-jobs.service.ts
Normal file
32
lib/services/doctor-jobs.service.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
|
|
||||||
|
async function getAssignedOrderIds() {
|
||||||
|
const supabase = getSupabaseServerAdminClient();
|
||||||
|
|
||||||
|
const { data: assignedOrderIds } = await supabase
|
||||||
|
.schema('medreport')
|
||||||
|
.from('doctor_analysis_feedback')
|
||||||
|
.select('analysis_order_id')
|
||||||
|
.not('doctor_user_id', 'is', null)
|
||||||
|
.throwOnError();
|
||||||
|
|
||||||
|
return assignedOrderIds?.map((f) => f.analysis_order_id) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOpenJobAnalysisResponseIds() {
|
||||||
|
const supabase = getSupabaseServerAdminClient();
|
||||||
|
const assignedIds = await getAssignedOrderIds();
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
|
.schema('medreport')
|
||||||
|
.from('analysis_responses')
|
||||||
|
.select('id, analysis_order_id')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (assignedIds.length > 0) {
|
||||||
|
query = query.not('analysis_order_id', 'in', `(${assignedIds.join(',')})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: analysisResponses } = await query.throwOnError();
|
||||||
|
return analysisResponses?.map(({ id }) => id) || [];
|
||||||
|
}
|
||||||
@@ -1,50 +1,41 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { CompanySubmitData } from '@/lib/types/company';
|
import { toArray } from '@/lib/utils';
|
||||||
import { emailSchema } from '@/lib/validations/email.schema';
|
|
||||||
|
|
||||||
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
|
|
||||||
import { getMailer } from '@kit/mailers';
|
import { getMailer } from '@kit/mailers';
|
||||||
import { enhanceAction } from '@kit/next/actions';
|
import { enhanceAction } from '@kit/next/actions';
|
||||||
import { getLogger } from '@kit/shared/logger';
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
|
||||||
export const sendDoctorSummaryCompletedEmail = async (
|
import { emailSchema } from '~/lib/validations/email.schema';
|
||||||
language: string,
|
|
||||||
recipientName: string,
|
|
||||||
recipientEmail: string,
|
|
||||||
orderNr: string,
|
|
||||||
orderId: number,
|
|
||||||
) => {
|
|
||||||
const { html, subject } = await renderDoctorSummaryReceivedEmail({
|
|
||||||
language,
|
|
||||||
recipientName,
|
|
||||||
recipientEmail,
|
|
||||||
orderNr,
|
|
||||||
orderId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendEmail({
|
type EmailTemplate = {
|
||||||
subject,
|
html: string;
|
||||||
html,
|
subject: string;
|
||||||
to: recipientEmail,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendCompanyOfferEmail = async (
|
type EmailRenderer<T = any> = (params: T) => Promise<EmailTemplate>;
|
||||||
data: CompanySubmitData,
|
|
||||||
language: string,
|
|
||||||
) => {
|
|
||||||
const { renderCompanyOfferEmail } = await import('@kit/email-templates');
|
|
||||||
const { html, subject } = await renderCompanyOfferEmail({
|
|
||||||
language,
|
|
||||||
companyData: data,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendEmail({
|
export const sendEmailFromTemplate = async <T>(
|
||||||
subject,
|
renderer: EmailRenderer<T>,
|
||||||
html,
|
templateParams: T,
|
||||||
to: process.env.CONTACT_EMAIL || '',
|
recipients: string | string[],
|
||||||
});
|
) => {
|
||||||
|
const { html, subject } = await renderer(templateParams);
|
||||||
|
|
||||||
|
const recipientsArray = toArray(recipients);
|
||||||
|
if (!recipientsArray.length) {
|
||||||
|
throw new Error('No valid email recipients provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailPromises = recipientsArray.map((email) =>
|
||||||
|
sendEmail({
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
to: email,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(emailPromises);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendEmail = enhanceAction(
|
export const sendEmail = enhanceAction(
|
||||||
@@ -53,7 +44,7 @@ export const sendEmail = enhanceAction(
|
|||||||
const log = await getLogger();
|
const log = await getLogger();
|
||||||
|
|
||||||
if (!process.env.EMAIL_USER) {
|
if (!process.env.EMAIL_USER) {
|
||||||
log.error('Sending email failed, as no sender found in env.')
|
log.error('Sending email failed, as no sender was found in env.');
|
||||||
throw new Error('No email user configured');
|
throw new Error('No email user configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export async function getLatestPublicMessageListItem() {
|
|||||||
Action: MedipostAction.GetPublicMessageList,
|
Action: MedipostAction.GetPublicMessageList,
|
||||||
User: USER,
|
User: USER,
|
||||||
Password: PASSWORD,
|
Password: PASSWORD,
|
||||||
Sender: 'syndev',
|
Sender: RECIPIENT,
|
||||||
// LastChecked (date+time) can be used here to get only messages since the last check - add when cron is created
|
// LastChecked (date+time) can be used here to get only messages since the last check - add when cron is created
|
||||||
// MessageType check only for messages of certain type
|
// MessageType check only for messages of certain type
|
||||||
},
|
},
|
||||||
@@ -180,7 +180,10 @@ export async function getPrivateMessage(messageId: string) {
|
|||||||
|
|
||||||
await validateMedipostResponse(data, { canHaveEmptyCode: true });
|
await validateMedipostResponse(data, { canHaveEmptyCode: true });
|
||||||
|
|
||||||
return parseXML(data) as MedipostOrderResponse;
|
return {
|
||||||
|
message: parseXML(data) as MedipostOrderResponse,
|
||||||
|
xml: data as string,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePrivateMessage(messageId: string) {
|
export async function deletePrivateMessage(messageId: string) {
|
||||||
@@ -211,7 +214,9 @@ export async function readPrivateMessageResponse({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds });
|
const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds });
|
||||||
if (!privateMessage) {
|
messageId = privateMessage?.messageId ?? null;
|
||||||
|
|
||||||
|
if (!privateMessage || !messageId) {
|
||||||
return {
|
return {
|
||||||
messageId: null,
|
messageId: null,
|
||||||
hasAnalysisResponse: false,
|
hasAnalysisResponse: false,
|
||||||
@@ -221,40 +226,28 @@ export async function readPrivateMessageResponse({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
messageId = privateMessage.messageId;
|
const { message: privateMessageContent, xml: privateMessageXml } = await getPrivateMessage(
|
||||||
if (!messageId) {
|
|
||||||
return {
|
|
||||||
messageId: null,
|
|
||||||
hasAnalysisResponse: false,
|
|
||||||
hasPartialAnalysisResponse: false,
|
|
||||||
hasFullAnalysisResponse: false,
|
|
||||||
medusaOrderId: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const privateMessageContent = await getPrivateMessage(
|
|
||||||
privateMessage.messageId,
|
privateMessage.messageId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const messageResponse = privateMessageContent?.Saadetis?.Vastus;
|
const messageResponse = privateMessageContent?.Saadetis?.Vastus;
|
||||||
medusaOrderId = privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId;
|
medusaOrderId = privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId;
|
||||||
|
|
||||||
if (!medusaOrderId || !medusaOrderId.toString().startsWith('order_')) {
|
const hasInvalidOrderId = !medusaOrderId || !medusaOrderId.toString().startsWith('order_');
|
||||||
return {
|
|
||||||
messageId,
|
|
||||||
hasAnalysisResponse: false,
|
|
||||||
hasPartialAnalysisResponse: false,
|
|
||||||
hasFullAnalysisResponse: false,
|
|
||||||
medusaOrderId: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!messageResponse) {
|
if (hasInvalidOrderId || !messageResponse) {
|
||||||
|
await createMedipostActionLog({
|
||||||
|
action: 'sync_analysis_results_from_medipost',
|
||||||
|
xml: privateMessageXml,
|
||||||
|
hasAnalysisResults: false,
|
||||||
|
medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
messageId,
|
messageId,
|
||||||
hasAnalysisResponse: false,
|
hasAnalysisResponse: false,
|
||||||
hasPartialAnalysisResponse: false,
|
hasPartialAnalysisResponse: false,
|
||||||
hasFullAnalysisResponse: false,
|
hasFullAnalysisResponse: false,
|
||||||
medusaOrderId,
|
medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,12 +738,36 @@ export async function sendOrderToMedipost({
|
|||||||
await sendPrivateMessage(orderXml);
|
await sendPrivateMessage(orderXml);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const isMedipostError = e instanceof MedipostValidationError;
|
const isMedipostError = e instanceof MedipostValidationError;
|
||||||
await logMedipostDispatch({
|
if (isMedipostError) {
|
||||||
medusaOrderId,
|
await logMedipostDispatch({
|
||||||
isSuccess: false,
|
medusaOrderId,
|
||||||
isMedipostError,
|
isSuccess: false,
|
||||||
errorMessage: isMedipostError ? e.response : undefined,
|
isMedipostError,
|
||||||
});
|
errorMessage: e.response,
|
||||||
|
});
|
||||||
|
await createMedipostActionLog({
|
||||||
|
action: 'send_order_to_medipost',
|
||||||
|
xml: orderXml,
|
||||||
|
hasAnalysisResults: false,
|
||||||
|
medusaOrderId,
|
||||||
|
responseXml: e.response,
|
||||||
|
hasError: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await logMedipostDispatch({
|
||||||
|
medusaOrderId,
|
||||||
|
isSuccess: false,
|
||||||
|
isMedipostError,
|
||||||
|
});
|
||||||
|
await createMedipostActionLog({
|
||||||
|
action: 'send_order_to_medipost',
|
||||||
|
xml: orderXml,
|
||||||
|
hasAnalysisResults: false,
|
||||||
|
medusaOrderId,
|
||||||
|
hasError: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
await logMedipostDispatch({
|
await logMedipostDispatch({
|
||||||
@@ -758,6 +775,12 @@ export async function sendOrderToMedipost({
|
|||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
isMedipostError: false,
|
isMedipostError: false,
|
||||||
});
|
});
|
||||||
|
await createMedipostActionLog({
|
||||||
|
action: 'send_order_to_medipost',
|
||||||
|
xml: orderXml,
|
||||||
|
hasAnalysisResults: false,
|
||||||
|
medusaOrderId,
|
||||||
|
});
|
||||||
await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
|
await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -825,3 +848,37 @@ export async function getOrderedAnalysisElementsIds({
|
|||||||
|
|
||||||
return [...analysisPackageElements, ...orderedAnalysisElements];
|
return [...analysisPackageElements, ...orderedAnalysisElements];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createMedipostActionLog({
|
||||||
|
action,
|
||||||
|
xml,
|
||||||
|
hasAnalysisResults = false,
|
||||||
|
medusaOrderId,
|
||||||
|
responseXml,
|
||||||
|
hasError = false,
|
||||||
|
}: {
|
||||||
|
action:
|
||||||
|
| 'send_order_to_medipost'
|
||||||
|
| 'sync_analysis_results_from_medipost'
|
||||||
|
| 'send_fake_analysis_results_to_medipost'
|
||||||
|
| 'send_analysis_results_to_medipost';
|
||||||
|
xml: string;
|
||||||
|
hasAnalysisResults?: boolean;
|
||||||
|
medusaOrderId?: string | null;
|
||||||
|
responseXml?: string | null;
|
||||||
|
hasError?: boolean;
|
||||||
|
}) {
|
||||||
|
await getSupabaseServerAdminClient()
|
||||||
|
.schema('medreport')
|
||||||
|
.from('medipost_actions')
|
||||||
|
.insert({
|
||||||
|
action,
|
||||||
|
xml,
|
||||||
|
has_analysis_results: hasAnalysisResults,
|
||||||
|
medusa_order_id: medusaOrderId,
|
||||||
|
response_xml: responseXml,
|
||||||
|
has_error: hasError,
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.throwOnError();
|
||||||
|
}
|
||||||
|
|||||||
@@ -134,8 +134,10 @@ export async function getAnalysisOrders({
|
|||||||
|
|
||||||
export async function getAnalysisOrdersAdmin({
|
export async function getAnalysisOrdersAdmin({
|
||||||
orderStatus,
|
orderStatus,
|
||||||
|
medusaOrderId,
|
||||||
}: {
|
}: {
|
||||||
orderStatus?: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
|
orderStatus?: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
|
||||||
|
medusaOrderId?: string | null;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const query = getSupabaseServerAdminClient()
|
const query = getSupabaseServerAdminClient()
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
@@ -144,6 +146,9 @@ export async function getAnalysisOrdersAdmin({
|
|||||||
if (orderStatus) {
|
if (orderStatus) {
|
||||||
query.eq('status', orderStatus);
|
query.eq('status', orderStatus);
|
||||||
}
|
}
|
||||||
|
if (medusaOrderId) {
|
||||||
|
query.eq('medusa_order_id', medusaOrderId);
|
||||||
|
}
|
||||||
const orders = await query.order('created_at', { ascending: false }).throwOnError();
|
const orders = await query.order('created_at', { ascending: false }).throwOnError();
|
||||||
return orders.data;
|
return orders.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,20 @@ async function doctorMiddleware(request: NextRequest, response: NextResponse) {
|
|||||||
*/
|
*/
|
||||||
function getPatterns() {
|
function getPatterns() {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
pattern: new URLPattern({ pathname: '/' }),
|
||||||
|
handler: async (req: NextRequest, res: NextResponse) => {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await getUser(req, res);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(pathsConfig.app.home, req.nextUrl.origin).href,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
pattern: new URLPattern({ pathname: '/admin/*?' }),
|
pattern: new URLPattern({ pathname: '/admin/*?' }),
|
||||||
handler: adminMiddleware,
|
handler: adminMiddleware,
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import { SupabaseClient } from '@supabase/supabase-js';
|
import { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
renderAllResultsReceivedEmail,
|
||||||
|
renderFirstResultsReceivedEmail,
|
||||||
|
} from '@kit/email-templates';
|
||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAssignedDoctorAccount,
|
||||||
|
getDoctorAccounts,
|
||||||
|
} from '../../../../../lib/services/account.service';
|
||||||
|
import {
|
||||||
|
NotificationAction,
|
||||||
|
createNotificationLog,
|
||||||
|
} from '../../../../../lib/services/audit/notificationEntries.service';
|
||||||
|
import { sendEmailFromTemplate } from '../../../../../lib/services/mailer.service';
|
||||||
import { RecordChange, Tables } from '../record-change.type';
|
import { RecordChange, Tables } from '../record-change.type';
|
||||||
|
|
||||||
export function createDatabaseWebhookRouterService(
|
export function createDatabaseWebhookRouterService(
|
||||||
@@ -42,6 +55,12 @@ class DatabaseWebhookRouterService {
|
|||||||
return this.handleAccountsWebhook(payload);
|
return this.handleAccountsWebhook(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'analysis_orders': {
|
||||||
|
const payload = body as RecordChange<typeof body.table>;
|
||||||
|
|
||||||
|
return this.handleAnalysisOrdersWebhook(payload);
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -83,4 +102,69 @@ class DatabaseWebhookRouterService {
|
|||||||
return service.handleAccountDeletedWebhook(body.old_record);
|
return service.handleAccountDeletedWebhook(body.old_record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleAnalysisOrdersWebhook(
|
||||||
|
body: RecordChange<'analysis_orders'>,
|
||||||
|
) {
|
||||||
|
if (body.type === 'UPDATE' && body.record && body.old_record) {
|
||||||
|
const { record, old_record } = body;
|
||||||
|
|
||||||
|
if (record.status === old_record.status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let action;
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
analysisOrderId: record.id,
|
||||||
|
language: 'et',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (record.status === 'PARTIAL_ANALYSIS_RESPONSE') {
|
||||||
|
action = NotificationAction.NEW_JOBS_ALERT;
|
||||||
|
|
||||||
|
const doctorAccounts = await getDoctorAccounts();
|
||||||
|
const doctorEmails: string[] = doctorAccounts
|
||||||
|
.map(({ email }) => email)
|
||||||
|
.filter((email): email is string => !!email);
|
||||||
|
|
||||||
|
await sendEmailFromTemplate(
|
||||||
|
renderFirstResultsReceivedEmail,
|
||||||
|
data,
|
||||||
|
doctorEmails,
|
||||||
|
);
|
||||||
|
} else if (record.status === 'FULL_ANALYSIS_RESPONSE') {
|
||||||
|
action = NotificationAction.PATIENT_RESULTS_RECEIVED_ALERT;
|
||||||
|
const doctorAccount = await getAssignedDoctorAccount(record.id);
|
||||||
|
const assignedDoctorEmail = doctorAccount?.email;
|
||||||
|
|
||||||
|
if (!assignedDoctorEmail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendEmailFromTemplate(
|
||||||
|
renderAllResultsReceivedEmail,
|
||||||
|
data,
|
||||||
|
assignedDoctorEmail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
await createNotificationLog({
|
||||||
|
action,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
relatedRecordId: record.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (action)
|
||||||
|
await createNotificationLog({
|
||||||
|
action,
|
||||||
|
status: 'FAIL',
|
||||||
|
comment: e?.message,
|
||||||
|
relatedRecordId: record.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
packages/email-templates/src/components/email-button.tsx
Normal file
16
packages/email-templates/src/components/email-button.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Button } from '@react-email/components';
|
||||||
|
|
||||||
|
export function EmailButton(
|
||||||
|
props: React.PropsWithChildren<{
|
||||||
|
href: string;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="hover:bg-primary/90 inline-flex w-full items-center justify-center gap-1 rounded bg-[#16a249] py-3 text-center text-[16px] font-semibold whitespace-nowrap text-white no-underline shadow-xs transition-colors focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
href={props.href}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Head,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Tailwind,
|
||||||
|
Text,
|
||||||
|
render
|
||||||
|
} from '@react-email/components';
|
||||||
|
|
||||||
|
import { BodyStyle } from '../components/body-style';
|
||||||
|
import CommonFooter from '../components/common-footer';
|
||||||
|
import { EmailContent } from '../components/content';
|
||||||
|
import { EmailButton } from '../components/email-button';
|
||||||
|
import { EmailHeader } from '../components/header';
|
||||||
|
import { EmailHeading } from '../components/heading';
|
||||||
|
import { EmailWrapper } from '../components/wrapper';
|
||||||
|
import { initializeEmailI18n } from '../lib/i18n';
|
||||||
|
|
||||||
|
export async function renderAllResultsReceivedEmail({
|
||||||
|
language,
|
||||||
|
analysisOrderId,
|
||||||
|
}: {
|
||||||
|
language: string;
|
||||||
|
analysisOrderId: number;
|
||||||
|
}) {
|
||||||
|
const namespace = 'all-results-received-email';
|
||||||
|
|
||||||
|
const { t } = await initializeEmailI18n({
|
||||||
|
language,
|
||||||
|
namespace: [namespace, 'common'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewText = t(`${namespace}:previewText`);
|
||||||
|
|
||||||
|
const subject = t(`${namespace}:subject`);
|
||||||
|
|
||||||
|
const html = await render(
|
||||||
|
<Html>
|
||||||
|
<Head>
|
||||||
|
<BodyStyle />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
|
||||||
|
<Tailwind>
|
||||||
|
<Body>
|
||||||
|
<EmailWrapper>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{previewText}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
|
|
||||||
|
<EmailContent>
|
||||||
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
{t(`${namespace}:hello`)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
|
||||||
|
{t(`${namespace}:openOrdersHeading`)}
|
||||||
|
</Text>
|
||||||
|
<EmailButton
|
||||||
|
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||||
|
>
|
||||||
|
{t(`${namespace}:linkText`)}
|
||||||
|
</EmailButton>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
{t(`${namespace}:ifLinksDisabled`)}{' '}
|
||||||
|
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||||
|
</Text>
|
||||||
|
<CommonFooter t={t} />
|
||||||
|
</EmailContent>
|
||||||
|
</EmailWrapper>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
subject,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Button,
|
|
||||||
Head,
|
Head,
|
||||||
Html,
|
Html,
|
||||||
Link,
|
|
||||||
Preview,
|
Preview,
|
||||||
Tailwind,
|
Tailwind,
|
||||||
Text,
|
Text,
|
||||||
@@ -13,6 +11,7 @@ import {
|
|||||||
import { BodyStyle } from '../components/body-style';
|
import { BodyStyle } from '../components/body-style';
|
||||||
import CommonFooter from '../components/common-footer';
|
import CommonFooter from '../components/common-footer';
|
||||||
import { EmailContent } from '../components/content';
|
import { EmailContent } from '../components/content';
|
||||||
|
import { EmailButton } from '../components/email-button';
|
||||||
import { EmailHeader } from '../components/header';
|
import { EmailHeader } from '../components/header';
|
||||||
import { EmailHeading } from '../components/heading';
|
import { EmailHeading } from '../components/heading';
|
||||||
import { EmailWrapper } from '../components/wrapper';
|
import { EmailWrapper } from '../components/wrapper';
|
||||||
@@ -20,16 +19,14 @@ import { initializeEmailI18n } from '../lib/i18n';
|
|||||||
|
|
||||||
export async function renderDoctorSummaryReceivedEmail({
|
export async function renderDoctorSummaryReceivedEmail({
|
||||||
language,
|
language,
|
||||||
recipientEmail,
|
|
||||||
recipientName,
|
recipientName,
|
||||||
orderNr,
|
orderNr,
|
||||||
orderId,
|
analysisOrderId,
|
||||||
}: {
|
}: {
|
||||||
language?: string;
|
language?: string;
|
||||||
recipientName: string;
|
recipientName: string;
|
||||||
recipientEmail: string;
|
|
||||||
orderNr: string;
|
orderNr: string;
|
||||||
orderId: number;
|
analysisOrderId: number;
|
||||||
}) {
|
}) {
|
||||||
const namespace = 'doctor-summary-received-email';
|
const namespace = 'doctor-summary-received-email';
|
||||||
|
|
||||||
@@ -38,8 +35,6 @@ export async function renderDoctorSummaryReceivedEmail({
|
|||||||
namespace: [namespace, 'common'],
|
namespace: [namespace, 'common'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const to = recipientEmail;
|
|
||||||
|
|
||||||
const previewText = t(`${namespace}:previewText`, {
|
const previewText = t(`${namespace}:previewText`, {
|
||||||
orderNr,
|
orderNr,
|
||||||
});
|
});
|
||||||
@@ -72,14 +67,15 @@ export async function renderDoctorSummaryReceivedEmail({
|
|||||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
{t(`${namespace}:summaryReceivedForOrder`, { orderNr })}
|
{t(`${namespace}:summaryReceivedForOrder`, { orderNr })}
|
||||||
</Text>
|
</Text>
|
||||||
<Link
|
|
||||||
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/order/${orderId}`}
|
<EmailButton
|
||||||
|
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
|
||||||
>
|
>
|
||||||
<Button> {t(`${namespace}:linkText`, { orderNr })}</Button>
|
{t(`${namespace}:linkText`, { orderNr })}
|
||||||
</Link>
|
</EmailButton>
|
||||||
<Text>
|
<Text>
|
||||||
{t(`${namespace}:ifButtonDisabled`)}{' '}
|
{t(`${namespace}:ifButtonDisabled`)}{' '}
|
||||||
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/order/${orderId}`}
|
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
|
||||||
</Text>
|
</Text>
|
||||||
<CommonFooter t={t} />
|
<CommonFooter t={t} />
|
||||||
</EmailContent>
|
</EmailContent>
|
||||||
@@ -92,6 +88,5 @@ export async function renderDoctorSummaryReceivedEmail({
|
|||||||
return {
|
return {
|
||||||
html,
|
html,
|
||||||
subject,
|
subject,
|
||||||
to,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Head,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Tailwind,
|
||||||
|
Text,
|
||||||
|
render,
|
||||||
|
} from '@react-email/components';
|
||||||
|
|
||||||
|
import { BodyStyle } from '../components/body-style';
|
||||||
|
import CommonFooter from '../components/common-footer';
|
||||||
|
import { EmailContent } from '../components/content';
|
||||||
|
import { EmailButton } from '../components/email-button';
|
||||||
|
import { EmailHeader } from '../components/header';
|
||||||
|
import { EmailHeading } from '../components/heading';
|
||||||
|
import { EmailWrapper } from '../components/wrapper';
|
||||||
|
import { initializeEmailI18n } from '../lib/i18n';
|
||||||
|
|
||||||
|
export async function renderFirstResultsReceivedEmail({
|
||||||
|
language,
|
||||||
|
analysisOrderId,
|
||||||
|
}: {
|
||||||
|
language: string;
|
||||||
|
analysisOrderId: number;
|
||||||
|
}) {
|
||||||
|
const namespace = 'first-results-received-email';
|
||||||
|
|
||||||
|
const { t } = await initializeEmailI18n({
|
||||||
|
language,
|
||||||
|
namespace: [namespace, 'common'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewText = t(`${namespace}:previewText`);
|
||||||
|
|
||||||
|
const subject = t(`${namespace}:subject`);
|
||||||
|
|
||||||
|
const html = await render(
|
||||||
|
<Html>
|
||||||
|
<Head>
|
||||||
|
<BodyStyle />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
|
||||||
|
<Tailwind>
|
||||||
|
<Body>
|
||||||
|
<EmailWrapper>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{previewText}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
|
|
||||||
|
<EmailContent>
|
||||||
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
{t(`${namespace}:hello`)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
{t(`${namespace}:resultsReceivedForOrders`)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
|
||||||
|
{t(`${namespace}:openOrdersHeading`)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<EmailButton
|
||||||
|
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||||
|
>
|
||||||
|
{t(`${namespace}:linkText`)}
|
||||||
|
</EmailButton>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
{t(`${namespace}:ifLinksDisabled`)}{' '}
|
||||||
|
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
|
||||||
|
</Text>
|
||||||
|
<CommonFooter t={t} />
|
||||||
|
</EmailContent>
|
||||||
|
</EmailWrapper>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
subject,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Head,
|
||||||
|
Html,
|
||||||
|
Link,
|
||||||
|
Preview,
|
||||||
|
Tailwind,
|
||||||
|
Text,
|
||||||
|
render
|
||||||
|
} from '@react-email/components';
|
||||||
|
|
||||||
|
import { BodyStyle } from '../components/body-style';
|
||||||
|
import CommonFooter from '../components/common-footer';
|
||||||
|
import { EmailContent } from '../components/content';
|
||||||
|
import { EmailHeader } from '../components/header';
|
||||||
|
import { EmailHeading } from '../components/heading';
|
||||||
|
import { EmailWrapper } from '../components/wrapper';
|
||||||
|
import { initializeEmailI18n } from '../lib/i18n';
|
||||||
|
|
||||||
|
export async function renderNewJobsAvailableEmail({
|
||||||
|
language,
|
||||||
|
analysisResponseIds,
|
||||||
|
}: {
|
||||||
|
language?: string;
|
||||||
|
analysisResponseIds: number[];
|
||||||
|
}) {
|
||||||
|
const namespace = 'new-jobs-available-email';
|
||||||
|
|
||||||
|
const { t } = await initializeEmailI18n({
|
||||||
|
language,
|
||||||
|
namespace: [namespace, 'common'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewText = t(`${namespace}:previewText`, {
|
||||||
|
nr: analysisResponseIds.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subject = t(`${namespace}:subject`, {
|
||||||
|
nr: analysisResponseIds.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = await render(
|
||||||
|
<Html>
|
||||||
|
<Head>
|
||||||
|
<BodyStyle />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
|
||||||
|
<Tailwind>
|
||||||
|
<Body>
|
||||||
|
<EmailWrapper>
|
||||||
|
<EmailHeader>
|
||||||
|
<EmailHeading>{previewText}</EmailHeading>
|
||||||
|
</EmailHeader>
|
||||||
|
|
||||||
|
<EmailContent>
|
||||||
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
{t(`${namespace}:hello`)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||||
|
{t(`${namespace}:resultsReceivedForOrders`, {
|
||||||
|
nr: analysisResponseIds.length,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
|
||||||
|
{t(`${namespace}:openOrdersHeading`, {
|
||||||
|
nr: analysisResponseIds.length,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<ul className="list-none text-[16px] leading-[24px]">
|
||||||
|
{analysisResponseIds.map((analysisResponseId, index) => (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
key={analysisResponseId}
|
||||||
|
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisResponseId}`}
|
||||||
|
>
|
||||||
|
{t(`${namespace}:linkText`, { nr: index + 1 })}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Text>
|
||||||
|
{t(`${namespace}:ifLinksDisabled`)}{' '}
|
||||||
|
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/open-jobs`}
|
||||||
|
</Text>
|
||||||
|
<CommonFooter t={t} />
|
||||||
|
</EmailContent>
|
||||||
|
</EmailWrapper>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
subject,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ export async function renderOtpEmail(props: Props) {
|
|||||||
|
|
||||||
<Section className="mb-[16px] mt-[16px] text-center">
|
<Section className="mb-[16px] mt-[16px] text-center">
|
||||||
<Button className={'w-full rounded bg-neutral-950 text-center'}>
|
<Button className={'w-full rounded bg-neutral-950 text-center'}>
|
||||||
<Text className="text-[16px] font-medium font-semibold leading-[16px] text-white">
|
<Text className="text-[16px] font-semibold leading-[16px] text-white">
|
||||||
{props.otp}
|
{props.otp}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import {
|
|||||||
} from '@react-email/components';
|
} from '@react-email/components';
|
||||||
|
|
||||||
import { BodyStyle } from '../components/body-style';
|
import { BodyStyle } from '../components/body-style';
|
||||||
|
import CommonFooter from '../components/common-footer';
|
||||||
import { EmailContent } from '../components/content';
|
import { EmailContent } from '../components/content';
|
||||||
import { EmailHeader } from '../components/header';
|
import { EmailHeader } from '../components/header';
|
||||||
import { EmailHeading } from '../components/heading';
|
import { EmailHeading } from '../components/heading';
|
||||||
import { EmailWrapper } from '../components/wrapper';
|
import { EmailWrapper } from '../components/wrapper';
|
||||||
import { initializeEmailI18n } from '../lib/i18n';
|
import { initializeEmailI18n } from '../lib/i18n';
|
||||||
import CommonFooter from '../components/common-footer';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
analysisPackageName: string;
|
analysisPackageName: string;
|
||||||
@@ -31,7 +31,10 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) {
|
|||||||
namespace: [namespace, 'common'],
|
namespace: [namespace, 'common'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const previewText = t(`${namespace}:previewText`);
|
const previewText = t(`${namespace}:previewText`, {
|
||||||
|
analysisPackageName: props.analysisPackageName,
|
||||||
|
});
|
||||||
|
|
||||||
const subject = t(`${namespace}:subject`, {
|
const subject = t(`${namespace}:subject`, {
|
||||||
analysisPackageName: props.analysisPackageName,
|
analysisPackageName: props.analysisPackageName,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ export * from './emails/otp.email';
|
|||||||
export * from './emails/company-offer.email';
|
export * from './emails/company-offer.email';
|
||||||
export * from './emails/synlab.email';
|
export * from './emails/synlab.email';
|
||||||
export * from './emails/doctor-summary-received.email';
|
export * from './emails/doctor-summary-received.email';
|
||||||
|
export * from './emails/new-jobs-available.email';
|
||||||
|
export * from './emails/first-results-received.email';
|
||||||
|
export * from './emails/all-results-received.email';
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"previewText": "All analysis results have been received",
|
||||||
|
"subject": "All patient analysis results have been received",
|
||||||
|
"openOrdersHeading": "Review the results and prepare a summary:",
|
||||||
|
"linkText": "See results",
|
||||||
|
"ifLinksDisabled": "If the link does not work, you can see the results by copying this link into your browser.",
|
||||||
|
"hello": "Hello"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"subject": "New Company Join Request",
|
||||||
|
"previewText": "The company {{companyName}} is requesting a quote",
|
||||||
|
"companyName": "Company Name:",
|
||||||
|
"contactPerson": "Contact Person:",
|
||||||
|
"email": "Email:",
|
||||||
|
"phone": "Phone:"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"previewText": "First analysis responses received",
|
||||||
|
"subject": "New job - first analysis responses received",
|
||||||
|
"resultsReceivedForOrders": "New job available to claim",
|
||||||
|
"openOrdersHeading": "See here:",
|
||||||
|
"linkText": "See results",
|
||||||
|
"ifLinksDisabled": "If the link does not work, you can see available jobs by copying this link into your browser.",
|
||||||
|
"hello": "Hello,"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"previewText": "New jobs available",
|
||||||
|
"subject": "Please write a summary",
|
||||||
|
"resultsReceivedForOrders": "Please review the results and write a summary.",
|
||||||
|
"openOrdersHeading": "See here:",
|
||||||
|
"linkText": "Open job {{nr}}",
|
||||||
|
"ifLinksDisabled": "If the links do not work, you can see available jobs by copying this link into your browser.",
|
||||||
|
"hello": "Hello,"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"previewText": "Kõik analüüside vastused on saabunud",
|
||||||
|
"subject": "Patsiendi kõikide analüüside vastused on saabunud",
|
||||||
|
"openOrdersHeading": "Vaata tulemusi ja kirjuta kokkuvõte:",
|
||||||
|
"linkText": "Vaata tulemusi",
|
||||||
|
"ifLinksDisabled": "Kui link ei tööta, näed analüüsitulemusi sellelt aadressilt:",
|
||||||
|
"hello": "Tere"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"previewText": "Saabusid esimesed analüüside vastused",
|
||||||
|
"subject": "Uus töö - saabusid esimesed analüüside vastused",
|
||||||
|
"resultsReceivedForOrders": "Patsiendile saabusid esimesed analüüside vastused.",
|
||||||
|
"openOrdersHeading": "Vaata siit:",
|
||||||
|
"linkText": "Vaata tulemusi",
|
||||||
|
"ifLinksDisabled": "Kui link ei tööta, näed analüüsitulemusi sellelt aadressilt:",
|
||||||
|
"hello": "Tere"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"previewText": "Palun koosta kokkuvõte",
|
||||||
|
"subject": "Palun koosta kokkuvõte",
|
||||||
|
"resultsReceivedForOrders": "Palun vaata tulemused üle ja kirjuta kokkuvõte.",
|
||||||
|
"openOrdersHeading": "Vaata siit:",
|
||||||
|
"linkText": "Töö {{nr}}",
|
||||||
|
"ifLinksDisabled": "Kui lingid ei tööta, näed vabasid töid sellelt aadressilt:",
|
||||||
|
"hello": "Tere"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"previewText": "All analysis results have been received",
|
||||||
|
"subject": "All patient analysis results have been received",
|
||||||
|
"openOrdersHeading": "Review the results and prepare a summary:",
|
||||||
|
"linkText": "See results",
|
||||||
|
"ifLinksDisabled": "If the link does not work, you can see the results by copying this link into your browser.",
|
||||||
|
"hello": "Hello"
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"footer": {
|
"footer": {
|
||||||
"lines1": "MedReport",
|
"lines1": "MedReport",
|
||||||
"lines2": "E-mail: <a href=\"mailto:info@medreport.ee\">info@medreport.ee</a>",
|
"lines2": "Электронная почта: <a href=\"mailto:info@medreport.ee\">info@medreport.ee</a>",
|
||||||
"lines3": "Klienditugi: <a href=\"tel:+37258871517\">+372 5887 1517</a>",
|
"lines3": "Служба поддержки: <a href=\"tel:+37258871517\">+372 5887 1517</a>",
|
||||||
"lines4": "<a href=\"https://www.medreport.ee\">www.medreport.ee</a>"
|
"lines4": "<a href=\"https://www.medreport.ee\">www.medreport.ee</a>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"subject": "Uus ettevõtte liitumispäring",
|
"subject": "Новый запрос на присоединение компании",
|
||||||
"previewText": "Ettevõte {{companyName}} soovib pakkumist",
|
"previewText": "Компания {{companyName}} запрашивает предложение",
|
||||||
"companyName": "Ettevõtte nimi:",
|
"companyName": "Название компании:",
|
||||||
"contactPerson": "Kontaktisik:",
|
"contactPerson": "Контактное лицо:",
|
||||||
"email": "E-mail:",
|
"email": "Электронная почта:",
|
||||||
"phone": "Telefon:"
|
"phone": "Телефон:"
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"subject": "Saabus arsti kokkuvõtte tellimusele {{orderNr}}",
|
"subject": "Получено заключение врача по заказу {{orderNr}}",
|
||||||
"previewText": "Arst on saatnud kokkuvõtte sinu analüüsitulemustele.",
|
"previewText": "Врач отправил заключение по вашим результатам анализа.",
|
||||||
"hello": "Tere, {{displayName}}",
|
"hello": "Здравствуйте, {{displayName}}",
|
||||||
"summaryReceivedForOrder": "Arst on koostanud selgitava kokkuvõtte sinu tellitud analüüsidele.",
|
"summaryReceivedForOrder": "Врач подготовил пояснительное заключение по заказанным вами анализам.",
|
||||||
"linkText": "Vaata kokkuvõtet",
|
"linkText": "Посмотреть заключение",
|
||||||
"ifButtonDisabled": "Kui nupule vajutamine ei toimi, kopeeri see link oma brauserisse:"
|
"ifButtonDisabled": "Если кнопка не работает, скопируйте эту ссылку в ваш браузер:"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"previewText": "First analysis responses received",
|
||||||
|
"subject": "New job - first analysis responses received",
|
||||||
|
"resultsReceivedForOrders": "New job available to claim",
|
||||||
|
"openOrdersHeading": "See here:",
|
||||||
|
"linkText": "See results",
|
||||||
|
"ifLinksDisabled": "If the link does not work, you can see the results by copying this link into your browser.",
|
||||||
|
"hello": "Hello,"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"previewText": "New jobs available",
|
||||||
|
"subject": "Please write a summary",
|
||||||
|
"resultsReceivedForOrders": "Please review the results and write a summary.",
|
||||||
|
"openOrdersHeading": "See here:",
|
||||||
|
"linkText": "Open job {{nr}}",
|
||||||
|
"ifLinksDisabled": "If the links do not work, you can see available jobs by copying this link into your browser.",
|
||||||
|
"hello": "Hello,"
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"subject": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}",
|
"subject": "Ваш заказ Medreport подтвержден - {{analysisPackageName}}",
|
||||||
"previewText": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}",
|
"previewText": "Ваш заказ Medreport подтвержден - {{analysisPackageName}}",
|
||||||
"heading": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}",
|
"heading": "Ваш заказ Medreport подтвержден - {{analysisPackageName}}",
|
||||||
"hello": "Tere {{personName}},",
|
"hello": "Здравствуйте, {{personName}},",
|
||||||
"lines1": "Saatekiri {{analysisPackageName}} analüüsi uuringuteks on saadetud laborisse digitaalselt. Palun mine proove andma: Synlab - {{partnerLocationName}}",
|
"lines1": "Направление на исследование {{analysisPackageName}} было отправлено в лабораторию в цифровом виде. Пожалуйста, сдайте анализы: Synlab - {{partnerLocationName}}",
|
||||||
"lines2": "<i>Kui Teil ei ole võimalik valitud asukohta minna proove andma, siis võite minna endale sobivasse proovivõtupunkti - <a href=\"https://medreport.ee/et/verevotupunktid\">vaata asukohti ja lahtiolekuaegasid</a>.</i>",
|
"lines2": "<i>Если вы не можете посетить выбранный пункт сдачи анализов, вы можете обратиться в удобный для вас пункт - <a href=\"https://medreport.ee/et/verevotupunktid\">посмотреть адреса и часы работы</a>.</i>",
|
||||||
"lines3": "Soovituslik on proove anda pigem hommikul (enne 12:00) ning söömata ja joomata (vett võib juua).",
|
"lines3": "Рекомендуется сдавать анализы утром (до 12:00) натощак (можно пить воду).",
|
||||||
"lines4": "Proovivõtupunktis valige järjekorrasüsteemis: <strong>saatekirjad</strong> alt <strong>eriarsti saatekiri</strong>.",
|
"lines4": "В пункте сдачи анализов выберите в системе очереди: <strong>направления</strong> -> <strong>направление от специалиста</strong>.",
|
||||||
"lines5": "Juhul kui tekkis lisaküsimusi, siis võtke julgelt ühendust.",
|
"lines5": "Если у вас возникнут дополнительные вопросы, смело свяжитесь с нами.",
|
||||||
"lines6": "SYNLAB klienditoe telefon: <a href=\"tel:+37217123\">17123</a>"
|
"lines6": "Телефон службы поддержки SYNLAB: <a href=\"tel:+37217123\">17123</a>"
|
||||||
}
|
}
|
||||||
@@ -5,12 +5,16 @@ import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
|||||||
|
|
||||||
type UpdateData = Database['medreport']['Tables']['accounts']['Update'];
|
type UpdateData = Database['medreport']['Tables']['accounts']['Update'];
|
||||||
|
|
||||||
export function useUpdateAccountData(accountId: string) {
|
export function useUpdateAccountData(accountId?: string) {
|
||||||
const client = useSupabase();
|
const client = useSupabase();
|
||||||
|
|
||||||
const mutationKey = ['account:data', accountId];
|
const mutationKey = ['account:data', accountId];
|
||||||
|
|
||||||
const mutationFn = async (data: UpdateData) => {
|
const mutationFn = async (data: UpdateData) => {
|
||||||
|
if (!accountId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await client
|
const response = await client
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { SupabaseClient } from '@supabase/supabase-js';
|
|||||||
|
|
||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
import { UserAnalysis } from '../types/accounts';
|
import {
|
||||||
|
AnalysisResultDetails,
|
||||||
|
UserAnalysis,
|
||||||
|
} from '../types/accounts';
|
||||||
|
|
||||||
export type AccountWithParams =
|
export type AccountWithParams =
|
||||||
Database['medreport']['Tables']['accounts']['Row'] & {
|
Database['medreport']['Tables']['accounts']['Row'] & {
|
||||||
@@ -184,7 +187,49 @@ class AccountsApi {
|
|||||||
return response.data?.customer_id;
|
return response.data?.customer_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserAnalysis(): Promise<UserAnalysis | null> {
|
async getUserAnalysis(
|
||||||
|
analysisOrderId: number,
|
||||||
|
): Promise<AnalysisResultDetails | null> {
|
||||||
|
const authUser = await this.client.auth.getUser();
|
||||||
|
const { data, error: userError } = authUser;
|
||||||
|
|
||||||
|
if (userError) {
|
||||||
|
console.error('Failed to get user', userError);
|
||||||
|
throw userError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = data;
|
||||||
|
|
||||||
|
const { data: analysisResponse } = await this.client
|
||||||
|
.schema('medreport')
|
||||||
|
.from('analysis_responses')
|
||||||
|
.select(
|
||||||
|
`*,
|
||||||
|
elements:analysis_response_elements(analysis_name,norm_status,response_value,unit,norm_lower_included,norm_upper_included,norm_lower,norm_upper,response_time),
|
||||||
|
order:analysis_order_id(medusa_order_id, status, created_at),
|
||||||
|
summary:analysis_order_id(doctor_analysis_feedback(*))`,
|
||||||
|
)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('analysis_order_id', analysisOrderId)
|
||||||
|
.throwOnError();
|
||||||
|
|
||||||
|
const responseWithElements = analysisResponse?.[0];
|
||||||
|
if (!responseWithElements) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedback = responseWithElements.summary.doctor_analysis_feedback?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...responseWithElements,
|
||||||
|
summary:
|
||||||
|
feedback?.status === 'COMPLETED'
|
||||||
|
? responseWithElements.summary.doctor_analysis_feedback?.[0]
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserAnalyses(): Promise<UserAnalysis | null> {
|
||||||
const authUser = await this.client.auth.getUser();
|
const authUser = await this.client.auth.getUser();
|
||||||
const { data, error: userError } = authUser;
|
const { data, error: userError } = authUser;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
export type UserAnalysisElement =
|
export type UserAnalysisElement =
|
||||||
@@ -15,3 +17,51 @@ export enum ApplicationRoleEnum {
|
|||||||
Doctor = 'doctor',
|
Doctor = 'doctor',
|
||||||
SuperAdmin = 'super_admin',
|
SuperAdmin = 'super_admin',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ElementSchema = z.object({
|
||||||
|
unit: z.string(),
|
||||||
|
norm_lower: z.number(),
|
||||||
|
norm_upper: z.number(),
|
||||||
|
norm_status: z.number(),
|
||||||
|
analysis_name: z.string(),
|
||||||
|
response_time: z.string(),
|
||||||
|
response_value: z.number(),
|
||||||
|
norm_lower_included: z.boolean(),
|
||||||
|
norm_upper_included: z.boolean(),
|
||||||
|
});
|
||||||
|
export type Element = z.infer<typeof ElementSchema>;
|
||||||
|
|
||||||
|
export const OrderSchema = z.object({
|
||||||
|
status: z.string(),
|
||||||
|
medusa_order_id: z.string(),
|
||||||
|
created_at: z.coerce.date(),
|
||||||
|
});
|
||||||
|
export type Order = z.infer<typeof OrderSchema>;
|
||||||
|
|
||||||
|
export const SummarySchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
value: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
user_id: z.string(),
|
||||||
|
created_at: z.coerce.date(),
|
||||||
|
created_by: z.string(),
|
||||||
|
updated_at: z.coerce.date().nullable(),
|
||||||
|
updated_by: z.string(),
|
||||||
|
doctor_user_id: z.string().nullable(),
|
||||||
|
analysis_order_id: z.number(),
|
||||||
|
});
|
||||||
|
export type Summary = z.infer<typeof SummarySchema>;
|
||||||
|
|
||||||
|
export const AnalysisResultDetailsSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
analysis_order_id: z.number(),
|
||||||
|
order_number: z.string(),
|
||||||
|
order_status: z.string(),
|
||||||
|
user_id: z.string(),
|
||||||
|
created_at: z.coerce.date(),
|
||||||
|
updated_at: z.coerce.date().nullable(),
|
||||||
|
elements: z.array(ElementSchema),
|
||||||
|
order: OrderSchema,
|
||||||
|
summary: SummarySchema.nullable(),
|
||||||
|
});
|
||||||
|
export type AnalysisResultDetails = z.infer<typeof AnalysisResultDetailsSchema>;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function AuthLayoutShell({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'flex h-screen flex-col items-center justify-center' +
|
'sm:py-auto flex flex-col items-center justify-center py-6' +
|
||||||
' bg-background lg:bg-muted/30 gap-y-10 lg:gap-y-8' +
|
' bg-background lg:bg-muted/30 gap-y-10 lg:gap-y-8' +
|
||||||
' animate-in fade-in slide-in-from-top-16 zoom-in-95 duration-1000'
|
' animate-in fade-in slide-in-from-top-16 zoom-in-95 duration-1000'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const OauthProviders: React.FC<{
|
|||||||
redirectTo,
|
redirectTo,
|
||||||
queryParams: props.queryParams,
|
queryParams: props.queryParams,
|
||||||
scopes,
|
scopes,
|
||||||
|
skipBrowserRedirect: false,
|
||||||
},
|
},
|
||||||
} satisfies SignInWithOAuthCredentials;
|
} satisfies SignInWithOAuthCredentials;
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const PatientSchema = z.object({
|
|||||||
email: z.string().nullable(),
|
email: z.string().nullable(),
|
||||||
height: z.number().optional().nullable(),
|
height: z.number().optional().nullable(),
|
||||||
weight: z.number().optional().nullable(),
|
weight: z.number().optional().nullable(),
|
||||||
|
preferred_locale: z.string().nullable(),
|
||||||
});
|
});
|
||||||
export type Patient = z.infer<typeof PatientSchema>;
|
export type Patient = z.infer<typeof PatientSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const AccountSchema = z.object({
|
|||||||
last_name: z.string().nullable(),
|
last_name: z.string().nullable(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
primary_owner_user_id: z.string(),
|
primary_owner_user_id: z.string(),
|
||||||
|
preferred_locale: z.string().nullable(),
|
||||||
});
|
});
|
||||||
export type Account = z.infer<typeof AccountSchema>;
|
export type Account = z.infer<typeof AccountSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import 'server-only';
|
|||||||
|
|
||||||
import { isBefore } from 'date-fns';
|
import { isBefore } from 'date-fns';
|
||||||
|
|
||||||
|
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
|
||||||
import { getFullName } from '@kit/shared/utils';
|
import { getFullName } from '@kit/shared/utils';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { sendDoctorSummaryCompletedEmail } from '../../../../../../../lib/services/mailer.service';
|
import { sendEmailFromTemplate } from '../../../../../../../lib/services/mailer.service';
|
||||||
import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema';
|
import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema';
|
||||||
import {
|
import {
|
||||||
AnalysisResponseBase,
|
AnalysisResponseBase,
|
||||||
@@ -54,7 +55,7 @@ async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
|
|||||||
supabase
|
supabase
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
.select('name, last_name, id, primary_owner_user_id')
|
.select('name, last_name, id, primary_owner_user_id, preferred_locale')
|
||||||
.in('primary_owner_user_id', userIds),
|
.in('primary_owner_user_id', userIds),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
|
|||||||
? await supabase
|
? await supabase
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
.select('name, last_name, id, primary_owner_user_id')
|
.select('name, last_name, id, primary_owner_user_id, preferred_locale')
|
||||||
.in('primary_owner_user_id', doctorUserIds)
|
.in('primary_owner_user_id', doctorUserIds)
|
||||||
: { data: [] };
|
: { data: [] };
|
||||||
|
|
||||||
@@ -408,7 +409,7 @@ export async function getAnalysisResultsForDoctor(
|
|||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
.select(
|
.select(
|
||||||
`primary_owner_user_id, id, name, last_name, personal_code, phone, email,
|
`primary_owner_user_id, id, name, last_name, personal_code, phone, email, preferred_locale,
|
||||||
account_params(height,weight)`,
|
account_params(height,weight)`,
|
||||||
)
|
)
|
||||||
.eq('is_personal_account', true)
|
.eq('is_personal_account', true)
|
||||||
@@ -472,6 +473,7 @@ export async function getAnalysisResultsForDoctor(
|
|||||||
personal_code,
|
personal_code,
|
||||||
phone,
|
phone,
|
||||||
account_params,
|
account_params,
|
||||||
|
preferred_locale,
|
||||||
} = accountWithParams[0];
|
} = accountWithParams[0];
|
||||||
|
|
||||||
const analysisResponseElementsWithPreviousData = [];
|
const analysisResponseElementsWithPreviousData = [];
|
||||||
@@ -503,6 +505,7 @@ export async function getAnalysisResultsForDoctor(
|
|||||||
},
|
},
|
||||||
doctorFeedback: doctorFeedback?.[0],
|
doctorFeedback: doctorFeedback?.[0],
|
||||||
patient: {
|
patient: {
|
||||||
|
preferred_locale,
|
||||||
userId: primary_owner_user_id,
|
userId: primary_owner_user_id,
|
||||||
accountId,
|
accountId,
|
||||||
firstName: name,
|
firstName: name,
|
||||||
@@ -638,7 +641,7 @@ export async function submitFeedback(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'COMPLETED') {
|
if (status === 'COMPLETED') {
|
||||||
const [{ data: recipient }, { data: medusaOrderIds }] = await Promise.all([
|
const [{ data: recipient }, { data: analysisOrder }] = await Promise.all([
|
||||||
supabase
|
supabase
|
||||||
.schema('medreport')
|
.schema('medreport')
|
||||||
.from('accounts')
|
.from('accounts')
|
||||||
@@ -653,24 +656,33 @@ export async function submitFeedback(
|
|||||||
.eq('id', analysisOrderId)
|
.eq('id', analysisOrderId)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.throwOnError(),
|
.throwOnError(),
|
||||||
|
supabase
|
||||||
|
.schema('medreport')
|
||||||
|
.from('analysis_orders')
|
||||||
|
.update({ status: 'COMPLETED' })
|
||||||
|
.eq('id', analysisOrderId)
|
||||||
|
.throwOnError(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!recipient?.[0]?.email) {
|
if (!recipient?.[0]?.email) {
|
||||||
throw new Error('Could not find user email.');
|
throw new Error('Could not find user email.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!medusaOrderIds?.[0]?.id) {
|
if (!analysisOrder?.[0]?.id) {
|
||||||
throw new Error('Could not retrieve order.');
|
throw new Error('Could not retrieve order.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { preferred_locale, name, last_name, email } = recipient[0];
|
const { preferred_locale, name, last_name, email } = recipient[0];
|
||||||
|
|
||||||
await sendDoctorSummaryCompletedEmail(
|
await sendEmailFromTemplate(
|
||||||
preferred_locale ?? 'et',
|
renderDoctorSummaryReceivedEmail,
|
||||||
getFullName(name, last_name),
|
{
|
||||||
|
language: preferred_locale ?? 'et',
|
||||||
|
recipientName: getFullName(name, last_name),
|
||||||
|
orderNr: analysisOrder?.[0]?.medusa_order_id ?? '',
|
||||||
|
analysisOrderId: analysisOrder[0].id,
|
||||||
|
},
|
||||||
email,
|
email,
|
||||||
medusaOrderIds?.[0]?.medusa_order_id ?? '',
|
|
||||||
medusaOrderIds[0].id,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { sdk } from "@lib/config"
|
import { sdk } from "@lib/config";
|
||||||
import { HttpTypes } from "@medusajs/types"
|
import { HttpTypes } from "@medusajs/types";
|
||||||
import { getCacheOptions } from "./cookies"
|
import { getCacheOptions } from "./cookies";
|
||||||
|
|
||||||
export const listCategories = async (query?: Record<string, any>) => {
|
export const listCategories = async (query?: Record<string, any>) => {
|
||||||
const next = {
|
const next = {
|
||||||
...(await getCacheOptions("categories")),
|
...(await getCacheOptions("categories")),
|
||||||
}
|
};
|
||||||
|
|
||||||
const limit = query?.limit || 100
|
const limit = query?.limit || 100;
|
||||||
|
|
||||||
return sdk.client
|
return sdk.client
|
||||||
.fetch<{ product_categories: HttpTypes.StoreProductCategory[] }>(
|
.fetch<{ product_categories: HttpTypes.StoreProductCategory[] }>(
|
||||||
@@ -23,8 +23,8 @@ export const listCategories = async (query?: Record<string, any>) => {
|
|||||||
cache: "force-cache",
|
cache: "force-cache",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(({ product_categories }) => product_categories)
|
.then(({ product_categories }) => product_categories);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const getCategoryByHandle = async (categoryHandle: string[]) => {
|
export const getCategoryByHandle = async (categoryHandle: string[]) => {
|
||||||
const { product_categories } = await getProductCategories({
|
const { product_categories } = await getProductCategories({
|
||||||
@@ -32,7 +32,7 @@ export const getCategoryByHandle = async (categoryHandle: string[]) => {
|
|||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
return product_categories[0];
|
return product_categories[0];
|
||||||
}
|
};
|
||||||
|
|
||||||
export const getProductCategories = async ({
|
export const getProductCategories = async ({
|
||||||
handle,
|
handle,
|
||||||
@@ -45,19 +45,18 @@ export const getProductCategories = async ({
|
|||||||
} = {}) => {
|
} = {}) => {
|
||||||
const next = {
|
const next = {
|
||||||
...(await getCacheOptions("categories")),
|
...(await getCacheOptions("categories")),
|
||||||
}
|
};
|
||||||
|
|
||||||
return sdk.client
|
return sdk.client.fetch<HttpTypes.StoreProductCategoryListResponse>(
|
||||||
.fetch<HttpTypes.StoreProductCategoryListResponse>(
|
`/store/product-categories`,
|
||||||
`/store/product-categories`,
|
{
|
||||||
{
|
query: {
|
||||||
query: {
|
fields,
|
||||||
fields,
|
handle,
|
||||||
handle,
|
limit,
|
||||||
limit,
|
},
|
||||||
},
|
next,
|
||||||
next,
|
//cache: "force-cache",
|
||||||
//cache: "force-cache",
|
}
|
||||||
}
|
);
|
||||||
);
|
};
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const listProducts = async ({
|
|||||||
regionId,
|
regionId,
|
||||||
}: {
|
}: {
|
||||||
pageParam?: number
|
pageParam?: number
|
||||||
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string; id?: string[] }
|
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string; id?: string[], category_id?: string }
|
||||||
countryCode?: string
|
countryCode?: string
|
||||||
regionId?: string
|
regionId?: string
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
@@ -63,7 +63,7 @@ export const listProducts = async ({
|
|||||||
offset,
|
offset,
|
||||||
region_id: region?.id,
|
region_id: region?.id,
|
||||||
fields:
|
fields:
|
||||||
"*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags",
|
"*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags,+status",
|
||||||
...queryParams,
|
...queryParams,
|
||||||
},
|
},
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { StoreProduct } from '@medusajs/types';
|
|||||||
import { Button } from '@medusajs/ui';
|
import { Button } from '@medusajs/ui';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { handleAddToCart } from '../../../../lib/services/medusaCart.service';
|
import { handleAddToCart } from '../../../../lib/services/medusaCart.service';
|
||||||
|
import { toast } from '@kit/ui/sonner';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -49,12 +50,19 @@ export default function SelectAnalysisPackage({
|
|||||||
|
|
||||||
const handleSelect = async () => {
|
const handleSelect = async () => {
|
||||||
setIsAddingToCart(true);
|
setIsAddingToCart(true);
|
||||||
await handleAddToCart({
|
try {
|
||||||
selectedVariant: { id: variantId },
|
await handleAddToCart({
|
||||||
countryCode,
|
selectedVariant: { id: variantId },
|
||||||
});
|
countryCode,
|
||||||
setIsAddingToCart(false);
|
});
|
||||||
router.push('/home/cart');
|
setIsAddingToCart(false);
|
||||||
|
toast.success(<Trans i18nKey={'order-analysis-package:analysisPackageAddedToCart'} />);
|
||||||
|
router.push('/home/cart');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(<Trans i18nKey={'order-analysis-package:analysisPackageAddToCartError'} />);
|
||||||
|
setIsAddingToCart(false);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import SelectAnalysisPackage, { AnalysisPackageWithVariant } from './select-analysis-package';
|
import SelectAnalysisPackage, {
|
||||||
|
AnalysisPackageWithVariant,
|
||||||
|
} from './select-analysis-package';
|
||||||
|
|
||||||
export default function SelectAnalysisPackages({
|
export default function SelectAnalysisPackages({
|
||||||
analysisPackages,
|
analysisPackages,
|
||||||
@@ -10,11 +12,16 @@ export default function SelectAnalysisPackages({
|
|||||||
countryCode: string;
|
countryCode: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-6">
|
<div className="grid gap-6 sm:grid-cols-3">
|
||||||
{analysisPackages.length > 0 ? analysisPackages.map(
|
{analysisPackages.length > 0 ? (
|
||||||
(analysisPackage) => (
|
analysisPackages.map((analysisPackage) => (
|
||||||
<SelectAnalysisPackage key={analysisPackage.title} analysisPackage={analysisPackage} countryCode={countryCode} />
|
<SelectAnalysisPackage
|
||||||
)) : (
|
key={analysisPackage.title}
|
||||||
|
analysisPackage={analysisPackage}
|
||||||
|
countryCode={countryCode}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
<h4>
|
<h4>
|
||||||
<Trans i18nKey="order-analysis-package:noPackagesAvailable" />
|
<Trans i18nKey="order-analysis-package:noPackagesAvailable" />
|
||||||
</h4>
|
</h4>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function InfoTooltip({
|
|||||||
content,
|
content,
|
||||||
icon,
|
icon,
|
||||||
}: {
|
}: {
|
||||||
content?: string | null;
|
content?: JSX.Element | string | null;
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
}) {
|
}) {
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
@@ -23,7 +23,7 @@ export function InfoTooltip({
|
|||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
{icon || <Info className="size-4 cursor-pointer" />}
|
{icon || <Info className="size-4 cursor-pointer" />}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{content}</TooltipContent>
|
<TooltipContent className='sm:max-w-[400px]'>{content}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const authConfig = AuthConfigSchema.parse({
|
|||||||
providers: {
|
providers: {
|
||||||
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
|
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
|
||||||
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
|
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
|
||||||
oAuth: ['google'],
|
oAuth: ['keycloak'],
|
||||||
},
|
},
|
||||||
} satisfies z.infer<typeof AuthConfigSchema>);
|
} satisfies z.infer<typeof AuthConfigSchema>);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const PathsSchema = z.object({
|
|||||||
home: z.string().min(1),
|
home: z.string().min(1),
|
||||||
selectPackage: z.string().min(1),
|
selectPackage: z.string().min(1),
|
||||||
booking: z.string().min(1),
|
booking: z.string().min(1),
|
||||||
|
bookingHandle: z.string().min(1),
|
||||||
myOrders: z.string().min(1),
|
myOrders: z.string().min(1),
|
||||||
analysisResults: z.string().min(1),
|
analysisResults: z.string().min(1),
|
||||||
orderAnalysisPackage: z.string().min(1),
|
orderAnalysisPackage: z.string().min(1),
|
||||||
@@ -64,6 +65,7 @@ const pathsConfig = PathsSchema.parse({
|
|||||||
joinTeam: '/join',
|
joinTeam: '/join',
|
||||||
selectPackage: '/select-package',
|
selectPackage: '/select-package',
|
||||||
booking: '/home/booking',
|
booking: '/home/booking',
|
||||||
|
bookingHandle: '/home/booking/[handle]',
|
||||||
orderAnalysisPackage: '/home/order-analysis-package',
|
orderAnalysisPackage: '/home/order-analysis-package',
|
||||||
myOrders: '/home/order',
|
myOrders: '/home/order',
|
||||||
analysisResults: '/home/analysis-results',
|
analysisResults: '/home/analysis-results',
|
||||||
|
|||||||
@@ -35,12 +35,6 @@ const routes = [
|
|||||||
Icon: <ShoppingCart className={iconClasses} />,
|
Icon: <ShoppingCart className={iconClasses} />,
|
||||||
end: true,
|
end: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'common:routes.analysisResults',
|
|
||||||
path: pathsConfig.app.analysisResults,
|
|
||||||
Icon: <TestTube2 className={iconClasses} />,
|
|
||||||
end: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'common:routes.orderAnalysisPackage',
|
label: 'common:routes.orderAnalysisPackage',
|
||||||
path: pathsConfig.app.orderAnalysisPackage,
|
path: pathsConfig.app.orderAnalysisPackage,
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './use-csrf-token';
|
export * from './use-csrf-token';
|
||||||
|
export * from './use-current-locale-language-names';
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
function useLanguageName(currentLanguage: string) {
|
||||||
|
return useMemo(() => {
|
||||||
|
return new Intl.DisplayNames([currentLanguage], {
|
||||||
|
type: 'language',
|
||||||
|
});
|
||||||
|
}, [currentLanguage]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCurrentLocaleLanguageNames() {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const { language: currentLanguage } = i18n;
|
||||||
|
return useLanguageName(currentLanguage);
|
||||||
|
}
|
||||||
@@ -10,5 +10,11 @@ import { getSupabaseClientKeys } from '../get-supabase-client-keys';
|
|||||||
export function getSupabaseBrowserClient<GenericSchema = Database>() {
|
export function getSupabaseBrowserClient<GenericSchema = Database>() {
|
||||||
const keys = getSupabaseClientKeys();
|
const keys = getSupabaseClientKeys();
|
||||||
|
|
||||||
return createBrowserClient<GenericSchema>(keys.url, keys.anonKey);
|
return createBrowserClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||||
|
auth: {
|
||||||
|
flowType: 'pkce',
|
||||||
|
autoRefreshToken: true,
|
||||||
|
persistSession: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export function createMiddlewareClient<GenericSchema = Database>(
|
|||||||
const keys = getSupabaseClientKeys();
|
const keys = getSupabaseClientKeys();
|
||||||
|
|
||||||
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||||
|
auth: {
|
||||||
|
flowType: 'pkce',
|
||||||
|
autoRefreshToken: true,
|
||||||
|
persistSession: true,
|
||||||
|
},
|
||||||
cookies: {
|
cookies: {
|
||||||
getAll() {
|
getAll() {
|
||||||
return request.cookies.getAll();
|
return request.cookies.getAll();
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export function getSupabaseServerClient<GenericSchema = Database>() {
|
|||||||
const keys = getSupabaseClientKeys();
|
const keys = getSupabaseClientKeys();
|
||||||
|
|
||||||
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||||
|
auth: {
|
||||||
|
flowType: 'pkce',
|
||||||
|
autoRefreshToken: true,
|
||||||
|
persistSession: true,
|
||||||
|
},
|
||||||
cookies: {
|
cookies: {
|
||||||
async getAll() {
|
async getAll() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ export type Database = {
|
|||||||
changed_by: string
|
changed_by: string
|
||||||
created_at: string
|
created_at: string
|
||||||
id: number
|
id: number
|
||||||
|
extra_data?: Json | null
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
account_id: string
|
account_id: string
|
||||||
@@ -206,6 +207,7 @@ export type Database = {
|
|||||||
changed_by: string
|
changed_by: string
|
||||||
created_at?: string
|
created_at?: string
|
||||||
id?: number
|
id?: number
|
||||||
|
extra_data?: Json | null
|
||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
account_id?: string
|
account_id?: string
|
||||||
@@ -213,6 +215,7 @@ export type Database = {
|
|||||||
changed_by?: string
|
changed_by?: string
|
||||||
created_at?: string
|
created_at?: string
|
||||||
id?: number
|
id?: number
|
||||||
|
extra_data?: Json | null
|
||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: []
|
||||||
}
|
}
|
||||||
@@ -1088,7 +1091,7 @@ export type Database = {
|
|||||||
price: number
|
price: number
|
||||||
price_periods: string | null
|
price_periods: string | null
|
||||||
requires_payment: boolean
|
requires_payment: boolean
|
||||||
sync_id: number
|
sync_id: string | null
|
||||||
updated_at: string | null
|
updated_at: string | null
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
@@ -1107,7 +1110,7 @@ export type Database = {
|
|||||||
price: number
|
price: number
|
||||||
price_periods?: string | null
|
price_periods?: string | null
|
||||||
requires_payment: boolean
|
requires_payment: boolean
|
||||||
sync_id: number
|
sync_id?: string | null
|
||||||
updated_at?: string | null
|
updated_at?: string | null
|
||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
@@ -1126,7 +1129,7 @@ export type Database = {
|
|||||||
price?: number
|
price?: number
|
||||||
price_periods?: string | null
|
price_periods?: string | null
|
||||||
requires_payment?: boolean
|
requires_payment?: boolean
|
||||||
sync_id?: number
|
sync_id?: string | null
|
||||||
updated_at?: string | null
|
updated_at?: string | null
|
||||||
}
|
}
|
||||||
Relationships: [
|
Relationships: [
|
||||||
@@ -1147,7 +1150,7 @@ export type Database = {
|
|||||||
doctor_user_id: string | null
|
doctor_user_id: string | null
|
||||||
id: number
|
id: number
|
||||||
status: Database["medreport"]["Enums"]["analysis_feedback_status"]
|
status: Database["medreport"]["Enums"]["analysis_feedback_status"]
|
||||||
updated_at: string
|
updated_at: string | null
|
||||||
updated_by: string | null
|
updated_by: string | null
|
||||||
user_id: string
|
user_id: string
|
||||||
value: string | null
|
value: string | null
|
||||||
@@ -1159,7 +1162,7 @@ export type Database = {
|
|||||||
doctor_user_id?: string | null
|
doctor_user_id?: string | null
|
||||||
id?: number
|
id?: number
|
||||||
status?: Database["medreport"]["Enums"]["analysis_feedback_status"]
|
status?: Database["medreport"]["Enums"]["analysis_feedback_status"]
|
||||||
updated_at?: string
|
updated_at?: string | null
|
||||||
updated_by?: string | null
|
updated_by?: string | null
|
||||||
user_id: string
|
user_id: string
|
||||||
value?: string | null
|
value?: string | null
|
||||||
@@ -1171,7 +1174,7 @@ export type Database = {
|
|||||||
doctor_user_id?: string | null
|
doctor_user_id?: string | null
|
||||||
id?: number
|
id?: number
|
||||||
status?: Database["medreport"]["Enums"]["analysis_feedback_status"]
|
status?: Database["medreport"]["Enums"]["analysis_feedback_status"]
|
||||||
updated_at?: string
|
updated_at?: string | null
|
||||||
updated_by?: string | null
|
updated_by?: string | null
|
||||||
user_id?: string
|
user_id?: string
|
||||||
value?: string | null
|
value?: string | null
|
||||||
@@ -1254,6 +1257,34 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
medipost_actions: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
action: string
|
||||||
|
xml: string
|
||||||
|
has_analysis_results: boolean
|
||||||
|
created_at: string
|
||||||
|
medusa_order_id: string
|
||||||
|
response_xml: string
|
||||||
|
has_error: boolean
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
action: string
|
||||||
|
xml: string
|
||||||
|
has_analysis_results: boolean
|
||||||
|
medusa_order_id: string
|
||||||
|
response_xml: string
|
||||||
|
has_error: boolean
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
action?: string
|
||||||
|
xml?: string
|
||||||
|
has_analysis_results?: boolean
|
||||||
|
medusa_order_id?: string
|
||||||
|
response_xml?: string
|
||||||
|
has_error?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
medreport_product_groups: {
|
medreport_product_groups: {
|
||||||
Row: {
|
Row: {
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -1850,9 +1881,7 @@ export type Database = {
|
|||||||
Returns: Json
|
Returns: Json
|
||||||
}
|
}
|
||||||
create_team_account: {
|
create_team_account: {
|
||||||
Args:
|
Args: { account_name: string; new_personal_code: string }
|
||||||
| { account_name: string }
|
|
||||||
| { account_name: string; new_personal_code: string }
|
|
||||||
Returns: {
|
Returns: {
|
||||||
application_role: Database["medreport"]["Enums"]["application_role"]
|
application_role: Database["medreport"]["Enums"]["application_role"]
|
||||||
city: string | null
|
city: string | null
|
||||||
@@ -1921,6 +1950,15 @@ export type Database = {
|
|||||||
account_id: string
|
account_id: string
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
get_latest_medipost_dispatch_state_for_order: {
|
||||||
|
Args: {
|
||||||
|
medusa_order_id: string
|
||||||
|
}
|
||||||
|
Returns: {
|
||||||
|
has_success: boolean
|
||||||
|
action_date: string
|
||||||
|
}
|
||||||
|
}
|
||||||
get_medipost_dispatch_tries: {
|
get_medipost_dispatch_tries: {
|
||||||
Args: { p_medusa_order_id: string }
|
Args: { p_medusa_order_id: string }
|
||||||
Returns: number
|
Returns: number
|
||||||
@@ -2137,6 +2175,21 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Returns: Json
|
Returns: Json
|
||||||
}
|
}
|
||||||
|
sync_analysis_results: {
|
||||||
|
}
|
||||||
|
send_medipost_test_response_for_order: {
|
||||||
|
Args: {
|
||||||
|
medusa_order_id: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order_has_medipost_dispatch_error: {
|
||||||
|
Args: {
|
||||||
|
medusa_order_id: string
|
||||||
|
}
|
||||||
|
Returns: {
|
||||||
|
success: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Enums: {
|
Enums: {
|
||||||
analysis_feedback_status: "STARTED" | "DRAFT" | "COMPLETED"
|
analysis_feedback_status: "STARTED" | "DRAFT" | "COMPLETED"
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function LanguageSelector({
|
|||||||
}, [currentLanguage]);
|
}, [currentLanguage]);
|
||||||
|
|
||||||
const userId = user?.id;
|
const userId = user?.id;
|
||||||
const updateAccountMutation = useUpdateAccountData(userId!);
|
const updateAccountMutation = useUpdateAccountData(userId);
|
||||||
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
|
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
|
||||||
|
|
||||||
const updateLanguagePreference = async (
|
const updateLanguagePreference = async (
|
||||||
@@ -52,6 +52,10 @@ export function LanguageSelector({
|
|||||||
onChange(locale);
|
onChange(locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return i18n.changeLanguage(locale);
|
||||||
|
}
|
||||||
|
|
||||||
const promise = updateAccountMutation
|
const promise = updateAccountMutation
|
||||||
.mutateAsync({
|
.mutateAsync({
|
||||||
preferred_locale: locale,
|
preferred_locale: locale,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { cn } from '../../lib/utils';
|
|
||||||
import { LanguageSelector } from '@kit/ui/language-selector';
|
import { LanguageSelector } from '@kit/ui/language-selector';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
logo?: React.ReactNode;
|
logo?: React.ReactNode;
|
||||||
navigation?: React.ReactNode;
|
navigation?: React.ReactNode;
|
||||||
@@ -22,11 +24,16 @@ export const Header: React.FC<HeaderProps> = function ({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="grid h-14 grid-cols-3 items-center">
|
<div className="2xs:h-14 2xs:grid-cols-3 grid h-24 w-full items-center">
|
||||||
<div className={'mx-auto md:mx-0'}>{logo}</div>
|
<div className={'mx-auto flex'}>{logo}</div>
|
||||||
<div className="order-first md:order-none">{navigation}</div>
|
<div className="2xs:order-none order-first">{navigation}</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-x-2"><div className="max-w-[100px]"><LanguageSelector /></div>{actions}</div>
|
<div className="2xs:justify-end 2xs:gap-x-2 flex items-center justify-evenly">
|
||||||
|
<div className="max-w-[100px]">
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function PageWithSidebar(props: PageProps) {
|
|||||||
>
|
>
|
||||||
{MobileNavigation}
|
{MobileNavigation}
|
||||||
|
|
||||||
<div className={'bg-background flex flex-1 flex-col px-4 lg:px-0 pb-8'}>
|
<div className={'bg-background flex flex-1 flex-col px-4 pb-8 lg:px-0'}>
|
||||||
{Children}
|
{Children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +58,7 @@ export function PageMobileNavigation(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center border-b px-4 py-2 lg:hidden lg:px-0',
|
'flex w-full items-center px-4 py-2 lg:hidden lg:px-0',
|
||||||
props.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -128,5 +128,6 @@
|
|||||||
"updateRoleLoading": "Updating role...",
|
"updateRoleLoading": "Updating role...",
|
||||||
"updatePreferredLocaleSuccess": "Language preference updated",
|
"updatePreferredLocaleSuccess": "Language preference updated",
|
||||||
"updatePreferredLocaleError": "Language preference update failed",
|
"updatePreferredLocaleError": "Language preference update failed",
|
||||||
"updatePreferredLocaleLoading": "Updating language preference..."
|
"updatePreferredLocaleLoading": "Updating language preference...",
|
||||||
|
"doctorAnalysisSummary": "Doctor's summary"
|
||||||
}
|
}
|
||||||
@@ -12,5 +12,6 @@
|
|||||||
"normal": "Normal range"
|
"normal": "Normal range"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"orderTitle": "Order number {{orderNumber}}"
|
"orderTitle": "Order number {{orderNumber}}",
|
||||||
|
"view": "View results"
|
||||||
}
|
}
|
||||||
@@ -116,5 +116,6 @@
|
|||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"invalidDataError": "Invalid data submitted"
|
"invalidDataError": "Invalid data submitted",
|
||||||
|
"language": "Language"
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"assignedTo": "Doctor",
|
"assignedTo": "Doctor",
|
||||||
"resultsStatus": "Analysis results",
|
"resultsStatus": "Analysis results",
|
||||||
"waitingForNr": "Waiting for {{nr}}",
|
"waitingForNr": "Waiting for {{nr}}",
|
||||||
|
"language": "Preferred language",
|
||||||
"responsesReceived": "Results complete"
|
"responsesReceived": "Results complete"
|
||||||
},
|
},
|
||||||
"otherPatients": "Other patients",
|
"otherPatients": "Other patients",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"title": "Select analysis package",
|
"title": "Select analysis package",
|
||||||
"noPackagesAvailable": "No packages available",
|
"noPackagesAvailable": "No packages available",
|
||||||
"selectThisPackage": "Select this package",
|
"selectThisPackage": "Select this package",
|
||||||
"selectPackage": "Select package",
|
"selectPackage": "Select package",
|
||||||
"comparePackages": "Compare packages"
|
"comparePackages": "Compare packages",
|
||||||
|
"analysisPackageAddedToCart": "Analysis package added to cart",
|
||||||
|
"analysisPackageAddToCartError": "Adding analysis package to cart failed"
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "Select analysis",
|
"title": "Select analysis",
|
||||||
"description": "Select the analysis that suits your needs",
|
"description": "All analysis results will appear within 1-3 days after the blood test.",
|
||||||
"analysisNotAvailable": "Analysis is not available currently"
|
"analysisNotAvailable": "Analysis is not available currently",
|
||||||
|
"analysisAddedToCart": "Analysis added to cart",
|
||||||
|
"analysisAddToCartError": "Adding analysis to cart failed"
|
||||||
}
|
}
|
||||||
@@ -151,5 +151,6 @@
|
|||||||
"updateRoleLoading": "Rolli uuendatakse...",
|
"updateRoleLoading": "Rolli uuendatakse...",
|
||||||
"updatePreferredLocaleSuccess": "Eelistatud keel uuendatud",
|
"updatePreferredLocaleSuccess": "Eelistatud keel uuendatud",
|
||||||
"updatePreferredLocaleError": "Eelistatud keele uuendamine ei õnnestunud",
|
"updatePreferredLocaleError": "Eelistatud keele uuendamine ei õnnestunud",
|
||||||
"updatePreferredLocaleLoading": "Eelistatud keelt uuendatakse..."
|
"updatePreferredLocaleLoading": "Eelistatud keelt uuendatakse...",
|
||||||
|
"doctorAnalysisSummary": "Arsti kokkuvõte analüüsitulemuste kohta"
|
||||||
}
|
}
|
||||||
@@ -12,5 +12,6 @@
|
|||||||
"normal": "Normaalne vahemik"
|
"normal": "Normaalne vahemik"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"orderTitle": "Tellimus {{orderNumber}}"
|
"orderTitle": "Tellimus {{orderNumber}}",
|
||||||
|
"view": "Vaata tulemusi"
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user