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"
|
||||
|
||||
# AUTH
|
||||
NEXT_PUBLIC_AUTH_PASSWORD=true
|
||||
NEXT_PUBLIC_AUTH_PASSWORD=false
|
||||
NEXT_PUBLIC_AUTH_MAGIC_LINK=false
|
||||
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
|
||||
|
||||
|
||||
@@ -36,3 +36,57 @@ MONTONIO_API_URL=https://sandbox-stargate.montonio.com
|
||||
|
||||
# JOBS
|
||||
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
|
||||
@@ -19,4 +19,6 @@ EMAIL_PASSWORD=password
|
||||
# STRIPE
|
||||
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
|
||||
|
||||
dump.sql
|
||||
data.sql
|
||||
11
Dockerfile
11
Dockerfile
@@ -11,6 +11,7 @@ COPY packages packages
|
||||
COPY tooling tooling
|
||||
COPY .env .env
|
||||
COPY .env.production .env.production
|
||||
COPY .env.staging .env.staging
|
||||
|
||||
# Load env file and echo a specific variable
|
||||
# RUN dotenv -e .env -- printenv | grep 'SUPABASE' || true
|
||||
@@ -20,13 +21,10 @@ COPY . .
|
||||
|
||||
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 \
|
||||
&& . .env \
|
||||
&& . .env.production \
|
||||
&& . .env.staging \
|
||||
&& set +a \
|
||||
&& node check-env.js \
|
||||
&& pnpm build
|
||||
@@ -34,18 +32,21 @@ RUN set -a \
|
||||
|
||||
# --- Stage 2: Runtime ---
|
||||
FROM node:20-alpine
|
||||
ARG APP_ENV=production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app ./
|
||||
|
||||
RUN cp ".env.${APP_ENV}" .env.local
|
||||
|
||||
RUN npm install -g pnpm@9 \
|
||||
&& pnpm install --prod --frozen-lockfile
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# 🔍 Optional: Log key envs for debug
|
||||
RUN echo "📄 .env.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
|
||||
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ function AuthButtons() {
|
||||
</div>
|
||||
|
||||
<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}>
|
||||
<Trans i18nKey={'auth:signIn'} />
|
||||
</Link>
|
||||
|
||||
@@ -4,14 +4,15 @@ import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { SubmitButton } from '@kit/shared/components/ui/submit-button';
|
||||
import { sendCompanyOfferEmail } from '@/lib/services/mailer.service';
|
||||
import { sendEmailFromTemplate } from '@/lib/services/mailer.service';
|
||||
import { CompanySubmitData } from '@/lib/types/company';
|
||||
import { companyOfferSchema } from '@/lib/validations/company-offer.schema';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
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 { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
@@ -39,7 +40,14 @@ const CompanyOfferForm = () => {
|
||||
});
|
||||
|
||||
try {
|
||||
sendCompanyOfferEmail(data, language)
|
||||
sendEmailFromTemplate(
|
||||
renderCompanyOfferEmail,
|
||||
{
|
||||
companyData: data,
|
||||
language,
|
||||
},
|
||||
process.env.CONTACT_EMAIL!,
|
||||
)
|
||||
.then(() => router.push('/company-offer/success'))
|
||||
.catch((error) => {
|
||||
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');
|
||||
await createSyncSuccessEntry();
|
||||
} catch (e) {
|
||||
await createSyncFailEntry(JSON.stringify(e));
|
||||
console.error(e);
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
await createSyncFailEntry(JSON.stringify({
|
||||
message: errorMessage,
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
name: e instanceof Error ? e.name : 'Unknown',
|
||||
}, null, 2));
|
||||
console.error('Sync failed:', e);
|
||||
throw new Error(
|
||||
`Failed to sync public message data, error: ${JSON.stringify(e)}`,
|
||||
`Failed to sync public message data, error: ${errorMessage}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,19 +71,19 @@ export default async function syncConnectedOnline() {
|
||||
return {
|
||||
id: service.ID,
|
||||
clinic_id: service.ClinicID,
|
||||
code: service.Code,
|
||||
description: service.Description || null,
|
||||
display: service.Display,
|
||||
duration: service.Duration,
|
||||
has_free_codes: !!service.HasFreeCodes,
|
||||
sync_id: service.SyncID,
|
||||
name: service.Name,
|
||||
description: service.Description || null,
|
||||
price: service.Price,
|
||||
requires_payment: !!service.RequiresPayment,
|
||||
duration: service.Duration,
|
||||
neto_duration: service.NetoDuration,
|
||||
display: service.Display,
|
||||
price_periods: service.PricePeriods || null,
|
||||
online_hide_duration: service.OnlineHideDuration,
|
||||
online_hide_price: service.OnlineHidePrice,
|
||||
price: service.Price,
|
||||
price_periods: service.PricePeriods || null,
|
||||
requires_payment: !!service.RequiresPayment,
|
||||
sync_id: service.SyncID,
|
||||
code: service.Code,
|
||||
has_free_codes: !!service.HasFreeCodes,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
console.info("SEND XML", messageXml);
|
||||
|
||||
try {
|
||||
await sendPrivateMessageTestResponse({ messageXml });
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getOrder } from "~/lib/services/order.service";
|
||||
import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service";
|
||||
import { retrieveOrder } from "@lib/data";
|
||||
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) {
|
||||
// const isDev = process.env.NODE_ENV === 'development';
|
||||
@@ -35,6 +35,11 @@ export async function POST(request: Request) {
|
||||
});
|
||||
|
||||
try {
|
||||
await createMedipostActionLog({
|
||||
action: 'send_fake_analysis_results_to_medipost',
|
||||
xml: messageXml,
|
||||
medusaOrderId,
|
||||
});
|
||||
await sendPrivateMessageTestResponse({ messageXml });
|
||||
} catch (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 { useQueryClient } from '@tanstack/react-query';
|
||||
import { capitalize } from 'lodash';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions';
|
||||
@@ -22,6 +23,9 @@ import {
|
||||
doctorAnalysisFeedbackFormSchema,
|
||||
} from '@kit/doctor/schema/doctor-analysis.schema';
|
||||
import ConfirmationModal from '@kit/shared/components/confirmation-modal';
|
||||
import {
|
||||
useCurrentLocaleLanguageNames
|
||||
} from '@kit/shared/hooks';
|
||||
import { getFullName } from '@kit/shared/utils';
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -57,6 +61,8 @@ export default function AnalysisView({
|
||||
|
||||
const { data: user } = useUser();
|
||||
|
||||
const languageNames = useCurrentLocaleLanguageNames();
|
||||
|
||||
const isInProgress = !!(
|
||||
!!feedback?.status &&
|
||||
feedback?.doctor_user_id &&
|
||||
@@ -191,6 +197,12 @@ export default function AnalysisView({
|
||||
<Trans i18nKey="doctor:email" />
|
||||
</div>
|
||||
<div>{patient.email}</div>
|
||||
<div className="font-bold">
|
||||
<Trans i18nKey="common:language" />
|
||||
</div>
|
||||
<div>
|
||||
{capitalize(languageNames.of(patient.preferred_locale ?? 'et'))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="xs:hidden block">
|
||||
<DoctorJobSelect
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Eye } from 'lucide-react';
|
||||
import { getResultSetName } from '@kit/doctor/lib/helpers';
|
||||
import { ResponseTable } from '@kit/doctor/schema/doctor-analysis.schema';
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
import { useCurrentLocaleLanguageNames } from '@kit/shared/hooks';
|
||||
import { getFullName } from '@kit/shared/utils';
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -23,7 +24,9 @@ import {
|
||||
TableRow,
|
||||
} from '@kit/ui/table';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import DoctorJobSelect from './doctor-job-select';
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
export default function ResultsTable({
|
||||
results = [],
|
||||
@@ -58,6 +61,8 @@ export default function ResultsTable({
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
const languageNames = useCurrentLocaleLanguageNames();
|
||||
|
||||
const fetchPage = async (page: number) => {
|
||||
startTransition(async () => {
|
||||
const result = await fetchAction({
|
||||
@@ -116,6 +121,9 @@ export default function ResultsTable({
|
||||
<TableHead className="w-20">
|
||||
<Trans i18nKey="doctor:resultsTable.resultsStatus" />
|
||||
</TableHead>
|
||||
<TableHead className="w-20">
|
||||
<Trans i18nKey="doctor:resultsTable.language" />
|
||||
</TableHead>
|
||||
<TableHead className="w-20">
|
||||
<Trans i18nKey="doctor:resultsTable.assignedTo" />
|
||||
</TableHead>
|
||||
@@ -179,6 +187,11 @@ export default function ResultsTable({
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{capitalize(
|
||||
languageNames.of(result?.patient?.preferred_locale ?? 'et'),
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DoctorJobSelect
|
||||
doctorUserId={result.doctor?.primary_owner_user_id}
|
||||
|
||||
@@ -18,8 +18,8 @@ async function AnalysisPage({
|
||||
id: string;
|
||||
}>;
|
||||
}) {
|
||||
const { id: analysisResponseId } = await params;
|
||||
const analysisResultDetails = await loadResult(Number(analysisResponseId));
|
||||
const { id: analysisOrderId } = await params;
|
||||
const analysisResultDetails = await loadResult(Number(analysisOrderId));
|
||||
|
||||
if (!analysisResultDetails) {
|
||||
return null;
|
||||
@@ -28,7 +28,7 @@ async function AnalysisPage({
|
||||
if (analysisResultDetails) {
|
||||
await createDoctorPageViewLog({
|
||||
action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS,
|
||||
recordKey: analysisResponseId,
|
||||
recordKey: analysisOrderId,
|
||||
dataOwnerUserId: analysisResultDetails.patient.userId,
|
||||
});
|
||||
}
|
||||
@@ -50,3 +50,5 @@ async function AnalysisPage({
|
||||
|
||||
export default DoctorGuard(AnalysisPage);
|
||||
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 { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
|
||||
import { HomeLayoutPageHeader } from '../../_components/home-page-header';
|
||||
import OrderCards from '../../_components/order-cards';
|
||||
import ServiceCategories from '../../_components/service-categories';
|
||||
import { loadTtoServices } from '../../_lib/server/load-tto-services';
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
const i18n = await createI18nServerInstance();
|
||||
@@ -18,15 +22,30 @@ export const generateMetadata = async () => {
|
||||
};
|
||||
|
||||
function BookingPage() {
|
||||
const { heroCategories, ttoCategories } = use(loadTtoServices());
|
||||
|
||||
if (!heroCategories.length && !ttoCategories.length) {
|
||||
return (
|
||||
<>
|
||||
<AppBreadcrumbs />
|
||||
<h3 className="mt-8">
|
||||
<Trans i18nKey="booking:noCategories" />
|
||||
</h3>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBreadcrumbs />
|
||||
<HomeLayoutPageHeader
|
||||
title={<Trans i18nKey={'booking:title'} />}
|
||||
description={<Trans i18nKey={'booking:description'} />}
|
||||
/>
|
||||
|
||||
<PageBody>
|
||||
<OrderCards />
|
||||
<PageBody className="space-y-2">
|
||||
<OrderCards heroCategories={heroCategories} />
|
||||
<ServiceCategories categories={ttoCategories} />
|
||||
</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 { Button } from '@kit/ui/button';
|
||||
|
||||
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() {
|
||||
return (
|
||||
<div className='flex gap-4 lg:px-4'>
|
||||
<div className="flex gap-4 lg:px-4">
|
||||
<Card
|
||||
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">
|
||||
<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" />
|
||||
</div>
|
||||
<div className='ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-warning'>
|
||||
<Link href='/home/order-analysis'>
|
||||
<div className="bg-warning ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white">
|
||||
<Link href="/home/order-analysis">
|
||||
<Button size="icon" variant="outline" className="px-2 text-black">
|
||||
<ChevronRight className="size-4 stroke-2" />
|
||||
</Button>
|
||||
@@ -33,10 +31,10 @@ export default function DashboardCards() {
|
||||
</CardHeader>
|
||||
<CardFooter className="flex flex-col items-start gap-2">
|
||||
<h5>
|
||||
<Trans i18nKey='dashboard:heroCard.orderAnalysis.title' />
|
||||
<Trans i18nKey="dashboard:heroCard.orderAnalysis.title" />
|
||||
</h5>
|
||||
<CardDescription className="text-primary">
|
||||
<Trans i18nKey='dashboard:heroCard.orderAnalysis.description' />
|
||||
<Trans i18nKey="dashboard:heroCard.orderAnalysis.description" />
|
||||
</CardDescription>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function Dashboard({
|
||||
|
||||
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({
|
||||
gender: params?.gender,
|
||||
age: params?.age,
|
||||
@@ -233,8 +233,11 @@ export default function Dashboard({
|
||||
index,
|
||||
) => {
|
||||
return (
|
||||
<div className="flex justify-between" key={index}>
|
||||
<div className="mr-4 flex flex-row items-center gap-4">
|
||||
<div
|
||||
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
|
||||
className={cn(
|
||||
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
|
||||
@@ -243,7 +246,7 @@ export default function Dashboard({
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="min-w-fit">
|
||||
<div className="inline-flex items-center gap-1 align-baseline text-sm font-medium">
|
||||
{title}
|
||||
<InfoTooltip content={tooltipContent} />
|
||||
@@ -253,16 +256,24 @@ export default function Dashboard({
|
||||
</p>
|
||||
</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>
|
||||
{href ? (
|
||||
<Link href={href}>
|
||||
<Button size="sm" variant="secondary">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="w-full min-w-fit"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="w-full min-w-fit"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -31,8 +31,8 @@ export async function HomeMenuNavigation(props: {
|
||||
})
|
||||
: 0;
|
||||
|
||||
const cartItemsCount = props.cart?.items?.length ?? 0;
|
||||
const hasCartItems = cartItemsCount > 0;
|
||||
const cartQuantityTotal = props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 0;
|
||||
const hasCartItems = cartQuantityTotal > 0;
|
||||
|
||||
return (
|
||||
<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]" />
|
||||
<Trans
|
||||
i18nKey="common:shoppingCartCount"
|
||||
values={{ count: cartItemsCount }}
|
||||
values={{ count: cartQuantityTotal }}
|
||||
/>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
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 {
|
||||
featureFlagsConfig,
|
||||
pathsConfig,
|
||||
personalAccountNavigationConfig,
|
||||
} from '@kit/shared/config';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
@@ -15,7 +19,6 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
@@ -23,14 +26,16 @@ import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
// home imports
|
||||
import { HomeAccountSelector } from '../_components/home-account-selector';
|
||||
import type { UserWorkspace } from '../_lib/server/load-user-workspace';
|
||||
|
||||
export function HomeMobileNavigation(props: {
|
||||
workspace: UserWorkspace;
|
||||
cart: StoreCart | null;
|
||||
}) {
|
||||
const user = props.workspace.user;
|
||||
|
||||
const signOut = useSignOut();
|
||||
const { data: personalAccountData } = usePersonalAccountData(user.id);
|
||||
|
||||
const Links = personalAccountNavigationConfig.routes.map((item, index) => {
|
||||
if ('children' in item) {
|
||||
@@ -51,8 +56,30 @@ export function HomeMobileNavigation(props: {
|
||||
}
|
||||
});
|
||||
|
||||
const cartItemsCount = props.cart?.items?.length ?? 0;
|
||||
const hasCartItems = cartItemsCount > 0;
|
||||
const hasTotpFactor = useMemo(() => {
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
@@ -61,29 +88,13 @@ export function HomeMobileNavigation(props: {
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<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}>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownLink
|
||||
path="/home/cart"
|
||||
label="common:shoppingCartCount"
|
||||
Icon={<ShoppingCart className="stroke-[1.5px]" />}
|
||||
labelOptions={{ count: cartItemsCount }}
|
||||
labelOptions={{ count: cartQuantityTotal }}
|
||||
/>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -91,6 +102,41 @@ export function HomeMobileNavigation(props: {
|
||||
|
||||
<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 />
|
||||
|
||||
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { HeartPulse, Loader2, ShoppingCart } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
@@ -12,15 +13,17 @@ import {
|
||||
import { StoreProduct } from '@medusajs/types';
|
||||
import { useState } from 'react';
|
||||
import { handleAddToCart } from '~/lib/services/medusaCart.service';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { formatCurrency } from '@/packages/shared/src/utils';
|
||||
|
||||
export type OrderAnalysisCard = Pick<
|
||||
StoreProduct, 'title' | 'description' | 'subtitle'
|
||||
> & {
|
||||
isAvailable: boolean;
|
||||
variant: { id: string };
|
||||
price: number | null;
|
||||
};
|
||||
|
||||
export default function OrderAnalysesCards({
|
||||
@@ -30,37 +33,47 @@ export default function OrderAnalysesCards({
|
||||
analyses: OrderAnalysisCard[];
|
||||
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) => {
|
||||
if (isAddingToCart) {
|
||||
if (variantAddingToCart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsAddingToCart(true);
|
||||
setVariantAddingToCart(variantId);
|
||||
try {
|
||||
await handleAddToCart({
|
||||
selectedVariant: { id: variantId },
|
||||
countryCode,
|
||||
});
|
||||
setIsAddingToCart(false);
|
||||
router.push('/home/cart');
|
||||
toast.success(<Trans i18nKey={'order-analysis:analysisAddedToCart'} />);
|
||||
setVariantAddingToCart(null);
|
||||
} catch (e) {
|
||||
setIsAddingToCart(false);
|
||||
toast.error(<Trans i18nKey={'order-analysis:analysisAddToCartError'} />);
|
||||
setVariantAddingToCart(null);
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-6 mt-4">
|
||||
<div className="grid 2xs:grid-cols-3 gap-6 mt-4">
|
||||
{analyses.map(({
|
||||
title,
|
||||
variant,
|
||||
description,
|
||||
subtitle,
|
||||
isAvailable,
|
||||
price,
|
||||
}) => {
|
||||
const formattedPrice = typeof price === 'number'
|
||||
? formatCurrency({
|
||||
currencyCode: 'eur',
|
||||
locale: language,
|
||||
value: price,
|
||||
})
|
||||
: null;
|
||||
return (
|
||||
<Card
|
||||
key={title}
|
||||
@@ -81,7 +94,7 @@ export default function OrderAnalysesCards({
|
||||
className="px-2 text-black"
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
@@ -92,7 +105,14 @@ export default function OrderAnalysesCards({
|
||||
{description && (
|
||||
<>
|
||||
{' '}
|
||||
<InfoTooltip content={`${description}`} />
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span>{formattedPrice}</span>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</h5>
|
||||
|
||||
@@ -1,74 +1,68 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { ChevronRight, HeartPulse } from 'lucide-react';
|
||||
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 {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardDescription,
|
||||
CardProps,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardProps,
|
||||
} from '@kit/ui/card';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const dummyCards = [
|
||||
{
|
||||
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',
|
||||
},
|
||||
];
|
||||
import { ServiceCategory } from './service-categories';
|
||||
|
||||
export default function OrderCards() {
|
||||
export default function OrderCards({
|
||||
heroCategories,
|
||||
}: {
|
||||
heroCategories: ServiceCategory[];
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-6 mt-4">
|
||||
{dummyCards.map(({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
cardVariant,
|
||||
descriptionColor,
|
||||
iconBg,
|
||||
}) => (
|
||||
<div className="xs:grid-cols-3 mt-4 grid grid-cols-1 gap-2">
|
||||
{heroCategories.map(({ name, description, color, handle }) => (
|
||||
<Card
|
||||
key={title}
|
||||
variant={cardVariant}
|
||||
key={name}
|
||||
variant={`gradient-${color}` as CardProps['variant']}
|
||||
className="flex flex-col justify-between"
|
||||
>
|
||||
<CardHeader className="items-end-safe">
|
||||
<CardHeader className="relative flex flex-row justify-between">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
|
||||
iconBg,
|
||||
'flex size-8 items-center-safe justify-center-safe rounded-full',
|
||||
`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>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex flex-col items-start gap-2">
|
||||
<div
|
||||
className={'flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-primary\/10 mb-6'}
|
||||
>
|
||||
<HeartPulse className="size-4 fill-green-500" />
|
||||
</div>
|
||||
<h5>
|
||||
<Trans i18nKey={title} />
|
||||
</h5>
|
||||
<CardDescription className={descriptionColor}>
|
||||
<Trans i18nKey={description} />
|
||||
</CardDescription>
|
||||
<CardFooter className="mt-5 flex flex-col items-start gap-2">
|
||||
<h5>{name}</h5>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardFooter>
|
||||
</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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@kit/ui/table';
|
||||
import { StoreOrderLineItem } from "@medusajs/types";
|
||||
import { AnalysisOrder } from '~/lib/services/order.service';
|
||||
import { formatDate } from 'date-fns';
|
||||
import Link from 'next/link';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
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[];
|
||||
title: string;
|
||||
analysisOrder: AnalysisOrder;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const openAnalysisResults = async () => {
|
||||
await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id);
|
||||
router.push(`${pathsConfig.app.analysisResults}/${analysisOrder.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Table className="rounded-lg border border-separate">
|
||||
<Table className="border-separate rounded-lg border">
|
||||
<TableHeader className="text-ui-fg-subtle txt-medium-plus">
|
||||
<TableRow>
|
||||
<TableHead className="px-6">
|
||||
@@ -35,13 +55,14 @@ export default function OrderItemsTable({ items, title, analysisOrder }: {
|
||||
<TableHead className="px-6">
|
||||
<Trans i18nKey="orders:table.status" />
|
||||
</TableHead>
|
||||
<TableHead className="px-6">
|
||||
</TableHead>
|
||||
<TableHead className="px-6"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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) => (
|
||||
<TableRow className="w-full" key={orderItem.id}>
|
||||
<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')}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-6 min-w-[180px]">
|
||||
<TableCell className="min-w-[180px] px-6">
|
||||
<Trans i18nKey={`orders:status.${analysisOrder.status}`} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right px-6">
|
||||
<span className="flex gap-x-1 justify-end w-[30px]">
|
||||
<Link href={`/home/analysis-results`} className="flex items-center justify-between text-small-regular">
|
||||
<button
|
||||
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer"
|
||||
>
|
||||
<Eye />
|
||||
</button>
|
||||
</Link>
|
||||
</span>
|
||||
<TableCell className="px-6 text-right">
|
||||
<Button size="sm" onClick={openAnalysisResults}>
|
||||
<Trans i18nKey="analysis-results:view" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</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 { listProductTypes } from "@lib/data/products";
|
||||
import { listRegions } from '@lib/data/regions';
|
||||
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';
|
||||
|
||||
async function countryCodesLoader() {
|
||||
@@ -14,7 +15,9 @@ async function countryCodesLoader() {
|
||||
export const loadCountryCodes = cache(countryCodesLoader);
|
||||
|
||||
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 ?? [];
|
||||
}
|
||||
export const loadProductCategories = cache(productCategoriesLoader);
|
||||
@@ -29,25 +32,42 @@ async function analysesLoader() {
|
||||
const [countryCodes, productCategories] = await Promise.all([
|
||||
loadCountryCodes(),
|
||||
loadProductCategories(),
|
||||
]);
|
||||
]);
|
||||
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 {
|
||||
analyses: category?.products?.map<OrderAnalysisCard>(({ title, description, subtitle, variants, status, metadata }) => {
|
||||
const variant = variants![0]!;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
subtitle,
|
||||
variant: {
|
||||
id: variant.id,
|
||||
analyses:
|
||||
categoryProducts?.response.products.map<OrderAnalysisCard>(
|
||||
({ title, description, subtitle, variants, status, metadata }) => {
|
||||
const variant = variants![0]!;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
subtitle,
|
||||
variant: {
|
||||
id: variant.id,
|
||||
},
|
||||
isAvailable:
|
||||
status === 'published' && !!metadata?.analysisIdOriginal,
|
||||
price: variant.calculated_price?.calculated_amount ?? null,
|
||||
};
|
||||
},
|
||||
isAvailable: status === 'published' && !!metadata?.analysisIdOriginal,
|
||||
};
|
||||
}) ?? [],
|
||||
) ?? [],
|
||||
countryCode,
|
||||
}
|
||||
};
|
||||
}
|
||||
export const loadAnalyses = cache(analysesLoader);
|
||||
|
||||
@@ -6,8 +6,8 @@ import { listRegions } from '@lib/data/regions';
|
||||
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
|
||||
import type { StoreProduct } from '@medusajs/types';
|
||||
import { loadCurrentUserAccount } from './load-user-account';
|
||||
import { AnalysisPackageWithVariant } from '~/components/select-analysis-package';
|
||||
import { AccountWithParams } from '@/packages/features/accounts/src/server/api';
|
||||
import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
|
||||
|
||||
async function countryCodesLoader() {
|
||||
const countryCodes = await listRegions().then((regions) =>
|
||||
@@ -38,8 +38,11 @@ function userSpecificVariantLoader({
|
||||
if (age >= 18 && age <= 29) {
|
||||
return '18-29';
|
||||
}
|
||||
if (age >= 30 && age <= 49) {
|
||||
return '30-49';
|
||||
if (age >= 30 && age <= 39) {
|
||||
return '30-39';
|
||||
}
|
||||
if (age >= 40 && age <= 49) {
|
||||
return '40-49';
|
||||
}
|
||||
if (age >= 50 && age <= 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 { 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';
|
||||
|
||||
export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>;
|
||||
@@ -9,14 +9,15 @@ export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>;
|
||||
/**
|
||||
* @name loadUserAnalysis
|
||||
* @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.
|
||||
* Load the user's analysis based on id. It's a cached per-request function that fetches the user's analysis data.
|
||||
*/
|
||||
export const loadUserAnalysis = cache(analysisLoader);
|
||||
|
||||
async function analysisLoader(): Promise<UserAnalysis | null> {
|
||||
async function analysisLoader(
|
||||
analysisOrderId: number,
|
||||
): Promise<AnalysisResultDetails | null> {
|
||||
const client = getSupabaseServerClient();
|
||||
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;
|
||||
}
|
||||
|
||||
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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
export enum NotificationAction {
|
||||
DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED',
|
||||
NEW_JOBS_ALERT = 'NEW_JOBS_ALERT',
|
||||
PATIENT_RESULTS_RECEIVED_ALERT = 'PATIENT_RESULTS_RECEIVED_ALERT',
|
||||
}
|
||||
|
||||
export const createNotificationLog = async ({
|
||||
@@ -17,7 +19,7 @@ export const createNotificationLog = async ({
|
||||
relatedRecordId?: string | number;
|
||||
}) => {
|
||||
try {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const supabase = getSupabaseServerAdminClient();
|
||||
|
||||
await supabase
|
||||
.schema('audit')
|
||||
@@ -30,6 +32,6 @@ export const createNotificationLog = async ({
|
||||
})
|
||||
.throwOnError();
|
||||
} 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 {
|
||||
VIEW_ANALYSIS_RESULTS = 'VIEW_ANALYSIS_RESULTS',
|
||||
VIEW_ANALYSIS_RESULTS_FROM_ORDER = 'VIEW_ANALYSIS_RESULTS_FROM_ORDER',
|
||||
REGISTRATION_SUCCESS = 'REGISTRATION_SUCCESS',
|
||||
VIEW_ORDER_ANALYSIS = 'VIEW_ORDER_ANALYSIS',
|
||||
VIEW_TEAM_ACCOUNT_DASHBOARD = 'VIEW_TEAM_ACCOUNT_DASHBOARD',
|
||||
@@ -10,9 +11,11 @@ export enum PageViewAction {
|
||||
export const createPageViewLog = async ({
|
||||
accountId,
|
||||
action,
|
||||
extraData,
|
||||
}: {
|
||||
accountId: string;
|
||||
action: PageViewAction;
|
||||
extraData?: Record<string, any>;
|
||||
}) => {
|
||||
try {
|
||||
const supabase = getSupabaseServerClient();
|
||||
@@ -34,6 +37,7 @@ export const createPageViewLog = async ({
|
||||
account_id: accountId,
|
||||
action,
|
||||
changed_by: user.id,
|
||||
extra_data: extraData,
|
||||
})
|
||||
.throwOnError();
|
||||
} 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';
|
||||
|
||||
import { CompanySubmitData } from '@/lib/types/company';
|
||||
import { emailSchema } from '@/lib/validations/email.schema';
|
||||
import { toArray } from '@/lib/utils';
|
||||
|
||||
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
|
||||
import { getMailer } from '@kit/mailers';
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
export const sendDoctorSummaryCompletedEmail = async (
|
||||
language: string,
|
||||
recipientName: string,
|
||||
recipientEmail: string,
|
||||
orderNr: string,
|
||||
orderId: number,
|
||||
) => {
|
||||
const { html, subject } = await renderDoctorSummaryReceivedEmail({
|
||||
language,
|
||||
recipientName,
|
||||
recipientEmail,
|
||||
orderNr,
|
||||
orderId,
|
||||
});
|
||||
import { emailSchema } from '~/lib/validations/email.schema';
|
||||
|
||||
await sendEmail({
|
||||
subject,
|
||||
html,
|
||||
to: recipientEmail,
|
||||
});
|
||||
type EmailTemplate = {
|
||||
html: string;
|
||||
subject: string;
|
||||
};
|
||||
|
||||
export const sendCompanyOfferEmail = async (
|
||||
data: CompanySubmitData,
|
||||
language: string,
|
||||
) => {
|
||||
const { renderCompanyOfferEmail } = await import('@kit/email-templates');
|
||||
const { html, subject } = await renderCompanyOfferEmail({
|
||||
language,
|
||||
companyData: data,
|
||||
});
|
||||
type EmailRenderer<T = any> = (params: T) => Promise<EmailTemplate>;
|
||||
|
||||
await sendEmail({
|
||||
subject,
|
||||
html,
|
||||
to: process.env.CONTACT_EMAIL || '',
|
||||
});
|
||||
export const sendEmailFromTemplate = async <T>(
|
||||
renderer: EmailRenderer<T>,
|
||||
templateParams: T,
|
||||
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(
|
||||
@@ -53,7 +44,7 @@ export const sendEmail = enhanceAction(
|
||||
const log = await getLogger();
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function getLatestPublicMessageListItem() {
|
||||
Action: MedipostAction.GetPublicMessageList,
|
||||
User: USER,
|
||||
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
|
||||
// MessageType check only for messages of certain type
|
||||
},
|
||||
@@ -180,7 +180,10 @@ export async function getPrivateMessage(messageId: string) {
|
||||
|
||||
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) {
|
||||
@@ -211,7 +214,9 @@ export async function readPrivateMessageResponse({
|
||||
|
||||
try {
|
||||
const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds });
|
||||
if (!privateMessage) {
|
||||
messageId = privateMessage?.messageId ?? null;
|
||||
|
||||
if (!privateMessage || !messageId) {
|
||||
return {
|
||||
messageId: null,
|
||||
hasAnalysisResponse: false,
|
||||
@@ -221,40 +226,28 @@ export async function readPrivateMessageResponse({
|
||||
};
|
||||
}
|
||||
|
||||
messageId = privateMessage.messageId;
|
||||
if (!messageId) {
|
||||
return {
|
||||
messageId: null,
|
||||
hasAnalysisResponse: false,
|
||||
hasPartialAnalysisResponse: false,
|
||||
hasFullAnalysisResponse: false,
|
||||
medusaOrderId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const privateMessageContent = await getPrivateMessage(
|
||||
const { message: privateMessageContent, xml: privateMessageXml } = await getPrivateMessage(
|
||||
privateMessage.messageId,
|
||||
);
|
||||
|
||||
const messageResponse = privateMessageContent?.Saadetis?.Vastus;
|
||||
medusaOrderId = privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId;
|
||||
|
||||
if (!medusaOrderId || !medusaOrderId.toString().startsWith('order_')) {
|
||||
return {
|
||||
messageId,
|
||||
hasAnalysisResponse: false,
|
||||
hasPartialAnalysisResponse: false,
|
||||
hasFullAnalysisResponse: false,
|
||||
medusaOrderId: undefined,
|
||||
};
|
||||
}
|
||||
const hasInvalidOrderId = !medusaOrderId || !medusaOrderId.toString().startsWith('order_');
|
||||
|
||||
if (!messageResponse) {
|
||||
if (hasInvalidOrderId || !messageResponse) {
|
||||
await createMedipostActionLog({
|
||||
action: 'sync_analysis_results_from_medipost',
|
||||
xml: privateMessageXml,
|
||||
hasAnalysisResults: false,
|
||||
medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId,
|
||||
});
|
||||
return {
|
||||
messageId,
|
||||
hasAnalysisResponse: false,
|
||||
hasPartialAnalysisResponse: false,
|
||||
hasFullAnalysisResponse: false,
|
||||
medusaOrderId,
|
||||
medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -745,12 +738,36 @@ export async function sendOrderToMedipost({
|
||||
await sendPrivateMessage(orderXml);
|
||||
} catch (e) {
|
||||
const isMedipostError = e instanceof MedipostValidationError;
|
||||
await logMedipostDispatch({
|
||||
medusaOrderId,
|
||||
isSuccess: false,
|
||||
isMedipostError,
|
||||
errorMessage: isMedipostError ? e.response : undefined,
|
||||
});
|
||||
if (isMedipostError) {
|
||||
await logMedipostDispatch({
|
||||
medusaOrderId,
|
||||
isSuccess: false,
|
||||
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;
|
||||
}
|
||||
await logMedipostDispatch({
|
||||
@@ -758,6 +775,12 @@ export async function sendOrderToMedipost({
|
||||
isSuccess: true,
|
||||
isMedipostError: false,
|
||||
});
|
||||
await createMedipostActionLog({
|
||||
action: 'send_order_to_medipost',
|
||||
xml: orderXml,
|
||||
hasAnalysisResults: false,
|
||||
medusaOrderId,
|
||||
});
|
||||
await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
|
||||
}
|
||||
|
||||
@@ -825,3 +848,37 @@ export async function getOrderedAnalysisElementsIds({
|
||||
|
||||
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({
|
||||
orderStatus,
|
||||
medusaOrderId,
|
||||
}: {
|
||||
orderStatus?: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
|
||||
medusaOrderId?: string | null;
|
||||
} = {}) {
|
||||
const query = getSupabaseServerAdminClient()
|
||||
.schema('medreport')
|
||||
@@ -144,6 +146,9 @@ export async function getAnalysisOrdersAdmin({
|
||||
if (orderStatus) {
|
||||
query.eq('status', orderStatus);
|
||||
}
|
||||
if (medusaOrderId) {
|
||||
query.eq('medusa_order_id', medusaOrderId);
|
||||
}
|
||||
const orders = await query.order('created_at', { ascending: false }).throwOnError();
|
||||
return orders.data;
|
||||
}
|
||||
|
||||
@@ -165,6 +165,20 @@ async function doctorMiddleware(request: NextRequest, response: NextResponse) {
|
||||
*/
|
||||
function getPatterns() {
|
||||
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/*?' }),
|
||||
handler: adminMiddleware,
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import {
|
||||
renderAllResultsReceivedEmail,
|
||||
renderFirstResultsReceivedEmail,
|
||||
} from '@kit/email-templates';
|
||||
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';
|
||||
|
||||
export function createDatabaseWebhookRouterService(
|
||||
@@ -42,6 +55,12 @@ class DatabaseWebhookRouterService {
|
||||
return this.handleAccountsWebhook(payload);
|
||||
}
|
||||
|
||||
case 'analysis_orders': {
|
||||
const payload = body as RecordChange<typeof body.table>;
|
||||
|
||||
return this.handleAnalysisOrdersWebhook(payload);
|
||||
}
|
||||
|
||||
default: {
|
||||
return;
|
||||
}
|
||||
@@ -83,4 +102,69 @@ class DatabaseWebhookRouterService {
|
||||
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 {
|
||||
Body,
|
||||
Button,
|
||||
Head,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Tailwind,
|
||||
Text,
|
||||
@@ -13,6 +11,7 @@ import {
|
||||
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';
|
||||
@@ -20,16 +19,14 @@ import { initializeEmailI18n } from '../lib/i18n';
|
||||
|
||||
export async function renderDoctorSummaryReceivedEmail({
|
||||
language,
|
||||
recipientEmail,
|
||||
recipientName,
|
||||
orderNr,
|
||||
orderId,
|
||||
analysisOrderId,
|
||||
}: {
|
||||
language?: string;
|
||||
recipientName: string;
|
||||
recipientEmail: string;
|
||||
orderNr: string;
|
||||
orderId: number;
|
||||
analysisOrderId: number;
|
||||
}) {
|
||||
const namespace = 'doctor-summary-received-email';
|
||||
|
||||
@@ -38,8 +35,6 @@ export async function renderDoctorSummaryReceivedEmail({
|
||||
namespace: [namespace, 'common'],
|
||||
});
|
||||
|
||||
const to = recipientEmail;
|
||||
|
||||
const previewText = t(`${namespace}:previewText`, {
|
||||
orderNr,
|
||||
});
|
||||
@@ -72,14 +67,15 @@ export async function renderDoctorSummaryReceivedEmail({
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:summaryReceivedForOrder`, { orderNr })}
|
||||
</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>
|
||||
</Link>
|
||||
{t(`${namespace}:linkText`, { orderNr })}
|
||||
</EmailButton>
|
||||
<Text>
|
||||
{t(`${namespace}:ifButtonDisabled`)}{' '}
|
||||
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/order/${orderId}`}
|
||||
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
|
||||
</Text>
|
||||
<CommonFooter t={t} />
|
||||
</EmailContent>
|
||||
@@ -92,6 +88,5 @@ export async function renderDoctorSummaryReceivedEmail({
|
||||
return {
|
||||
html,
|
||||
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">
|
||||
<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}
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
} 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';
|
||||
import CommonFooter from '../components/common-footer';
|
||||
|
||||
interface Props {
|
||||
analysisPackageName: string;
|
||||
@@ -31,7 +31,10 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) {
|
||||
namespace: [namespace, 'common'],
|
||||
});
|
||||
|
||||
const previewText = t(`${namespace}:previewText`);
|
||||
const previewText = t(`${namespace}:previewText`, {
|
||||
analysisPackageName: props.analysisPackageName,
|
||||
});
|
||||
|
||||
const subject = t(`${namespace}:subject`, {
|
||||
analysisPackageName: props.analysisPackageName,
|
||||
});
|
||||
|
||||
@@ -4,3 +4,6 @@ export * from './emails/otp.email';
|
||||
export * from './emails/company-offer.email';
|
||||
export * from './emails/synlab.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": {
|
||||
"lines1": "MedReport",
|
||||
"lines2": "E-mail: <a href=\"mailto:info@medreport.ee\">info@medreport.ee</a>",
|
||||
"lines3": "Klienditugi: <a href=\"tel:+37258871517\">+372 5887 1517</a>",
|
||||
"lines2": "Электронная почта: <a href=\"mailto:info@medreport.ee\">info@medreport.ee</a>",
|
||||
"lines3": "Служба поддержки: <a href=\"tel:+37258871517\">+372 5887 1517</a>",
|
||||
"lines4": "<a href=\"https://www.medreport.ee\">www.medreport.ee</a>"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"subject": "Uus ettevõtte liitumispäring",
|
||||
"previewText": "Ettevõte {{companyName}} soovib pakkumist",
|
||||
"companyName": "Ettevõtte nimi:",
|
||||
"contactPerson": "Kontaktisik:",
|
||||
"email": "E-mail:",
|
||||
"phone": "Telefon:"
|
||||
}
|
||||
"subject": "Новый запрос на присоединение компании",
|
||||
"previewText": "Компания {{companyName}} запрашивает предложение",
|
||||
"companyName": "Название компании:",
|
||||
"contactPerson": "Контактное лицо:",
|
||||
"email": "Электронная почта:",
|
||||
"phone": "Телефон:"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"subject": "Saabus arsti kokkuvõtte tellimusele {{orderNr}}",
|
||||
"previewText": "Arst on saatnud kokkuvõtte sinu analüüsitulemustele.",
|
||||
"hello": "Tere, {{displayName}}",
|
||||
"summaryReceivedForOrder": "Arst on koostanud selgitava kokkuvõtte sinu tellitud analüüsidele.",
|
||||
"linkText": "Vaata kokkuvõtet",
|
||||
"ifButtonDisabled": "Kui nupule vajutamine ei toimi, kopeeri see link oma brauserisse:"
|
||||
"subject": "Получено заключение врача по заказу {{orderNr}}",
|
||||
"previewText": "Врач отправил заключение по вашим результатам анализа.",
|
||||
"hello": "Здравствуйте, {{displayName}}",
|
||||
"summaryReceivedForOrder": "Врач подготовил пояснительное заключение по заказанным вами анализам.",
|
||||
"linkText": "Посмотреть заключение",
|
||||
"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}}",
|
||||
"previewText": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}",
|
||||
"heading": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}",
|
||||
"hello": "Tere {{personName}},",
|
||||
"lines1": "Saatekiri {{analysisPackageName}} analüüsi uuringuteks on saadetud laborisse digitaalselt. Palun mine proove andma: 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>",
|
||||
"lines3": "Soovituslik on proove anda pigem hommikul (enne 12:00) ning söömata ja joomata (vett võib juua).",
|
||||
"lines4": "Proovivõtupunktis valige järjekorrasüsteemis: <strong>saatekirjad</strong> alt <strong>eriarsti saatekiri</strong>.",
|
||||
"lines5": "Juhul kui tekkis lisaküsimusi, siis võtke julgelt ühendust.",
|
||||
"lines6": "SYNLAB klienditoe telefon: <a href=\"tel:+37217123\">17123</a>"
|
||||
"subject": "Ваш заказ Medreport подтвержден - {{analysisPackageName}}",
|
||||
"previewText": "Ваш заказ Medreport подтвержден - {{analysisPackageName}}",
|
||||
"heading": "Ваш заказ Medreport подтвержден - {{analysisPackageName}}",
|
||||
"hello": "Здравствуйте, {{personName}},",
|
||||
"lines1": "Направление на исследование {{analysisPackageName}} было отправлено в лабораторию в цифровом виде. Пожалуйста, сдайте анализы: Synlab - {{partnerLocationName}}",
|
||||
"lines2": "<i>Если вы не можете посетить выбранный пункт сдачи анализов, вы можете обратиться в удобный для вас пункт - <a href=\"https://medreport.ee/et/verevotupunktid\">посмотреть адреса и часы работы</a>.</i>",
|
||||
"lines3": "Рекомендуется сдавать анализы утром (до 12:00) натощак (можно пить воду).",
|
||||
"lines4": "В пункте сдачи анализов выберите в системе очереди: <strong>направления</strong> -> <strong>направление от специалиста</strong>.",
|
||||
"lines5": "Если у вас возникнут дополнительные вопросы, смело свяжитесь с нами.",
|
||||
"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'];
|
||||
|
||||
export function useUpdateAccountData(accountId: string) {
|
||||
export function useUpdateAccountData(accountId?: string) {
|
||||
const client = useSupabase();
|
||||
|
||||
const mutationKey = ['account:data', accountId];
|
||||
|
||||
const mutationFn = async (data: UpdateData) => {
|
||||
if (!accountId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await client
|
||||
.schema('medreport')
|
||||
.from('accounts')
|
||||
|
||||
@@ -2,7 +2,10 @@ import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
import { UserAnalysis } from '../types/accounts';
|
||||
import {
|
||||
AnalysisResultDetails,
|
||||
UserAnalysis,
|
||||
} from '../types/accounts';
|
||||
|
||||
export type AccountWithParams =
|
||||
Database['medreport']['Tables']['accounts']['Row'] & {
|
||||
@@ -184,7 +187,49 @@ class AccountsApi {
|
||||
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 { data, error: userError } = authUser;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export type UserAnalysisElement =
|
||||
@@ -15,3 +17,51 @@ export enum ApplicationRoleEnum {
|
||||
Doctor = 'doctor',
|
||||
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 (
|
||||
<div
|
||||
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' +
|
||||
' animate-in fade-in slide-in-from-top-16 zoom-in-95 duration-1000'
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ export const OauthProviders: React.FC<{
|
||||
redirectTo,
|
||||
queryParams: props.queryParams,
|
||||
scopes,
|
||||
skipBrowserRedirect: false,
|
||||
},
|
||||
} satisfies SignInWithOAuthCredentials;
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export const PatientSchema = z.object({
|
||||
email: z.string().nullable(),
|
||||
height: z.number().optional().nullable(),
|
||||
weight: z.number().optional().nullable(),
|
||||
preferred_locale: z.string().nullable(),
|
||||
});
|
||||
export type Patient = z.infer<typeof PatientSchema>;
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export const AccountSchema = z.object({
|
||||
last_name: z.string().nullable(),
|
||||
id: z.string(),
|
||||
primary_owner_user_id: z.string(),
|
||||
preferred_locale: z.string().nullable(),
|
||||
});
|
||||
export type Account = z.infer<typeof AccountSchema>;
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ import 'server-only';
|
||||
|
||||
import { isBefore } from 'date-fns';
|
||||
|
||||
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
|
||||
import { getFullName } from '@kit/shared/utils';
|
||||
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 {
|
||||
AnalysisResponseBase,
|
||||
@@ -54,7 +55,7 @@ async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
|
||||
supabase
|
||||
.schema('medreport')
|
||||
.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),
|
||||
]);
|
||||
|
||||
@@ -67,7 +68,7 @@ async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
|
||||
? await supabase
|
||||
.schema('medreport')
|
||||
.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)
|
||||
: { data: [] };
|
||||
|
||||
@@ -408,7 +409,7 @@ export async function getAnalysisResultsForDoctor(
|
||||
.schema('medreport')
|
||||
.from('accounts')
|
||||
.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)`,
|
||||
)
|
||||
.eq('is_personal_account', true)
|
||||
@@ -472,6 +473,7 @@ export async function getAnalysisResultsForDoctor(
|
||||
personal_code,
|
||||
phone,
|
||||
account_params,
|
||||
preferred_locale,
|
||||
} = accountWithParams[0];
|
||||
|
||||
const analysisResponseElementsWithPreviousData = [];
|
||||
@@ -503,6 +505,7 @@ export async function getAnalysisResultsForDoctor(
|
||||
},
|
||||
doctorFeedback: doctorFeedback?.[0],
|
||||
patient: {
|
||||
preferred_locale,
|
||||
userId: primary_owner_user_id,
|
||||
accountId,
|
||||
firstName: name,
|
||||
@@ -638,7 +641,7 @@ export async function submitFeedback(
|
||||
}
|
||||
|
||||
if (status === 'COMPLETED') {
|
||||
const [{ data: recipient }, { data: medusaOrderIds }] = await Promise.all([
|
||||
const [{ data: recipient }, { data: analysisOrder }] = await Promise.all([
|
||||
supabase
|
||||
.schema('medreport')
|
||||
.from('accounts')
|
||||
@@ -653,24 +656,33 @@ export async function submitFeedback(
|
||||
.eq('id', analysisOrderId)
|
||||
.limit(1)
|
||||
.throwOnError(),
|
||||
supabase
|
||||
.schema('medreport')
|
||||
.from('analysis_orders')
|
||||
.update({ status: 'COMPLETED' })
|
||||
.eq('id', analysisOrderId)
|
||||
.throwOnError(),
|
||||
]);
|
||||
|
||||
if (!recipient?.[0]?.email) {
|
||||
throw new Error('Could not find user email.');
|
||||
}
|
||||
|
||||
if (!medusaOrderIds?.[0]?.id) {
|
||||
if (!analysisOrder?.[0]?.id) {
|
||||
throw new Error('Could not retrieve order.');
|
||||
}
|
||||
|
||||
const { preferred_locale, name, last_name, email } = recipient[0];
|
||||
|
||||
await sendDoctorSummaryCompletedEmail(
|
||||
preferred_locale ?? 'et',
|
||||
getFullName(name, last_name),
|
||||
await sendEmailFromTemplate(
|
||||
renderDoctorSummaryReceivedEmail,
|
||||
{
|
||||
language: preferred_locale ?? 'et',
|
||||
recipientName: getFullName(name, last_name),
|
||||
orderNr: analysisOrder?.[0]?.medusa_order_id ?? '',
|
||||
analysisOrderId: analysisOrder[0].id,
|
||||
},
|
||||
email,
|
||||
medusaOrderIds?.[0]?.medusa_order_id ?? '',
|
||||
medusaOrderIds[0].id,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { sdk } from "@lib/config"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { getCacheOptions } from "./cookies"
|
||||
import { sdk } from "@lib/config";
|
||||
import { HttpTypes } from "@medusajs/types";
|
||||
import { getCacheOptions } from "./cookies";
|
||||
|
||||
export const listCategories = async (query?: Record<string, any>) => {
|
||||
const next = {
|
||||
...(await getCacheOptions("categories")),
|
||||
}
|
||||
};
|
||||
|
||||
const limit = query?.limit || 100
|
||||
const limit = query?.limit || 100;
|
||||
|
||||
return sdk.client
|
||||
.fetch<{ product_categories: HttpTypes.StoreProductCategory[] }>(
|
||||
@@ -23,8 +23,8 @@ export const listCategories = async (query?: Record<string, any>) => {
|
||||
cache: "force-cache",
|
||||
}
|
||||
)
|
||||
.then(({ product_categories }) => product_categories)
|
||||
}
|
||||
.then(({ product_categories }) => product_categories);
|
||||
};
|
||||
|
||||
export const getCategoryByHandle = async (categoryHandle: string[]) => {
|
||||
const { product_categories } = await getProductCategories({
|
||||
@@ -32,7 +32,7 @@ export const getCategoryByHandle = async (categoryHandle: string[]) => {
|
||||
limit: 1,
|
||||
});
|
||||
return product_categories[0];
|
||||
}
|
||||
};
|
||||
|
||||
export const getProductCategories = async ({
|
||||
handle,
|
||||
@@ -45,19 +45,18 @@ export const getProductCategories = async ({
|
||||
} = {}) => {
|
||||
const next = {
|
||||
...(await getCacheOptions("categories")),
|
||||
}
|
||||
};
|
||||
|
||||
return sdk.client
|
||||
.fetch<HttpTypes.StoreProductCategoryListResponse>(
|
||||
`/store/product-categories`,
|
||||
{
|
||||
query: {
|
||||
fields,
|
||||
handle,
|
||||
limit,
|
||||
},
|
||||
next,
|
||||
//cache: "force-cache",
|
||||
}
|
||||
);
|
||||
}
|
||||
return sdk.client.fetch<HttpTypes.StoreProductCategoryListResponse>(
|
||||
`/store/product-categories`,
|
||||
{
|
||||
query: {
|
||||
fields,
|
||||
handle,
|
||||
limit,
|
||||
},
|
||||
next,
|
||||
//cache: "force-cache",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ export const listProducts = async ({
|
||||
regionId,
|
||||
}: {
|
||||
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
|
||||
regionId?: string
|
||||
}): Promise<{
|
||||
@@ -63,7 +63,7 @@ export const listProducts = async ({
|
||||
offset,
|
||||
region_id: region?.id,
|
||||
fields:
|
||||
"*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags",
|
||||
"*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags,+status",
|
||||
...queryParams,
|
||||
},
|
||||
headers,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { StoreProduct } from '@medusajs/types';
|
||||
import { Button } from '@medusajs/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { handleAddToCart } from '../../../../lib/services/medusaCart.service';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -49,12 +50,19 @@ export default function SelectAnalysisPackage({
|
||||
|
||||
const handleSelect = async () => {
|
||||
setIsAddingToCart(true);
|
||||
await handleAddToCart({
|
||||
selectedVariant: { id: variantId },
|
||||
countryCode,
|
||||
});
|
||||
setIsAddingToCart(false);
|
||||
router.push('/home/cart');
|
||||
try {
|
||||
await handleAddToCart({
|
||||
selectedVariant: { id: variantId },
|
||||
countryCode,
|
||||
});
|
||||
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 (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import SelectAnalysisPackage, { AnalysisPackageWithVariant } from './select-analysis-package';
|
||||
import SelectAnalysisPackage, {
|
||||
AnalysisPackageWithVariant,
|
||||
} from './select-analysis-package';
|
||||
|
||||
export default function SelectAnalysisPackages({
|
||||
analysisPackages,
|
||||
@@ -10,11 +12,16 @@ export default function SelectAnalysisPackages({
|
||||
countryCode: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{analysisPackages.length > 0 ? analysisPackages.map(
|
||||
(analysisPackage) => (
|
||||
<SelectAnalysisPackage key={analysisPackage.title} analysisPackage={analysisPackage} countryCode={countryCode} />
|
||||
)) : (
|
||||
<div className="grid gap-6 sm:grid-cols-3">
|
||||
{analysisPackages.length > 0 ? (
|
||||
analysisPackages.map((analysisPackage) => (
|
||||
<SelectAnalysisPackage
|
||||
key={analysisPackage.title}
|
||||
analysisPackage={analysisPackage}
|
||||
countryCode={countryCode}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<h4>
|
||||
<Trans i18nKey="order-analysis-package:noPackagesAvailable" />
|
||||
</h4>
|
||||
|
||||
@@ -13,7 +13,7 @@ export function InfoTooltip({
|
||||
content,
|
||||
icon,
|
||||
}: {
|
||||
content?: string | null;
|
||||
content?: JSX.Element | string | null;
|
||||
icon?: JSX.Element;
|
||||
}) {
|
||||
if (!content) return null;
|
||||
@@ -23,7 +23,7 @@ export function InfoTooltip({
|
||||
<TooltipTrigger>
|
||||
{icon || <Info className="size-4 cursor-pointer" />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{content}</TooltipContent>
|
||||
<TooltipContent className='sm:max-w-[400px]'>{content}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ const authConfig = AuthConfigSchema.parse({
|
||||
providers: {
|
||||
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
|
||||
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
|
||||
oAuth: ['google'],
|
||||
oAuth: ['keycloak'],
|
||||
},
|
||||
} satisfies z.infer<typeof AuthConfigSchema>);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const PathsSchema = z.object({
|
||||
home: z.string().min(1),
|
||||
selectPackage: z.string().min(1),
|
||||
booking: z.string().min(1),
|
||||
bookingHandle: z.string().min(1),
|
||||
myOrders: z.string().min(1),
|
||||
analysisResults: z.string().min(1),
|
||||
orderAnalysisPackage: z.string().min(1),
|
||||
@@ -64,6 +65,7 @@ const pathsConfig = PathsSchema.parse({
|
||||
joinTeam: '/join',
|
||||
selectPackage: '/select-package',
|
||||
booking: '/home/booking',
|
||||
bookingHandle: '/home/booking/[handle]',
|
||||
orderAnalysisPackage: '/home/order-analysis-package',
|
||||
myOrders: '/home/order',
|
||||
analysisResults: '/home/analysis-results',
|
||||
|
||||
@@ -35,12 +35,6 @@ const routes = [
|
||||
Icon: <ShoppingCart className={iconClasses} />,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
label: 'common:routes.analysisResults',
|
||||
path: pathsConfig.app.analysisResults,
|
||||
Icon: <TestTube2 className={iconClasses} />,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
label: 'common:routes.orderAnalysisPackage',
|
||||
path: pathsConfig.app.orderAnalysisPackage,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
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>() {
|
||||
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();
|
||||
|
||||
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||
auth: {
|
||||
flowType: 'pkce',
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
},
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll();
|
||||
|
||||
@@ -15,6 +15,11 @@ export function getSupabaseServerClient<GenericSchema = Database>() {
|
||||
const keys = getSupabaseClientKeys();
|
||||
|
||||
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||
auth: {
|
||||
flowType: 'pkce',
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
},
|
||||
cookies: {
|
||||
async getAll() {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
@@ -199,6 +199,7 @@ export type Database = {
|
||||
changed_by: string
|
||||
created_at: string
|
||||
id: number
|
||||
extra_data?: Json | null
|
||||
}
|
||||
Insert: {
|
||||
account_id: string
|
||||
@@ -206,6 +207,7 @@ export type Database = {
|
||||
changed_by: string
|
||||
created_at?: string
|
||||
id?: number
|
||||
extra_data?: Json | null
|
||||
}
|
||||
Update: {
|
||||
account_id?: string
|
||||
@@ -213,6 +215,7 @@ export type Database = {
|
||||
changed_by?: string
|
||||
created_at?: string
|
||||
id?: number
|
||||
extra_data?: Json | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
@@ -1088,7 +1091,7 @@ export type Database = {
|
||||
price: number
|
||||
price_periods: string | null
|
||||
requires_payment: boolean
|
||||
sync_id: number
|
||||
sync_id: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
@@ -1107,7 +1110,7 @@ export type Database = {
|
||||
price: number
|
||||
price_periods?: string | null
|
||||
requires_payment: boolean
|
||||
sync_id: number
|
||||
sync_id?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
@@ -1126,7 +1129,7 @@ export type Database = {
|
||||
price?: number
|
||||
price_periods?: string | null
|
||||
requires_payment?: boolean
|
||||
sync_id?: number
|
||||
sync_id?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
@@ -1147,7 +1150,7 @@ export type Database = {
|
||||
doctor_user_id: string | null
|
||||
id: number
|
||||
status: Database["medreport"]["Enums"]["analysis_feedback_status"]
|
||||
updated_at: string
|
||||
updated_at: string | null
|
||||
updated_by: string | null
|
||||
user_id: string
|
||||
value: string | null
|
||||
@@ -1159,7 +1162,7 @@ export type Database = {
|
||||
doctor_user_id?: string | null
|
||||
id?: number
|
||||
status?: Database["medreport"]["Enums"]["analysis_feedback_status"]
|
||||
updated_at?: string
|
||||
updated_at?: string | null
|
||||
updated_by?: string | null
|
||||
user_id: string
|
||||
value?: string | null
|
||||
@@ -1171,7 +1174,7 @@ export type Database = {
|
||||
doctor_user_id?: string | null
|
||||
id?: number
|
||||
status?: Database["medreport"]["Enums"]["analysis_feedback_status"]
|
||||
updated_at?: string
|
||||
updated_at?: string | null
|
||||
updated_by?: string | null
|
||||
user_id?: string
|
||||
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: {
|
||||
Row: {
|
||||
created_at: string
|
||||
@@ -1850,9 +1881,7 @@ export type Database = {
|
||||
Returns: Json
|
||||
}
|
||||
create_team_account: {
|
||||
Args:
|
||||
| { account_name: string }
|
||||
| { account_name: string; new_personal_code: string }
|
||||
Args: { account_name: string; new_personal_code: string }
|
||||
Returns: {
|
||||
application_role: Database["medreport"]["Enums"]["application_role"]
|
||||
city: string | null
|
||||
@@ -1921,6 +1950,15 @@ export type Database = {
|
||||
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: {
|
||||
Args: { p_medusa_order_id: string }
|
||||
Returns: number
|
||||
@@ -2137,6 +2175,21 @@ export type Database = {
|
||||
}
|
||||
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: {
|
||||
analysis_feedback_status: "STARTED" | "DRAFT" | "COMPLETED"
|
||||
|
||||
@@ -40,7 +40,7 @@ export function LanguageSelector({
|
||||
}, [currentLanguage]);
|
||||
|
||||
const userId = user?.id;
|
||||
const updateAccountMutation = useUpdateAccountData(userId!);
|
||||
const updateAccountMutation = useUpdateAccountData(userId);
|
||||
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
|
||||
|
||||
const updateLanguagePreference = async (
|
||||
@@ -52,6 +52,10 @@ export function LanguageSelector({
|
||||
onChange(locale);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return i18n.changeLanguage(locale);
|
||||
}
|
||||
|
||||
const promise = updateAccountMutation
|
||||
.mutateAsync({
|
||||
preferred_locale: locale,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { LanguageSelector } from '@kit/ui/language-selector';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
logo?: React.ReactNode;
|
||||
navigation?: React.ReactNode;
|
||||
@@ -22,11 +24,16 @@ export const Header: React.FC<HeaderProps> = function ({
|
||||
{...props}
|
||||
>
|
||||
<div className="container">
|
||||
<div className="grid h-14 grid-cols-3 items-center">
|
||||
<div className={'mx-auto md:mx-0'}>{logo}</div>
|
||||
<div className="order-first md:order-none">{navigation}</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-x-2"><div className="max-w-[100px]"><LanguageSelector /></div>{actions}</div>
|
||||
<div className="2xs:h-14 2xs:grid-cols-3 grid h-24 w-full items-center">
|
||||
<div className={'mx-auto flex'}>{logo}</div>
|
||||
<div className="2xs:order-none order-first">{navigation}</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>
|
||||
|
||||
@@ -42,7 +42,7 @@ function PageWithSidebar(props: PageProps) {
|
||||
>
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,7 +58,7 @@ export function PageMobileNavigation(
|
||||
return (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -128,5 +128,6 @@
|
||||
"updateRoleLoading": "Updating role...",
|
||||
"updatePreferredLocaleSuccess": "Language preference updated",
|
||||
"updatePreferredLocaleError": "Language preference update failed",
|
||||
"updatePreferredLocaleLoading": "Updating language preference..."
|
||||
"updatePreferredLocaleLoading": "Updating language preference...",
|
||||
"doctorAnalysisSummary": "Doctor's summary"
|
||||
}
|
||||
@@ -12,5 +12,6 @@
|
||||
"normal": "Normal range"
|
||||
}
|
||||
},
|
||||
"orderTitle": "Order number {{orderNumber}}"
|
||||
"orderTitle": "Order number {{orderNumber}}",
|
||||
"view": "View results"
|
||||
}
|
||||
@@ -116,5 +116,6 @@
|
||||
"confirm": "Confirm",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"invalidDataError": "Invalid data submitted"
|
||||
"invalidDataError": "Invalid data submitted",
|
||||
"language": "Language"
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
"assignedTo": "Doctor",
|
||||
"resultsStatus": "Analysis results",
|
||||
"waitingForNr": "Waiting for {{nr}}",
|
||||
"language": "Preferred language",
|
||||
"responsesReceived": "Results complete"
|
||||
},
|
||||
"otherPatients": "Other patients",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"title": "Select analysis package",
|
||||
"noPackagesAvailable": "No packages available",
|
||||
"selectThisPackage": "Select this package",
|
||||
"selectPackage": "Select package",
|
||||
"comparePackages": "Compare packages"
|
||||
"title": "Select analysis package",
|
||||
"noPackagesAvailable": "No packages available",
|
||||
"selectThisPackage": "Select this package",
|
||||
"selectPackage": "Select package",
|
||||
"comparePackages": "Compare packages",
|
||||
"analysisPackageAddedToCart": "Analysis package added to cart",
|
||||
"analysisPackageAddToCartError": "Adding analysis package to cart failed"
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"title": "Select analysis",
|
||||
"description": "Select the analysis that suits your needs",
|
||||
"analysisNotAvailable": "Analysis is not available currently"
|
||||
"description": "All analysis results will appear within 1-3 days after the blood test.",
|
||||
"analysisNotAvailable": "Analysis is not available currently",
|
||||
"analysisAddedToCart": "Analysis added to cart",
|
||||
"analysisAddToCartError": "Adding analysis to cart failed"
|
||||
}
|
||||
@@ -151,5 +151,6 @@
|
||||
"updateRoleLoading": "Rolli uuendatakse...",
|
||||
"updatePreferredLocaleSuccess": "Eelistatud keel uuendatud",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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