48 Commits

Author SHA1 Message Date
bc61db07b2 test2 2025-09-04 01:21:42 +03:00
3d58e5aa84 try to display price before adding to cart 2025-09-03 23:04:09 +03:00
8ecca096f2 fix adding to cart loading 2025-09-03 23:03:00 +03:00
84c8dcc792 env test 2025-09-03 14:24:56 +03:00
40ffbf29a5 improve analyses sync error logging 2025-09-03 13:31:43 +03:00
b046b6ab16 use recipient from env as it's different in live 2025-09-03 13:31:41 +03:00
Danel Kungla
8cb9d7552c add Supabase configuration to staging environment and update Dockerfile to include .env.staging 2025-09-03 12:46:39 +03:00
Danel Kungla
cdb638f046 remove Supabase configuration from production environment variables 2025-09-03 10:04:56 +03:00
Danel Kungla
a587b222b9 updated supabase parameters for test 2025-09-02 17:52:47 +03:00
Danel Kungla
612673ddf9 add redirect for authenticated users to home page in middleware 2025-09-02 16:25:27 +03:00
Danel Kungla
3230dd7608 updated medusa publishable key env 2025-09-02 14:01:19 +03:00
Danel Kungla
e4d7c874cc updated medusa publishable key env 2025-09-02 13:13:21 +03:00
Helena
b7926f79a9 MED-89: add analysis view with doctor summary (#68)
* add analysis view with doctor summary

* remove console.log, also return null if analysis data missing

* replace orders table eye with button
2025-09-02 12:18:18 +03:00
Helena
9d62a2d86f MED-140: ui fixes (#69)
* MED-140: ui fixes

* make accountid optional in hook
2025-09-02 12:14:24 +03:00
Helena
3498406a0c MED-88: add doctor email notifications (#65)
* MED-88: add doctor email notifications

* add logging, send open jobs notification on partial analysis response

* update permissions

* fix import, permissions

* casing, let email be null

* unused import
2025-09-02 12:14:01 +03:00
Helena
56a832b96b MED-75: add russian translations (#70) 2025-09-02 11:53:30 +03:00
Danel Kungla
568104eaff updated test envs in aws 2025-09-01 23:25:24 +03:00
Danel Kungla
2eac3a6836 updated test envs in aws 2025-09-01 18:07:54 +03:00
2df366c14a feat(MED-121): fix variant age range check 2025-09-01 14:29:14 +03:00
Danel Kungla
6ce60eacc7 Add data.sql to .gitignore 2025-08-29 18:05:51 +03:00
danelkungla
6e76e75e85 Merge pull request #66 from MR-medreport/main
Main
2025-08-29 16:17:54 +03:00
71c3e2ef1e Merge pull request #64 from MR-medreport/MED-85
feat(MED-85-105-123): some testing feedback, other improvements
2025-08-29 11:52:55 +03:00
d83319a094 Merge branch 'main' into MED-85 2025-08-29 11:45:36 +03:00
Helena
505ef0d91b Merge branch 'main' of https://github.com/MR-medreport/MRB2B 2025-08-29 10:28:15 +03:00
Helena
fce4355be8 Merge branch 'main' of https://github.com/MR-medreport/MRB2B 2025-08-29 10:27:46 +03:00
Helena
da9658ad7a fix email button 2025-08-29 10:27:12 +03:00
danelkungla
e023d54a2a Merge pull request #63 from MR-medreport/main
update develop
2025-08-29 09:50:32 +03:00
danelkungla
bdaacbe78a MED-104: booking page
MED-104
2025-08-29 09:48:26 +03:00
Danel Kungla
5479f310d7 Merge branch 'main' into MED-104 2025-08-29 09:46:04 +03:00
815b877b5b Merge branch 'main' into MED-85 2025-08-28 16:12:46 +03:00
0c28f9681b feat(MED-86): fix status check for fake responses 2025-08-28 15:49:03 +03:00
70b85dc967 feat(MED-86): don't prettify results sync log since aws will split it up 2025-08-28 15:37:29 +03:00
71f5a25632 Merge branch 'main' into MED-85 2025-08-28 14:57:09 +03:00
d072226a5c feat(MED-105): organize env specific required migrations 2025-08-28 14:54:51 +03:00
da7f574234 feat(MED-86): user can see in Medusa BO if sending order to medipost succeeded 2025-08-28 14:42:07 +03:00
b3505c1627 feat(MED-100): show cart line item quantities total instead of items total count 2025-08-28 14:41:59 +03:00
Danel Kungla
ad28352fc8 MED-104: create booking view with categories 2025-08-28 14:11:54 +03:00
b931035c3b feat(MED-86): add db fn to show medipost dispatch error for order in Medusa 2025-08-28 13:36:07 +03:00
f723633646 feat(MED-123): don't redirect to cart on single analysis select 2025-08-28 13:31:29 +03:00
6e6ad13b52 feat(MED-123): show toast on adding analysis package to cart 2025-08-28 13:30:25 +03:00
Danel Kungla
31bc4b6cff initial commit 2025-08-28 13:15:39 +03:00
3ddc0a2716 feat(MED-123): show toast on adding analysis to cart 2025-08-28 12:44:07 +03:00
49eeaa1876 feat(MED-123): update translations 2025-08-28 12:40:33 +03:00
2ffad84100 feat(MED-105): log opening analysis results from orders view 2025-08-28 12:35:30 +03:00
b4985afdf0 feat(MED-85): add logging for medipost response error xml 2025-08-28 12:25:18 +03:00
a37c4cad9c feat(MED-85): add logging for medipost actions with xml and related order id 2025-08-28 11:56:07 +03:00
47ab39172e feat(MED-85): run sending fake medipost results in dev from Medusa BO 2025-08-28 10:30:43 +03:00
d760f86632 feat(MED-85): run force medipost results sync from Medusa BO 2025-08-28 09:54:32 +03:00
138 changed files with 2944 additions and 1237 deletions

2
.env
View File

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

View File

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

View File

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

@@ -43,3 +43,4 @@ yarn-error.log*
next-env.d.ts
dump.sql
data.sql

View File

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

View File

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

View File

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

View 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,
);
}

View File

@@ -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}`,
);
}
}

View File

@@ -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,
};
});

View 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 },
);
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
</>
);
}

View File

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

View 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);

View File

@@ -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>
</>
);

View File

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

View File

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

View File

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

View File

@@ -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()} />

View File

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

View File

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

View 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,
},
});
}

View File

@@ -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>
)
);
}

View 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;

View File

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

View File

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

View 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);

View 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);

View 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();
}

View File

@@ -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);
}

View File

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

View File

@@ -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);
}
};

View File

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

View 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) || [];
}

View File

@@ -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');
}

View File

@@ -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();
}

View File

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

View File

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

View File

@@ -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,
});
}
}
}
}

View 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>
);
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

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

View File

@@ -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,
});

View File

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

View File

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

View File

@@ -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:"
}

View File

@@ -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,"
}

View File

@@ -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,"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "Телефон:"
}

View File

@@ -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": "Если кнопка не работает, скопируйте эту ссылку в ваш браузер:"
}

View File

@@ -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,"
}

View File

@@ -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,"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -102,6 +102,7 @@ export const OauthProviders: React.FC<{
redirectTo,
queryParams: props.queryParams,
scopes,
skipBrowserRedirect: false,
},
} satisfies SignInWithOAuthCredentials;

View File

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

View File

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

View File

@@ -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,
);
}

View File

@@ -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",
}
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1,2 @@
export * from './use-csrf-token';
export * from './use-current-locale-language-names';

View File

@@ -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);
}

View File

@@ -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,
},
});
}

View File

@@ -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();

View File

@@ -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();

View File

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

View File

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

View File

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

View File

@@ -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,
)}
>

View File

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

View File

@@ -12,5 +12,6 @@
"normal": "Normal range"
}
},
"orderTitle": "Order number {{orderNumber}}"
"orderTitle": "Order number {{orderNumber}}",
"view": "View results"
}

View File

@@ -116,5 +116,6 @@
"confirm": "Confirm",
"previous": "Previous",
"next": "Next",
"invalidDataError": "Invalid data submitted"
"invalidDataError": "Invalid data submitted",
"language": "Language"
}

View File

@@ -17,6 +17,7 @@
"assignedTo": "Doctor",
"resultsStatus": "Analysis results",
"waitingForNr": "Waiting for {{nr}}",
"language": "Preferred language",
"responsesReceived": "Results complete"
},
"otherPatients": "Other patients",

View File

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

View File

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

View File

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

View File

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