Merge branch 'develop'

This commit is contained in:
2025-09-03 13:32:15 +03:00
90 changed files with 2159 additions and 1024 deletions

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

@@ -11,6 +11,7 @@ COPY packages packages
COPY tooling tooling COPY tooling tooling
COPY .env .env COPY .env .env
COPY .env.production .env.production COPY .env.production .env.production
COPY .env.staging .env.staging
# Load env file and echo a specific variable # Load env file and echo a specific variable
# RUN dotenv -e .env -- printenv | grep 'SUPABASE' || true # RUN dotenv -e .env -- printenv | grep 'SUPABASE' || true
@@ -20,13 +21,10 @@ COPY . .
ENV NODE_ENV=production ENV NODE_ENV=production
# 🔍 Optional: Log key envs for debug
RUN echo "📄 .env.production contents:" && cat .env.production \
&& echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true
RUN set -a \ RUN set -a \
&& . .env \ && . .env \
&& . .env.production \ && . .env.production \
&& . .env.staging \
&& set +a \ && set +a \
&& node check-env.js \ && node check-env.js \
&& pnpm build && pnpm build
@@ -34,18 +32,21 @@ RUN set -a \
# --- Stage 2: Runtime --- # --- Stage 2: Runtime ---
FROM node:20-alpine FROM node:20-alpine
ARG APP_ENV=production
WORKDIR /app WORKDIR /app
COPY --from=builder /app ./ COPY --from=builder /app ./
RUN cp ".env.${APP_ENV}" .env.local
RUN npm install -g pnpm@9 \ RUN npm install -g pnpm@9 \
&& pnpm install --prod --frozen-lockfile && pnpm install --prod --frozen-lockfile
ENV NODE_ENV=production ENV NODE_ENV=production
# 🔍 Optional: Log key envs for debug # 🔍 Optional: Log key envs for debug
RUN echo "📄 .env.production contents:" && cat .env.production \ RUN echo "📄 .env contents:" && cat .env.local \
&& echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true && echo "🔧 Current ENV available to Next.js build:" && printenv | grep -E 'SUPABASE|STRIPE|NEXT|NODE_ENV' || true

View File

@@ -69,7 +69,7 @@ function AuthButtons() {
</div> </div>
<div className={'flex gap-x-2.5'}> <div className={'flex gap-x-2.5'}>
<Button className={'hidden md:block'} asChild variant={'ghost'}> <Button className={'block'} asChild variant={'ghost'}>
<Link href={pathsConfig.auth.signIn}> <Link href={pathsConfig.auth.signIn}>
<Trans i18nKey={'auth:signIn'} /> <Trans i18nKey={'auth:signIn'} />
</Link> </Link>

View File

@@ -4,14 +4,15 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { SubmitButton } from '@kit/shared/components/ui/submit-button'; import { sendEmailFromTemplate } from '@/lib/services/mailer.service';
import { sendCompanyOfferEmail } from '@/lib/services/mailer.service';
import { CompanySubmitData } from '@/lib/types/company'; import { CompanySubmitData } from '@/lib/types/company';
import { companyOfferSchema } from '@/lib/validations/company-offer.schema'; import { companyOfferSchema } from '@/lib/validations/company-offer.schema';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { renderCompanyOfferEmail } from '@kit/email-templates';
import { SubmitButton } from '@kit/shared/components/ui/submit-button';
import { FormItem } from '@kit/ui/form'; import { FormItem } from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label'; import { Label } from '@kit/ui/label';
@@ -39,7 +40,14 @@ const CompanyOfferForm = () => {
}); });
try { try {
sendCompanyOfferEmail(data, language) sendEmailFromTemplate(
renderCompanyOfferEmail,
{
companyData: data,
language,
},
process.env.CONTACT_EMAIL!,
)
.then(() => router.push('/company-offer/success')) .then(() => router.push('/company-offer/success'))
.catch((error) => { .catch((error) => {
setIsLoading(false); setIsLoading(false);

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'); console.info('Inserting sync entry');
await createSyncSuccessEntry(); await createSyncSuccessEntry();
} catch (e) { } catch (e) {
await createSyncFailEntry(JSON.stringify(e)); const errorMessage = e instanceof Error ? e.message : String(e);
console.error(e); await createSyncFailEntry(JSON.stringify({
message: errorMessage,
stack: e instanceof Error ? e.stack : undefined,
name: e instanceof Error ? e.name : 'Unknown',
}, null, 2));
console.error('Sync failed:', e);
throw new Error( throw new Error(
`Failed to sync public message data, error: ${JSON.stringify(e)}`, `Failed to sync public message data, error: ${errorMessage}`,
); );
} }
} }

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

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { capitalize } from 'lodash';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions'; import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions';
@@ -22,6 +23,9 @@ import {
doctorAnalysisFeedbackFormSchema, doctorAnalysisFeedbackFormSchema,
} from '@kit/doctor/schema/doctor-analysis.schema'; } from '@kit/doctor/schema/doctor-analysis.schema';
import ConfirmationModal from '@kit/shared/components/confirmation-modal'; import ConfirmationModal from '@kit/shared/components/confirmation-modal';
import {
useCurrentLocaleLanguageNames
} from '@kit/shared/hooks';
import { getFullName } from '@kit/shared/utils'; import { getFullName } from '@kit/shared/utils';
import { useUser } from '@kit/supabase/hooks/use-user'; import { useUser } from '@kit/supabase/hooks/use-user';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -57,6 +61,8 @@ export default function AnalysisView({
const { data: user } = useUser(); const { data: user } = useUser();
const languageNames = useCurrentLocaleLanguageNames();
const isInProgress = !!( const isInProgress = !!(
!!feedback?.status && !!feedback?.status &&
feedback?.doctor_user_id && feedback?.doctor_user_id &&
@@ -191,6 +197,12 @@ export default function AnalysisView({
<Trans i18nKey="doctor:email" /> <Trans i18nKey="doctor:email" />
</div> </div>
<div>{patient.email}</div> <div>{patient.email}</div>
<div className="font-bold">
<Trans i18nKey="common:language" />
</div>
<div>
{capitalize(languageNames.of(patient.preferred_locale ?? 'et'))}
</div>
</div> </div>
<div className="xs:hidden block"> <div className="xs:hidden block">
<DoctorJobSelect <DoctorJobSelect

View File

@@ -10,6 +10,7 @@ import { Eye } from 'lucide-react';
import { getResultSetName } from '@kit/doctor/lib/helpers'; import { getResultSetName } from '@kit/doctor/lib/helpers';
import { ResponseTable } from '@kit/doctor/schema/doctor-analysis.schema'; import { ResponseTable } from '@kit/doctor/schema/doctor-analysis.schema';
import { pathsConfig } from '@kit/shared/config'; import { pathsConfig } from '@kit/shared/config';
import { useCurrentLocaleLanguageNames } from '@kit/shared/hooks';
import { getFullName } from '@kit/shared/utils'; import { getFullName } from '@kit/shared/utils';
import { useUser } from '@kit/supabase/hooks/use-user'; import { useUser } from '@kit/supabase/hooks/use-user';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -23,7 +24,9 @@ import {
TableRow, TableRow,
} from '@kit/ui/table'; } from '@kit/ui/table';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import DoctorJobSelect from './doctor-job-select'; import DoctorJobSelect from './doctor-job-select';
import { capitalize } from 'lodash';
export default function ResultsTable({ export default function ResultsTable({
results = [], results = [],
@@ -58,6 +61,8 @@ export default function ResultsTable({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const languageNames = useCurrentLocaleLanguageNames();
const fetchPage = async (page: number) => { const fetchPage = async (page: number) => {
startTransition(async () => { startTransition(async () => {
const result = await fetchAction({ const result = await fetchAction({
@@ -116,6 +121,9 @@ export default function ResultsTable({
<TableHead className="w-20"> <TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.resultsStatus" /> <Trans i18nKey="doctor:resultsTable.resultsStatus" />
</TableHead> </TableHead>
<TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.language" />
</TableHead>
<TableHead className="w-20"> <TableHead className="w-20">
<Trans i18nKey="doctor:resultsTable.assignedTo" /> <Trans i18nKey="doctor:resultsTable.assignedTo" />
</TableHead> </TableHead>
@@ -179,6 +187,11 @@ export default function ResultsTable({
}} }}
/> />
</TableCell> </TableCell>
<TableCell>
{capitalize(
languageNames.of(result?.patient?.preferred_locale ?? 'et'),
)}
</TableCell>
<TableCell> <TableCell>
<DoctorJobSelect <DoctorJobSelect
doctorUserId={result.doctor?.primary_owner_user_id} doctorUserId={result.doctor?.primary_owner_user_id}

View File

@@ -18,8 +18,8 @@ async function AnalysisPage({
id: string; id: string;
}>; }>;
}) { }) {
const { id: analysisResponseId } = await params; const { id: analysisOrderId } = await params;
const analysisResultDetails = await loadResult(Number(analysisResponseId)); const analysisResultDetails = await loadResult(Number(analysisOrderId));
if (!analysisResultDetails) { if (!analysisResultDetails) {
return null; return null;
@@ -28,7 +28,7 @@ async function AnalysisPage({
if (analysisResultDetails) { if (analysisResultDetails) {
await createDoctorPageViewLog({ await createDoctorPageViewLog({
action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS, action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS,
recordKey: analysisResponseId, recordKey: analysisOrderId,
dataOwnerUserId: analysisResultDetails.patient.userId, dataOwnerUserId: analysisResultDetails.patient.userId,
}); });
} }
@@ -50,3 +50,5 @@ async function AnalysisPage({
export default DoctorGuard(AnalysisPage); export default DoctorGuard(AnalysisPage);
const loadResult = cache(getAnalysisResultsForDoctor); const loadResult = cache(getAnalysisResultsForDoctor);

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

@@ -1,30 +1,28 @@
import { Trans } from '@kit/ui/trans';
import {
Card,
CardHeader,
CardDescription,
CardFooter,
} from '@kit/ui/card';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@kit/ui/button';
import { ChevronRight, HeartPulse } from 'lucide-react'; import { ChevronRight, HeartPulse } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Card, CardDescription, CardFooter, CardHeader } from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
export default function DashboardCards() { export default function DashboardCards() {
return ( return (
<div className='flex gap-4 lg:px-4'> <div className="flex gap-4 lg:px-4">
<Card <Card
variant="gradient-success" variant="gradient-success"
className="flex flex-col justify-between" className="xs:w-1/2 sm:w-auto flex w-full flex-col justify-between"
> >
<CardHeader className="flex-row"> <CardHeader className="flex-row">
<div <div
className={'flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-primary\/10 mb-6'} className={
'bg-primary/10 mb-6 flex size-8 items-center-safe justify-center-safe rounded-full text-white'
}
> >
<HeartPulse className="size-4 fill-green-500" /> <HeartPulse className="size-4 fill-green-500" />
</div> </div>
<div className='ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white bg-warning'> <div className="bg-warning ml-auto flex size-8 items-center-safe justify-center-safe rounded-full text-white">
<Link href='/home/order-analysis'> <Link href="/home/order-analysis">
<Button size="icon" variant="outline" className="px-2 text-black"> <Button size="icon" variant="outline" className="px-2 text-black">
<ChevronRight className="size-4 stroke-2" /> <ChevronRight className="size-4 stroke-2" />
</Button> </Button>
@@ -33,10 +31,10 @@ export default function DashboardCards() {
</CardHeader> </CardHeader>
<CardFooter className="flex flex-col items-start gap-2"> <CardFooter className="flex flex-col items-start gap-2">
<h5> <h5>
<Trans i18nKey='dashboard:heroCard.orderAnalysis.title' /> <Trans i18nKey="dashboard:heroCard.orderAnalysis.title" />
</h5> </h5>
<CardDescription className="text-primary"> <CardDescription className="text-primary">
<Trans i18nKey='dashboard:heroCard.orderAnalysis.description' /> <Trans i18nKey="dashboard:heroCard.orderAnalysis.description" />
</CardDescription> </CardDescription>
</CardFooter> </CardFooter>
</Card> </Card>

View File

@@ -166,7 +166,7 @@ export default function Dashboard({
return ( return (
<> <>
<div className="grid auto-rows-fr grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5"> <div className="xs:grid-cols-2 grid auto-rows-fr gap-3 sm:grid-cols-4 lg:grid-cols-5">
{cards({ {cards({
gender: params?.gender, gender: params?.gender,
age: params?.age, age: params?.age,
@@ -233,8 +233,11 @@ export default function Dashboard({
index, index,
) => { ) => {
return ( return (
<div className="flex justify-between" key={index}> <div
<div className="mr-4 flex flex-row items-center gap-4"> className="flex w-full justify-between gap-3 overflow-scroll"
key={index}
>
<div className="mr-4 flex min-w-fit flex-row items-center gap-4">
<div <div
className={cn( className={cn(
'flex size-8 items-center-safe justify-center-safe rounded-full text-white', 'flex size-8 items-center-safe justify-center-safe rounded-full text-white',
@@ -243,7 +246,7 @@ export default function Dashboard({
> >
{icon} {icon}
</div> </div>
<div> <div className="min-w-fit">
<div className="inline-flex items-center gap-1 align-baseline text-sm font-medium"> <div className="inline-flex items-center gap-1 align-baseline text-sm font-medium">
{title} {title}
<InfoTooltip content={tooltipContent} /> <InfoTooltip content={tooltipContent} />
@@ -253,16 +256,24 @@ export default function Dashboard({
</p> </p>
</div> </div>
</div> </div>
<div className="grid w-36 auto-rows-fr grid-cols-2 items-center gap-4"> <div className="grid w-36 auto-rows-fr grid-cols-2 items-center gap-4 min-w-fit">
<p className="text-sm font-medium"> {price}</p> <p className="text-sm font-medium"> {price}</p>
{href ? ( {href ? (
<Link href={href}> <Link href={href}>
<Button size="sm" variant="secondary"> <Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText} {buttonText}
</Button> </Button>
</Link> </Link>
) : ( ) : (
<Button size="sm" variant="secondary"> <Button
size="sm"
variant="secondary"
className="w-full min-w-fit"
>
{buttonText} {buttonText}
</Button> </Button>
)} )}

View File

@@ -1,12 +1,16 @@
'use client'; 'use client';
import { useMemo } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { StoreCart } from '@medusajs/types'; import { StoreCart } from '@medusajs/types';
import { LogOut, Menu, ShoppingCart } from 'lucide-react'; import { Cross, LogOut, Menu, Shield, ShoppingCart } from 'lucide-react';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { ApplicationRoleEnum } from '@kit/accounts/types/accounts';
import { import {
featureFlagsConfig, pathsConfig,
personalAccountNavigationConfig, personalAccountNavigationConfig,
} from '@kit/shared/config'; } from '@kit/shared/config';
import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
@@ -15,7 +19,6 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@kit/ui/dropdown-menu'; } from '@kit/ui/dropdown-menu';
@@ -23,14 +26,16 @@ import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
// home imports // home imports
import { HomeAccountSelector } from '../_components/home-account-selector';
import type { UserWorkspace } from '../_lib/server/load-user-workspace'; import type { UserWorkspace } from '../_lib/server/load-user-workspace';
export function HomeMobileNavigation(props: { export function HomeMobileNavigation(props: {
workspace: UserWorkspace; workspace: UserWorkspace;
cart: StoreCart | null; cart: StoreCart | null;
}) { }) {
const user = props.workspace.user;
const signOut = useSignOut(); const signOut = useSignOut();
const { data: personalAccountData } = usePersonalAccountData(user.id);
const Links = personalAccountNavigationConfig.routes.map((item, index) => { const Links = personalAccountNavigationConfig.routes.map((item, index) => {
if ('children' in item) { if ('children' in item) {
@@ -51,7 +56,29 @@ export function HomeMobileNavigation(props: {
} }
}); });
const cartQuantityTotal = props.cart?.items?.reduce((acc, item) => acc + item.quantity, 0) ?? 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; const hasCartItems = cartQuantityTotal > 0;
return ( return (
@@ -61,22 +88,6 @@ export function HomeMobileNavigation(props: {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}> <DropdownMenuContent sideOffset={10} className={'w-screen rounded-none'}>
<If condition={featureFlagsConfig.enableTeamAccounts}>
<DropdownMenuGroup>
<DropdownMenuLabel>
<Trans i18nKey={'common:yourAccounts'} />
</DropdownMenuLabel>
<HomeAccountSelector
userId={props.workspace.user.id}
accounts={props.workspace.accounts}
collisionPadding={0}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
</If>
<If condition={props.cart && hasCartItems}> <If condition={props.cart && hasCartItems}>
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownLink <DropdownLink
@@ -91,6 +102,41 @@ export function HomeMobileNavigation(props: {
<DropdownMenuGroup>{Links}</DropdownMenuGroup> <DropdownMenuGroup>{Links}</DropdownMenuGroup>
<If condition={isSuperAdmin}>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
href={pathsConfig.app.admin}
>
<Shield className={'h-5'} />
<span>Super Admin</span>
</Link>
</DropdownMenuItem>
</If>
<If condition={isDoctor}>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={
'flex h-full cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
href={pathsConfig.app.doctor}
>
<Cross className={'h-5'} />
<span>
<Trans i18nKey="common:doctor" />
</span>
</Link>
</DropdownMenuItem>
</If>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} /> <SignOutDropdownItem onSignOut={() => signOut.mutateAsync()} />

View File

@@ -52,7 +52,7 @@ export default function OrderAnalysesCards({
} }
return ( return (
<div className="grid grid-cols-3 gap-6 mt-4"> <div className="grid 2xs:grid-cols-3 gap-6 mt-4">
{analyses.map(({ {analyses.map(({
title, title,
variant, variant,

View File

@@ -1,22 +1,32 @@
'use client'; 'use client';
import { Trans } from '@kit/ui/trans'; import { useRouter } from 'next/navigation';
import { StoreOrderLineItem } from '@medusajs/types';
import { formatDate } from 'date-fns';
import { Eye } from 'lucide-react';
import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import { import {
Table, Table,
TableBody, TableBody,
TableHead,
TableRow,
TableHeader,
TableCell, TableCell,
TableHead,
TableHeader,
TableRow,
} from '@kit/ui/table'; } from '@kit/ui/table';
import { StoreOrderLineItem } from "@medusajs/types"; import { Trans } from '@kit/ui/trans';
import { AnalysisOrder } from '~/lib/services/order.service'; import { AnalysisOrder } from '~/lib/services/order.service';
import { formatDate } from 'date-fns';
import { Eye } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { logAnalysisResultsNavigateAction } from './actions'; import { logAnalysisResultsNavigateAction } from './actions';
export default function OrderItemsTable({ items, title, analysisOrder }: { export default function OrderItemsTable({
items,
title,
analysisOrder,
}: {
items: StoreOrderLineItem[]; items: StoreOrderLineItem[];
title: string; title: string;
analysisOrder: AnalysisOrder; analysisOrder: AnalysisOrder;
@@ -29,11 +39,11 @@ export default function OrderItemsTable({ items, title, analysisOrder }: {
const openAnalysisResults = async () => { const openAnalysisResults = async () => {
await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id); await logAnalysisResultsNavigateAction(analysisOrder.medusa_order_id);
router.push(`/home/analysis-results`); router.push(`${pathsConfig.app.analysisResults}/${analysisOrder.id}`);
} };
return ( return (
<Table className="rounded-lg border border-separate"> <Table className="border-separate rounded-lg border">
<TableHeader className="text-ui-fg-subtle txt-medium-plus"> <TableHeader className="text-ui-fg-subtle txt-medium-plus">
<TableRow> <TableRow>
<TableHead className="px-6"> <TableHead className="px-6">
@@ -45,13 +55,14 @@ export default function OrderItemsTable({ items, title, analysisOrder }: {
<TableHead className="px-6"> <TableHead className="px-6">
<Trans i18nKey="orders:table.status" /> <Trans i18nKey="orders:table.status" />
</TableHead> </TableHead>
<TableHead className="px-6"> <TableHead className="px-6"></TableHead>
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items {items
.sort((a, b) => (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1) .sort((a, b) =>
(a.created_at ?? '') > (b.created_at ?? '') ? -1 : 1,
)
.map((orderItem) => ( .map((orderItem) => (
<TableRow className="w-full" key={orderItem.id}> <TableRow className="w-full" key={orderItem.id}>
<TableCell className="text-left w-[100%] px-6"> <TableCell className="text-left w-[100%] px-6">
@@ -64,23 +75,18 @@ export default function OrderItemsTable({ items, title, analysisOrder }: {
{formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')} {formatDate(orderItem.created_at, 'dd.MM.yyyy HH:mm')}
</TableCell> </TableCell>
<TableCell className="px-6 min-w-[180px]"> <TableCell className="min-w-[180px] px-6">
<Trans i18nKey={`orders:status.${analysisOrder.status}`} /> <Trans i18nKey={`orders:status.${analysisOrder.status}`} />
</TableCell> </TableCell>
<TableCell className="text-right px-6"> <TableCell className="px-6 text-right">
<span className="flex gap-x-1 justify-end w-[30px]"> <Button size="sm" onClick={openAnalysisResults}>
<button <Trans i18nKey="analysis-results:view" />
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer " </Button>
onClick={openAnalysisResults}
>
<Eye />
</button>
</span>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
) );
} }

View File

@@ -6,8 +6,8 @@ import { listRegions } from '@lib/data/regions';
import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product';
import type { StoreProduct } from '@medusajs/types'; import type { StoreProduct } from '@medusajs/types';
import { loadCurrentUserAccount } from './load-user-account'; import { loadCurrentUserAccount } from './load-user-account';
import { AnalysisPackageWithVariant } from '~/components/select-analysis-package';
import { AccountWithParams } from '@/packages/features/accounts/src/server/api'; import { AccountWithParams } from '@/packages/features/accounts/src/server/api';
import { AnalysisPackageWithVariant } from '@kit/shared/components/select-analysis-package';
async function countryCodesLoader() { async function countryCodesLoader() {
const countryCodes = await listRegions().then((regions) => const countryCodes = await listRegions().then((regions) =>
@@ -38,8 +38,11 @@ function userSpecificVariantLoader({
if (age >= 18 && age <= 29) { if (age >= 18 && age <= 29) {
return '18-29'; return '18-29';
} }
if (age >= 30 && age <= 49) { if (age >= 30 && age <= 39) {
return '30-49'; return '30-39';
}
if (age >= 40 && age <= 49) {
return '40-49';
} }
if (age >= 50 && age <= 59) { if (age >= 50 && age <= 59) {
return '50-59'; return '50-59';

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 { cache } from 'react';
import { createAccountsApi } from '@kit/accounts/api'; import { createAccountsApi } from '@kit/accounts/api';
import { UserAnalysis } from '@kit/accounts/types/accounts'; import { AnalysisResultDetails } from '@kit/accounts/types/accounts';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>; export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>;
@@ -9,14 +9,15 @@ export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>;
/** /**
* @name loadUserAnalysis * @name loadUserAnalysis
* @description * @description
* Load the user's analyses. It's a cached per-request function that fetches the user workspace data. * Load the user's analysis based on id. It's a cached per-request function that fetches the user's analysis data.
* It can be used across the server components to load the user workspace data.
*/ */
export const loadUserAnalysis = cache(analysisLoader); export const loadUserAnalysis = cache(analysisLoader);
async function analysisLoader(): Promise<UserAnalysis | null> { async function analysisLoader(
analysisOrderId: number,
): Promise<AnalysisResultDetails | null> {
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const api = createAccountsApi(client); const api = createAccountsApi(client);
return api.getUserAnalysis(); return api.getUserAnalysis(analysisOrderId);
} }

View File

@@ -41,3 +41,43 @@ export async function getAccountAdmin({
return data as unknown as AccountWithMemberships; return data as unknown as AccountWithMemberships;
} }
export async function getDoctorAccounts() {
const { data } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('accounts')
.select('id, email, name, last_name, preferred_locale')
.eq('is_personal_account', true)
.eq('application_role', 'doctor')
.throwOnError();
return data?.map(({ id, email, name, last_name, preferred_locale }) => ({
id,
email,
name,
lastName: last_name,
preferredLocale: preferred_locale,
}));
}
export async function getAssignedDoctorAccount(analysisOrderId: number) {
const { data: doctorUser } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('doctor_analysis_feedback')
.select('doctor_user_id')
.eq('analysis_order_id', analysisOrderId)
.throwOnError();
const doctorData = doctorUser[0];
if (!doctorData || !doctorData.doctor_user_id) {
return null;
}
const { data } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('accounts')
.select('email')
.eq('primary_owner_user_id', doctorData.doctor_user_id);
return { email: data?.[0]?.email };
}

View File

@@ -1,8 +1,10 @@
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
export enum NotificationAction { export enum NotificationAction {
DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED', DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED',
NEW_JOBS_ALERT = 'NEW_JOBS_ALERT',
PATIENT_RESULTS_RECEIVED_ALERT = 'PATIENT_RESULTS_RECEIVED_ALERT',
} }
export const createNotificationLog = async ({ export const createNotificationLog = async ({
@@ -17,7 +19,7 @@ export const createNotificationLog = async ({
relatedRecordId?: string | number; relatedRecordId?: string | number;
}) => { }) => {
try { try {
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerAdminClient();
await supabase await supabase
.schema('audit') .schema('audit')
@@ -30,6 +32,6 @@ export const createNotificationLog = async ({
}) })
.throwOnError(); .throwOnError();
} catch (error) { } catch (error) {
console.error('Failed to insert doctor page view log', error); console.error('Failed to insert doctor notification log', error);
} }
}; };

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'; 'use server';
import { CompanySubmitData } from '@/lib/types/company'; import { toArray } from '@/lib/utils';
import { emailSchema } from '@/lib/validations/email.schema';
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
import { getMailer } from '@kit/mailers'; import { getMailer } from '@kit/mailers';
import { enhanceAction } from '@kit/next/actions'; import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger'; import { getLogger } from '@kit/shared/logger';
export const sendDoctorSummaryCompletedEmail = async ( import { emailSchema } from '~/lib/validations/email.schema';
language: string,
recipientName: string,
recipientEmail: string,
orderNr: string,
orderId: number,
) => {
const { html, subject } = await renderDoctorSummaryReceivedEmail({
language,
recipientName,
recipientEmail,
orderNr,
orderId,
});
await sendEmail({ type EmailTemplate = {
subject, html: string;
html, subject: string;
to: recipientEmail,
});
}; };
export const sendCompanyOfferEmail = async ( type EmailRenderer<T = any> = (params: T) => Promise<EmailTemplate>;
data: CompanySubmitData,
language: string,
) => {
const { renderCompanyOfferEmail } = await import('@kit/email-templates');
const { html, subject } = await renderCompanyOfferEmail({
language,
companyData: data,
});
await sendEmail({ export const sendEmailFromTemplate = async <T>(
subject, renderer: EmailRenderer<T>,
html, templateParams: T,
to: process.env.CONTACT_EMAIL || '', recipients: string | string[],
}); ) => {
const { html, subject } = await renderer(templateParams);
const recipientsArray = toArray(recipients);
if (!recipientsArray.length) {
throw new Error('No valid email recipients provided');
}
const emailPromises = recipientsArray.map((email) =>
sendEmail({
subject,
html,
to: email,
}),
);
await Promise.all(emailPromises);
}; };
export const sendEmail = enhanceAction( export const sendEmail = enhanceAction(
@@ -53,7 +44,7 @@ export const sendEmail = enhanceAction(
const log = await getLogger(); const log = await getLogger();
if (!process.env.EMAIL_USER) { if (!process.env.EMAIL_USER) {
log.error('Sending email failed, as no sender found in env.') log.error('Sending email failed, as no sender was found in env.');
throw new Error('No email user configured'); throw new Error('No email user configured');
} }

View File

@@ -97,7 +97,7 @@ export async function getLatestPublicMessageListItem() {
Action: MedipostAction.GetPublicMessageList, Action: MedipostAction.GetPublicMessageList,
User: USER, User: USER,
Password: PASSWORD, Password: PASSWORD,
Sender: 'syndev', Sender: RECIPIENT,
// LastChecked (date+time) can be used here to get only messages since the last check - add when cron is created // LastChecked (date+time) can be used here to get only messages since the last check - add when cron is created
// MessageType check only for messages of certain type // MessageType check only for messages of certain type
}, },

View File

@@ -165,6 +165,20 @@ async function doctorMiddleware(request: NextRequest, response: NextResponse) {
*/ */
function getPatterns() { function getPatterns() {
return [ return [
{
pattern: new URLPattern({ pathname: '/' }),
handler: async (req: NextRequest, res: NextResponse) => {
const {
data: { user },
} = await getUser(req, res);
if (user) {
return NextResponse.redirect(
new URL(pathsConfig.app.home, req.nextUrl.origin).href,
);
}
},
},
{ {
pattern: new URLPattern({ pathname: '/admin/*?' }), pattern: new URLPattern({ pathname: '/admin/*?' }),
handler: adminMiddleware, handler: adminMiddleware,

View File

@@ -1,7 +1,20 @@
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import {
renderAllResultsReceivedEmail,
renderFirstResultsReceivedEmail,
} from '@kit/email-templates';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import {
getAssignedDoctorAccount,
getDoctorAccounts,
} from '../../../../../lib/services/account.service';
import {
NotificationAction,
createNotificationLog,
} from '../../../../../lib/services/audit/notificationEntries.service';
import { sendEmailFromTemplate } from '../../../../../lib/services/mailer.service';
import { RecordChange, Tables } from '../record-change.type'; import { RecordChange, Tables } from '../record-change.type';
export function createDatabaseWebhookRouterService( export function createDatabaseWebhookRouterService(
@@ -42,6 +55,12 @@ class DatabaseWebhookRouterService {
return this.handleAccountsWebhook(payload); return this.handleAccountsWebhook(payload);
} }
case 'analysis_orders': {
const payload = body as RecordChange<typeof body.table>;
return this.handleAnalysisOrdersWebhook(payload);
}
default: { default: {
return; return;
} }
@@ -83,4 +102,69 @@ class DatabaseWebhookRouterService {
return service.handleAccountDeletedWebhook(body.old_record); return service.handleAccountDeletedWebhook(body.old_record);
} }
} }
private async handleAnalysisOrdersWebhook(
body: RecordChange<'analysis_orders'>,
) {
if (body.type === 'UPDATE' && body.record && body.old_record) {
const { record, old_record } = body;
if (record.status === old_record.status) {
return;
}
let action;
try {
const data = {
analysisOrderId: record.id,
language: 'et',
};
if (record.status === 'PARTIAL_ANALYSIS_RESPONSE') {
action = NotificationAction.NEW_JOBS_ALERT;
const doctorAccounts = await getDoctorAccounts();
const doctorEmails: string[] = doctorAccounts
.map(({ email }) => email)
.filter((email): email is string => !!email);
await sendEmailFromTemplate(
renderFirstResultsReceivedEmail,
data,
doctorEmails,
);
} else if (record.status === 'FULL_ANALYSIS_RESPONSE') {
action = NotificationAction.PATIENT_RESULTS_RECEIVED_ALERT;
const doctorAccount = await getAssignedDoctorAccount(record.id);
const assignedDoctorEmail = doctorAccount?.email;
if (!assignedDoctorEmail) {
return;
}
await sendEmailFromTemplate(
renderAllResultsReceivedEmail,
data,
assignedDoctorEmail,
);
}
if (action) {
await createNotificationLog({
action,
status: 'SUCCESS',
relatedRecordId: record.id,
});
}
} catch (e: any) {
if (action)
await createNotificationLog({
action,
status: 'FAIL',
comment: e?.message,
relatedRecordId: record.id,
});
}
}
}
} }

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

@@ -19,16 +19,14 @@ import { initializeEmailI18n } from '../lib/i18n';
export async function renderDoctorSummaryReceivedEmail({ export async function renderDoctorSummaryReceivedEmail({
language, language,
recipientEmail,
recipientName, recipientName,
orderNr, orderNr,
orderId, analysisOrderId,
}: { }: {
language?: string; language?: string;
recipientName: string; recipientName: string;
recipientEmail: string;
orderNr: string; orderNr: string;
orderId: number; analysisOrderId: number;
}) { }) {
const namespace = 'doctor-summary-received-email'; const namespace = 'doctor-summary-received-email';
@@ -37,8 +35,6 @@ export async function renderDoctorSummaryReceivedEmail({
namespace: [namespace, 'common'], namespace: [namespace, 'common'],
}); });
const to = recipientEmail;
const previewText = t(`${namespace}:previewText`, { const previewText = t(`${namespace}:previewText`, {
orderNr, orderNr,
}); });
@@ -73,13 +69,13 @@ export async function renderDoctorSummaryReceivedEmail({
</Text> </Text>
<EmailButton <EmailButton
href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/order/${orderId}`} href={`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
> >
{t(`${namespace}:linkText`, { orderNr })} {t(`${namespace}:linkText`, { orderNr })}
</EmailButton> </EmailButton>
<Text> <Text>
{t(`${namespace}:ifButtonDisabled`)}{' '} {t(`${namespace}:ifButtonDisabled`)}{' '}
{`${process.env.NEXT_PUBLIC_SITE_URL}/home/order/${orderId}`} {`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`}
</Text> </Text>
<CommonFooter t={t} /> <CommonFooter t={t} />
</EmailContent> </EmailContent>
@@ -92,6 +88,5 @@ export async function renderDoctorSummaryReceivedEmail({
return { return {
html, html,
subject, subject,
to,
}; };
} }

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"> <Section className="mb-[16px] mt-[16px] text-center">
<Button className={'w-full rounded bg-neutral-950 text-center'}> <Button className={'w-full rounded bg-neutral-950 text-center'}>
<Text className="text-[16px] font-medium font-semibold leading-[16px] text-white"> <Text className="text-[16px] font-semibold leading-[16px] text-white">
{props.otp} {props.otp}
</Text> </Text>
</Button> </Button>

View File

@@ -9,12 +9,12 @@ import {
} from '@react-email/components'; } from '@react-email/components';
import { BodyStyle } from '../components/body-style'; import { BodyStyle } from '../components/body-style';
import CommonFooter from '../components/common-footer';
import { EmailContent } from '../components/content'; import { EmailContent } from '../components/content';
import { EmailHeader } from '../components/header'; import { EmailHeader } from '../components/header';
import { EmailHeading } from '../components/heading'; import { EmailHeading } from '../components/heading';
import { EmailWrapper } from '../components/wrapper'; import { EmailWrapper } from '../components/wrapper';
import { initializeEmailI18n } from '../lib/i18n'; import { initializeEmailI18n } from '../lib/i18n';
import CommonFooter from '../components/common-footer';
interface Props { interface Props {
analysisPackageName: string; analysisPackageName: string;
@@ -31,7 +31,10 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) {
namespace: [namespace, 'common'], namespace: [namespace, 'common'],
}); });
const previewText = t(`${namespace}:previewText`); const previewText = t(`${namespace}:previewText`, {
analysisPackageName: props.analysisPackageName,
});
const subject = t(`${namespace}:subject`, { const subject = t(`${namespace}:subject`, {
analysisPackageName: props.analysisPackageName, analysisPackageName: props.analysisPackageName,
}); });

View File

@@ -4,3 +4,6 @@ export * from './emails/otp.email';
export * from './emails/company-offer.email'; export * from './emails/company-offer.email';
export * from './emails/synlab.email'; export * from './emails/synlab.email';
export * from './emails/doctor-summary-received.email'; export * from './emails/doctor-summary-received.email';
export * from './emails/new-jobs-available.email';
export * from './emails/first-results-received.email';
export * from './emails/all-results-received.email';

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": { "footer": {
"lines1": "MedReport", "lines1": "MedReport",
"lines2": "E-mail: <a href=\"mailto:info@medreport.ee\">info@medreport.ee</a>", "lines2": "Электронная почта: <a href=\"mailto:info@medreport.ee\">info@medreport.ee</a>",
"lines3": "Klienditugi: <a href=\"tel:+37258871517\">+372 5887 1517</a>", "lines3": "Служба поддержки: <a href=\"tel:+37258871517\">+372 5887 1517</a>",
"lines4": "<a href=\"https://www.medreport.ee\">www.medreport.ee</a>" "lines4": "<a href=\"https://www.medreport.ee\">www.medreport.ee</a>"
} }
} }

View File

@@ -1,8 +1,8 @@
{ {
"subject": "Uus ettevõtte liitumispäring", "subject": "Новый запрос на присоединение компании",
"previewText": "Ettevõte {{companyName}} soovib pakkumist", "previewText": "Компания {{companyName}} запрашивает предложение",
"companyName": "Ettevõtte nimi:", "companyName": "Название компании:",
"contactPerson": "Kontaktisik:", "contactPerson": "Контактное лицо:",
"email": "E-mail:", "email": "Электронная почта:",
"phone": "Telefon:" "phone": "Телефон:"
} }

View File

@@ -1,8 +1,8 @@
{ {
"subject": "Saabus arsti kokkuvõtte tellimusele {{orderNr}}", "subject": "Получено заключение врача по заказу {{orderNr}}",
"previewText": "Arst on saatnud kokkuvõtte sinu analüüsitulemustele.", "previewText": "Врач отправил заключение по вашим результатам анализа.",
"hello": "Tere, {{displayName}}", "hello": "Здравствуйте, {{displayName}}",
"summaryReceivedForOrder": "Arst on koostanud selgitava kokkuvõtte sinu tellitud analüüsidele.", "summaryReceivedForOrder": "Врач подготовил пояснительное заключение по заказанным вами анализам.",
"linkText": "Vaata kokkuvõtet", "linkText": "Посмотреть заключение",
"ifButtonDisabled": "Kui nupule vajutamine ei toimi, kopeeri see link oma brauserisse:" "ifButtonDisabled": "Если кнопка не работает, скопируйте эту ссылку в ваш браузер:"
} }

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}}", "subject": "Ваш заказ Medreport подтвержден - {{analysisPackageName}}",
"previewText": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}", "previewText": "Ваш заказ Medreport подтвержден - {{analysisPackageName}}",
"heading": "Teie Medreport tellimus on kinnitatud - {{analysisPackageName}}", "heading": "Ваш заказ Medreport подтвержден - {{analysisPackageName}}",
"hello": "Tere {{personName}},", "hello": "Здравствуйте, {{personName}},",
"lines1": "Saatekiri {{analysisPackageName}} analüüsi uuringuteks on saadetud laborisse digitaalselt. Palun mine proove andma: Synlab - {{partnerLocationName}}", "lines1": "Направление на исследование {{analysisPackageName}} было отправлено в лабораторию в цифровом виде. Пожалуйста, сдайте анализы: Synlab - {{partnerLocationName}}",
"lines2": "<i>Kui Teil ei ole võimalik valitud asukohta minna proove andma, siis võite minna endale sobivasse proovivõtupunkti - <a href=\"https://medreport.ee/et/verevotupunktid\">vaata asukohti ja lahtiolekuaegasid</a>.</i>", "lines2": "<i>Если вы не можете посетить выбранный пункт сдачи анализов, вы можете обратиться в удобный для вас пункт - <a href=\"https://medreport.ee/et/verevotupunktid\">посмотреть адреса и часы работы</a>.</i>",
"lines3": "Soovituslik on proove anda pigem hommikul (enne 12:00) ning söömata ja joomata (vett võib juua).", "lines3": "Рекомендуется сдавать анализы утром (до 12:00) натощак (можно пить воду).",
"lines4": "Proovivõtupunktis valige järjekorrasüsteemis: <strong>saatekirjad</strong> alt <strong>eriarsti saatekiri</strong>.", "lines4": "В пункте сдачи анализов выберите в системе очереди: <strong>направления</strong> -> <strong>направление от специалиста</strong>.",
"lines5": "Juhul kui tekkis lisaküsimusi, siis võtke julgelt ühendust.", "lines5": "Если у вас возникнут дополнительные вопросы, смело свяжитесь с нами.",
"lines6": "SYNLAB klienditoe telefon: <a href=\"tel:+37217123\">17123</a>" "lines6": "Телефон службы поддержки SYNLAB: <a href=\"tel:+37217123\">17123</a>"
} }

View File

@@ -5,12 +5,16 @@ import { useSupabase } from '@kit/supabase/hooks/use-supabase';
type UpdateData = Database['medreport']['Tables']['accounts']['Update']; type UpdateData = Database['medreport']['Tables']['accounts']['Update'];
export function useUpdateAccountData(accountId: string) { export function useUpdateAccountData(accountId?: string) {
const client = useSupabase(); const client = useSupabase();
const mutationKey = ['account:data', accountId]; const mutationKey = ['account:data', accountId];
const mutationFn = async (data: UpdateData) => { const mutationFn = async (data: UpdateData) => {
if (!accountId) {
return null;
}
const response = await client const response = await client
.schema('medreport') .schema('medreport')
.from('accounts') .from('accounts')

View File

@@ -2,7 +2,11 @@ import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { UserAnalysis } from '../types/accounts'; import {
AnalysisResultDetails,
UserAnalysis,
UserAnalysisResponse,
} from '../types/accounts';
export type AccountWithParams = export type AccountWithParams =
Database['medreport']['Tables']['accounts']['Row'] & { Database['medreport']['Tables']['accounts']['Row'] & {
@@ -184,7 +188,49 @@ class AccountsApi {
return response.data?.customer_id; return response.data?.customer_id;
} }
async getUserAnalysis(): Promise<UserAnalysis | null> { async getUserAnalysis(
analysisOrderId: number,
): Promise<AnalysisResultDetails | null> {
const authUser = await this.client.auth.getUser();
const { data, error: userError } = authUser;
if (userError) {
console.error('Failed to get user', userError);
throw userError;
}
const { user } = data;
const { data: analysisResponse } = await this.client
.schema('medreport')
.from('analysis_responses')
.select(
`*,
elements:analysis_response_elements(analysis_name,norm_status,response_value,unit,norm_lower_included,norm_upper_included,norm_lower,norm_upper,response_time),
order:analysis_order_id(medusa_order_id, status, created_at),
summary:analysis_order_id(doctor_analysis_feedback(*))`,
)
.eq('user_id', user.id)
.eq('analysis_order_id', analysisOrderId)
.throwOnError();
const responseWithElements = analysisResponse?.[0];
if (!responseWithElements) {
return null;
}
const feedback = responseWithElements.summary.doctor_analysis_feedback?.[0];
return {
...responseWithElements,
summary:
feedback?.status === 'COMPLETED'
? responseWithElements.summary.doctor_analysis_feedback?.[0]
: null,
};
}
async getUserAnalyses(): Promise<UserAnalysis | null> {
const authUser = await this.client.auth.getUser(); const authUser = await this.client.auth.getUser();
const { data, error: userError } = authUser; const { data, error: userError } = authUser;

View File

@@ -1,3 +1,5 @@
import * as z from 'zod';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
export type UserAnalysisElement = export type UserAnalysisElement =
@@ -15,3 +17,51 @@ export enum ApplicationRoleEnum {
Doctor = 'doctor', Doctor = 'doctor',
SuperAdmin = 'super_admin', SuperAdmin = 'super_admin',
} }
export const ElementSchema = z.object({
unit: z.string(),
norm_lower: z.number(),
norm_upper: z.number(),
norm_status: z.number(),
analysis_name: z.string(),
response_time: z.string(),
response_value: z.number(),
norm_lower_included: z.boolean(),
norm_upper_included: z.boolean(),
});
export type Element = z.infer<typeof ElementSchema>;
export const OrderSchema = z.object({
status: z.string(),
medusa_order_id: z.string(),
created_at: z.coerce.date(),
});
export type Order = z.infer<typeof OrderSchema>;
export const SummarySchema = z.object({
id: z.number(),
value: z.string(),
status: z.string(),
user_id: z.string(),
created_at: z.coerce.date(),
created_by: z.string(),
updated_at: z.coerce.date().nullable(),
updated_by: z.string(),
doctor_user_id: z.string().nullable(),
analysis_order_id: z.number(),
});
export type Summary = z.infer<typeof SummarySchema>;
export const AnalysisResultDetailsSchema = z.object({
id: z.number(),
analysis_order_id: z.number(),
order_number: z.string(),
order_status: z.string(),
user_id: z.string(),
created_at: z.coerce.date(),
updated_at: z.coerce.date().nullable(),
elements: z.array(ElementSchema),
order: OrderSchema,
summary: SummarySchema.nullable(),
});
export type AnalysisResultDetails = z.infer<typeof AnalysisResultDetailsSchema>;

View File

@@ -7,7 +7,7 @@ export function AuthLayoutShell({
return ( return (
<div <div
className={ className={
'flex h-screen flex-col items-center justify-center' + 'sm:py-auto flex flex-col items-center justify-center py-6' +
' bg-background lg:bg-muted/30 gap-y-10 lg:gap-y-8' + ' bg-background lg:bg-muted/30 gap-y-10 lg:gap-y-8' +
' animate-in fade-in slide-in-from-top-16 zoom-in-95 duration-1000' ' animate-in fade-in slide-in-from-top-16 zoom-in-95 duration-1000'
} }

View File

@@ -41,6 +41,7 @@ export const PatientSchema = z.object({
email: z.string().nullable(), email: z.string().nullable(),
height: z.number().optional().nullable(), height: z.number().optional().nullable(),
weight: z.number().optional().nullable(), weight: z.number().optional().nullable(),
preferred_locale: z.string().nullable(),
}); });
export type Patient = z.infer<typeof PatientSchema>; export type Patient = z.infer<typeof PatientSchema>;

View File

@@ -80,6 +80,7 @@ export const AccountSchema = z.object({
last_name: z.string().nullable(), last_name: z.string().nullable(),
id: z.string(), id: z.string(),
primary_owner_user_id: z.string(), primary_owner_user_id: z.string(),
preferred_locale: z.string().nullable(),
}); });
export type Account = z.infer<typeof AccountSchema>; export type Account = z.infer<typeof AccountSchema>;

View File

@@ -2,10 +2,11 @@ import 'server-only';
import { isBefore } from 'date-fns'; import { isBefore } from 'date-fns';
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
import { getFullName } from '@kit/shared/utils'; import { getFullName } from '@kit/shared/utils';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { sendDoctorSummaryCompletedEmail } from '../../../../../../../lib/services/mailer.service'; import { sendEmailFromTemplate } from '../../../../../../../lib/services/mailer.service';
import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema'; import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema';
import { import {
AnalysisResponseBase, AnalysisResponseBase,
@@ -54,7 +55,7 @@ async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
supabase supabase
.schema('medreport') .schema('medreport')
.from('accounts') .from('accounts')
.select('name, last_name, id, primary_owner_user_id') .select('name, last_name, id, primary_owner_user_id, preferred_locale')
.in('primary_owner_user_id', userIds), .in('primary_owner_user_id', userIds),
]); ]);
@@ -67,7 +68,7 @@ async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
? await supabase ? await supabase
.schema('medreport') .schema('medreport')
.from('accounts') .from('accounts')
.select('name, last_name, id, primary_owner_user_id') .select('name, last_name, id, primary_owner_user_id, preferred_locale')
.in('primary_owner_user_id', doctorUserIds) .in('primary_owner_user_id', doctorUserIds)
: { data: [] }; : { data: [] };
@@ -408,7 +409,7 @@ export async function getAnalysisResultsForDoctor(
.schema('medreport') .schema('medreport')
.from('accounts') .from('accounts')
.select( .select(
`primary_owner_user_id, id, name, last_name, personal_code, phone, email, `primary_owner_user_id, id, name, last_name, personal_code, phone, email, preferred_locale,
account_params(height,weight)`, account_params(height,weight)`,
) )
.eq('is_personal_account', true) .eq('is_personal_account', true)
@@ -472,6 +473,7 @@ export async function getAnalysisResultsForDoctor(
personal_code, personal_code,
phone, phone,
account_params, account_params,
preferred_locale,
} = accountWithParams[0]; } = accountWithParams[0];
const analysisResponseElementsWithPreviousData = []; const analysisResponseElementsWithPreviousData = [];
@@ -503,6 +505,7 @@ export async function getAnalysisResultsForDoctor(
}, },
doctorFeedback: doctorFeedback?.[0], doctorFeedback: doctorFeedback?.[0],
patient: { patient: {
preferred_locale,
userId: primary_owner_user_id, userId: primary_owner_user_id,
accountId, accountId,
firstName: name, firstName: name,
@@ -638,7 +641,7 @@ export async function submitFeedback(
} }
if (status === 'COMPLETED') { if (status === 'COMPLETED') {
const [{ data: recipient }, { data: medusaOrderIds }] = await Promise.all([ const [{ data: recipient }, { data: analysisOrder }] = await Promise.all([
supabase supabase
.schema('medreport') .schema('medreport')
.from('accounts') .from('accounts')
@@ -653,24 +656,33 @@ export async function submitFeedback(
.eq('id', analysisOrderId) .eq('id', analysisOrderId)
.limit(1) .limit(1)
.throwOnError(), .throwOnError(),
supabase
.schema('medreport')
.from('analysis_orders')
.update({ status: 'COMPLETED' })
.eq('id', analysisOrderId)
.throwOnError(),
]); ]);
if (!recipient?.[0]?.email) { if (!recipient?.[0]?.email) {
throw new Error('Could not find user email.'); throw new Error('Could not find user email.');
} }
if (!medusaOrderIds?.[0]?.id) { if (!analysisOrder?.[0]?.id) {
throw new Error('Could not retrieve order.'); throw new Error('Could not retrieve order.');
} }
const { preferred_locale, name, last_name, email } = recipient[0]; const { preferred_locale, name, last_name, email } = recipient[0];
await sendDoctorSummaryCompletedEmail( await sendEmailFromTemplate(
preferred_locale ?? 'et', renderDoctorSummaryReceivedEmail,
getFullName(name, last_name), {
language: preferred_locale ?? 'et',
recipientName: getFullName(name, last_name),
orderNr: analysisOrder?.[0]?.medusa_order_id ?? '',
analysisOrderId: analysisOrder[0].id,
},
email, email,
medusaOrderIds?.[0]?.medusa_order_id ?? '',
medusaOrderIds[0].id,
); );
} }

View File

@@ -1,6 +1,8 @@
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import SelectAnalysisPackage, { AnalysisPackageWithVariant } from './select-analysis-package'; import SelectAnalysisPackage, {
AnalysisPackageWithVariant,
} from './select-analysis-package';
export default function SelectAnalysisPackages({ export default function SelectAnalysisPackages({
analysisPackages, analysisPackages,
@@ -10,11 +12,16 @@ export default function SelectAnalysisPackages({
countryCode: string; countryCode: string;
}) { }) {
return ( return (
<div className="grid grid-cols-3 gap-6"> <div className="grid gap-6 sm:grid-cols-3">
{analysisPackages.length > 0 ? analysisPackages.map( {analysisPackages.length > 0 ? (
(analysisPackage) => ( analysisPackages.map((analysisPackage) => (
<SelectAnalysisPackage key={analysisPackage.title} analysisPackage={analysisPackage} countryCode={countryCode} /> <SelectAnalysisPackage
)) : ( key={analysisPackage.title}
analysisPackage={analysisPackage}
countryCode={countryCode}
/>
))
) : (
<h4> <h4>
<Trans i18nKey="order-analysis-package:noPackagesAvailable" /> <Trans i18nKey="order-analysis-package:noPackagesAvailable" />
</h4> </h4>

View File

@@ -35,12 +35,6 @@ const routes = [
Icon: <ShoppingCart className={iconClasses} />, Icon: <ShoppingCart className={iconClasses} />,
end: true, end: true,
}, },
{
label: 'common:routes.analysisResults',
path: pathsConfig.app.analysisResults,
Icon: <TestTube2 className={iconClasses} />,
end: true,
},
{ {
label: 'common:routes.orderAnalysisPackage', label: 'common:routes.orderAnalysisPackage',
path: pathsConfig.app.orderAnalysisPackage, path: pathsConfig.app.orderAnalysisPackage,

View File

@@ -1 +1,2 @@
export * from './use-csrf-token'; 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

@@ -40,7 +40,7 @@ export function LanguageSelector({
}, [currentLanguage]); }, [currentLanguage]);
const userId = user?.id; const userId = user?.id;
const updateAccountMutation = useUpdateAccountData(userId!); const updateAccountMutation = useUpdateAccountData(userId);
const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery(); const revalidateUserDataQuery = useRevalidatePersonalAccountDataQuery();
const updateLanguagePreference = async ( const updateLanguagePreference = async (
@@ -52,6 +52,10 @@ export function LanguageSelector({
onChange(locale); onChange(locale);
} }
if (!userId) {
return i18n.changeLanguage(locale);
}
const promise = updateAccountMutation const promise = updateAccountMutation
.mutateAsync({ .mutateAsync({
preferred_locale: locale, preferred_locale: locale,

View File

@@ -1,5 +1,7 @@
import { cn } from '../../lib/utils';
import { LanguageSelector } from '@kit/ui/language-selector'; import { LanguageSelector } from '@kit/ui/language-selector';
import { cn } from '../../lib/utils';
interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> { interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {
logo?: React.ReactNode; logo?: React.ReactNode;
navigation?: React.ReactNode; navigation?: React.ReactNode;
@@ -22,11 +24,16 @@ export const Header: React.FC<HeaderProps> = function ({
{...props} {...props}
> >
<div className="container"> <div className="container">
<div className="grid h-14 grid-cols-3 items-center"> <div className="2xs:h-14 2xs:grid-cols-3 grid h-24 w-full items-center">
<div className={'mx-auto md:mx-0'}>{logo}</div> <div className={'mx-auto flex'}>{logo}</div>
<div className="order-first md:order-none">{navigation}</div> <div className="2xs:order-none order-first">{navigation}</div>
<div className="flex items-center justify-end gap-x-2"><div className="max-w-[100px]"><LanguageSelector /></div>{actions}</div> <div className="2xs:justify-end 2xs:gap-x-2 flex items-center justify-evenly">
<div className="max-w-[100px]">
<LanguageSelector />
</div>
{actions}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -42,7 +42,7 @@ function PageWithSidebar(props: PageProps) {
> >
{MobileNavigation} {MobileNavigation}
<div className={'bg-background flex flex-1 flex-col px-4 lg:px-0 pb-8'}> <div className={'bg-background flex flex-1 flex-col px-4 pb-8 lg:px-0'}>
{Children} {Children}
</div> </div>
</div> </div>
@@ -58,7 +58,7 @@ export function PageMobileNavigation(
return ( return (
<div <div
className={cn( className={cn(
'flex w-full items-center border-b px-4 py-2 lg:hidden lg:px-0', 'flex w-full items-center px-4 py-2 lg:hidden lg:px-0',
props.className, props.className,
)} )}
> >

View File

@@ -128,5 +128,6 @@
"updateRoleLoading": "Updating role...", "updateRoleLoading": "Updating role...",
"updatePreferredLocaleSuccess": "Language preference updated", "updatePreferredLocaleSuccess": "Language preference updated",
"updatePreferredLocaleError": "Language preference update failed", "updatePreferredLocaleError": "Language preference update failed",
"updatePreferredLocaleLoading": "Updating language preference..." "updatePreferredLocaleLoading": "Updating language preference...",
"doctorAnalysisSummary": "Doctor's summary"
} }

View File

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

View File

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

View File

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

View File

@@ -151,5 +151,6 @@
"updateRoleLoading": "Rolli uuendatakse...", "updateRoleLoading": "Rolli uuendatakse...",
"updatePreferredLocaleSuccess": "Eelistatud keel uuendatud", "updatePreferredLocaleSuccess": "Eelistatud keel uuendatud",
"updatePreferredLocaleError": "Eelistatud keele uuendamine ei õnnestunud", "updatePreferredLocaleError": "Eelistatud keele uuendamine ei õnnestunud",
"updatePreferredLocaleLoading": "Eelistatud keelt uuendatakse..." "updatePreferredLocaleLoading": "Eelistatud keelt uuendatakse...",
"doctorAnalysisSummary": "Arsti kokkuvõte analüüsitulemuste kohta"
} }

View File

@@ -12,5 +12,6 @@
"normal": "Normaalne vahemik" "normal": "Normaalne vahemik"
} }
}, },
"orderTitle": "Tellimus {{orderNumber}}" "orderTitle": "Tellimus {{orderNumber}}",
"view": "Vaata tulemusi"
} }

View File

@@ -136,5 +136,6 @@
"confirm": "Kinnita", "confirm": "Kinnita",
"previous": "Eelmine", "previous": "Eelmine",
"next": "Järgmine", "next": "Järgmine",
"invalidDataError": "Vigased andmed" "invalidDataError": "Vigased andmed",
"language": "Keel"
} }

View File

@@ -17,6 +17,7 @@
"assignedTo": "Arst", "assignedTo": "Arst",
"resultsStatus": "Analüüsitulemused", "resultsStatus": "Analüüsitulemused",
"waitingForNr": "Ootel {{nr}}", "waitingForNr": "Ootel {{nr}}",
"language": "Patsiendi keel",
"responsesReceived": "Tulemused koos" "responsesReceived": "Tulemused koos"
}, },
"otherPatients": "Muud patsiendid", "otherPatients": "Muud patsiendid",

View File

@@ -1,132 +1,155 @@
{ {
"accountTabLabel": "Account Settings", "accountTabLabel": "Настройки аккаунта",
"accountTabDescription": "Manage your account settings", "accountTabDescription": "Управляйте настройками вашего аккаунта",
"homePage": "Home", "homePage": "Главная",
"billingTab": "Billing", "billingTab": "Оплата",
"settingsTab": "Settings", "settingsTab": "Настройки",
"multiFactorAuth": "Multi-Factor Authentication", "multiFactorAuth": "Многофакторная аутентификация",
"multiFactorAuthDescription": "Set up Multi-Factor Authentication method to further secure your account", "multiFactorAuthDescription": "Настройте метод многофакторной аутентификации для дополнительной защиты вашего аккаунта",
"updateProfileSuccess": "Profile successfully updated", "updateProfileSuccess": "Профиль успешно обновлен",
"updateProfileError": "Encountered an error. Please try again", "updateProfileError": "Произошла ошибка. Пожалуйста, попробуйте снова",
"updatePasswordSuccess": "Password update request successful", "updatePasswordSuccess": "Запрос на обновление пароля выполнен успешно",
"updatePasswordSuccessMessage": "Your password has been successfully updated!", "updatePasswordSuccessMessage": "Ваш пароль был успешно обновлен!",
"updatePasswordError": "Encountered an error. Please try again", "updatePasswordError": "Произошла ошибка. Пожалуйста, попробуйте снова",
"updatePasswordLoading": "Updating password...", "updatePasswordLoading": "Обновление пароля...",
"updateProfileLoading": "Updating profile...", "updateProfileLoading": "Обновление профиля...",
"name": "Your Name", "name": "Ваше имя",
"nameDescription": "Update your name to be displayed on your profile", "nameDescription": "Обновите ваше имя, которое будет отображаться в профиле",
"emailLabel": "Email Address", "emailLabel": "Адрес электронной почты",
"accountImage": "Your Profile Picture", "accountImage": "Ваша фотография профиля",
"accountImageDescription": "Please choose a photo to upload as your profile picture.", "accountImageDescription": "Выберите фото для загрузки в качестве изображения профиля.",
"profilePictureHeading": "Upload a Profile Picture", "profilePictureHeading": "Загрузить фотографию профиля",
"profilePictureSubheading": "Choose a photo to upload as your profile picture.", "profilePictureSubheading": "Выберите фото для загрузки в качестве изображения профиля.",
"updateProfileSubmitLabel": "Update Profile", "updateProfileSubmitLabel": "Обновить профиль",
"updatePasswordCardTitle": "Update your Password", "updatePasswordCardTitle": "Обновите ваш пароль",
"updatePasswordCardDescription": "Update your password to keep your account secure.", "updatePasswordCardDescription": "Обновите пароль, чтобы сохранить ваш аккаунт в безопасности.",
"currentPassword": "Current Password", "currentPassword": "Текущий пароль",
"newPassword": "New Password", "newPassword": "Новый пароль",
"repeatPassword": "Repeat New Password", "repeatPassword": "Повторите новый пароль",
"repeatPasswordDescription": "Please repeat your new password to confirm it", "repeatPasswordDescription": "Пожалуйста, повторите новый пароль для подтверждения",
"yourPassword": "Your Password", "yourPassword": "Ваш пароль",
"updatePasswordSubmitLabel": "Update Password", "updatePasswordSubmitLabel": "Обновить пароль",
"updateEmailCardTitle": "Update your Email", "updateEmailCardTitle": "Обновите вашу почту",
"updateEmailCardDescription": "Update your email address you use to login to your account", "updateEmailCardDescription": "Обновите адрес электронной почты, который вы используете для входа в аккаунт",
"newEmail": "Your New Email", "newEmail": "Новый адрес электронной почты",
"repeatEmail": "Repeat Email", "repeatEmail": "Повторите адрес электронной почты",
"updateEmailSubmitLabel": "Update Email Address", "updateEmailSubmitLabel": "Обновить адрес электронной почты",
"updateEmailSuccess": "Email update request successful", "updateEmailSuccess": "Запрос на обновление почты выполнен успешно",
"updateEmailSuccessMessage": "We sent you an email to confirm your new email address. Please check your inbox and click on the link to confirm your new email address.", "updateEmailSuccessMessage": "Мы отправили вам письмо для подтверждения нового адреса. Пожалуйста, проверьте почту и перейдите по ссылке для подтверждения.",
"updateEmailLoading": "Updating your email...", "updateEmailLoading": "Обновление почты...",
"updateEmailError": "Email not updated. Please try again", "updateEmailError": "Почта не обновлена. Пожалуйста, попробуйте снова",
"passwordNotMatching": "Passwords do not match. Make sure you're using the correct password", "passwordNotMatching": "Пароли не совпадают. Убедитесь, что вы используете правильный пароль",
"emailNotMatching": "Emails do not match. Make sure you're using the correct email", "emailNotMatching": "Адреса электронной почты не совпадают. Убедитесь, что вы используете правильный адрес",
"passwordNotChanged": "Your password has not changed", "passwordNotChanged": "Ваш пароль не был изменен",
"emailsNotMatching": "Emails do not match. Make sure you're using the correct email", "emailsNotMatching": "Адреса электронной почты не совпадают. Убедитесь, что вы используете правильный адрес",
"cannotUpdatePassword": "You cannot update your password because your account is not linked to any.", "cannotUpdatePassword": "Вы не можете обновить пароль, так как ваш аккаунт не связан ни с одним.",
"setupMfaButtonLabel": "Setup a new Factor", "setupMfaButtonLabel": "Настроить новый фактор",
"multiFactorSetupErrorHeading": "Setup Failed", "multiFactorSetupErrorHeading": "Сбой настройки",
"multiFactorSetupErrorDescription": "Sorry, there was an error while setting up your factor. Please try again.", "multiFactorSetupErrorDescription": "Извините, произошла ошибка при настройке фактора. Пожалуйста, попробуйте снова.",
"multiFactorAuthHeading": "Secure your account with Multi-Factor Authentication", "multiFactorAuthHeading": "Защитите ваш аккаунт с помощью многофакторной аутентификации",
"multiFactorModalHeading": "Use your authenticator app to scan the QR code below. Then enter the code generated.", "multiFactorModalHeading": "Используйте приложение-аутентификатор для сканирования QR-кода ниже, затем введите сгенерированный код.",
"factorNameLabel": "A memorable name to identify this factor", "factorNameLabel": "Запоминающееся имя для идентификации этого фактора",
"factorNameHint": "Use an easy-to-remember name to easily identify this factor in the future. Ex. iPhone 14", "factorNameHint": "Используйте простое для запоминания имя, чтобы легко идентифицировать этот фактор в будущем. Пример: iPhone 14",
"factorNameSubmitLabel": "Set factor name", "factorNameSubmitLabel": "Задать имя фактора",
"unenrollTooltip": "Unenroll this factor", "unenrollTooltip": "Удалить фактор",
"unenrollingFactor": "Unenrolling factor...", "unenrollingFactor": "Удаление фактора...",
"unenrollFactorSuccess": "Factor successfully unenrolled", "unenrollFactorSuccess": "Фактор успешно удален",
"unenrollFactorError": "Unenrolling factor failed", "unenrollFactorError": "Не удалось удалить фактор",
"factorsListError": "Error loading factors list", "factorsListError": "Ошибка загрузки списка факторов",
"factorsListErrorDescription": "Sorry, we couldn't load the factors list. Please try again.", "factorsListErrorDescription": "Извините, не удалось загрузить список факторов. Пожалуйста, попробуйте снова.",
"factorName": "Factor Name", "factorName": "Имя фактора",
"factorType": "Type", "factorType": "Тип",
"factorStatus": "Status", "factorStatus": "Статус",
"mfaEnabledSuccessTitle": "Multi-Factor authentication is enabled", "mfaEnabledSuccessTitle": "Многофакторная аутентификация включена",
"mfaEnabledSuccessDescription": "Congratulations! You have successfully enrolled in the multi factor authentication process. You will now be able to access your account with a combination of your password and an authentication code sent to your phone number.", "mfaEnabledSuccessDescription": "Поздравляем! Вы успешно подключили многофакторную аутентификацию. Теперь вы сможете входить в аккаунт с помощью комбинации пароля и кода подтверждения, отправленного на ваш номер телефона.",
"verificationCode": "Verification Code", "verificationCode": "Код подтверждения",
"addEmailAddress": "Add Email address", "addEmailAddress": "Добавить адрес электронной почты",
"verifyActivationCodeDescription": "Enter the 6-digit code generated by your authenticator app in the field above", "verifyActivationCodeDescription": "Введите 6-значный код, сгенерированный вашим приложением-аутентификатором, в поле выше",
"loadingFactors": "Loading factors...", "loadingFactors": "Загрузка факторов...",
"enableMfaFactor": "Enable Factor", "enableMfaFactor": "Включить фактор",
"disableMfaFactor": "Disable Factor", "disableMfaFactor": "Отключить фактор",
"qrCodeErrorHeading": "QR Code Error", "qrCodeErrorHeading": "Ошибка QR-кода",
"qrCodeErrorDescription": "Sorry, we weren't able to generate the QR code", "qrCodeErrorDescription": "Извините, не удалось сгенерировать QR-код",
"multiFactorSetupSuccess": "Factor successfully enrolled", "multiFactorSetupSuccess": "Фактор успешно подключен",
"submitVerificationCode": "Submit Verification Code", "submitVerificationCode": "Отправить код подтверждения",
"mfaEnabledSuccessAlert": "Multi-Factor authentication is enabled", "mfaEnabledSuccessAlert": "Многофакторная аутентификация включена",
"verifyingCode": "Verifying code...", "verifyingCode": "Проверка кода...",
"invalidVerificationCodeHeading": "Invalid Verification Code", "invalidVerificationCodeHeading": "Неверный код подтверждения",
"invalidVerificationCodeDescription": "The verification code you entered is invalid. Please try again.", "invalidVerificationCodeDescription": "Введенный вами код неверен. Пожалуйста, попробуйте снова.",
"unenrollFactorModalHeading": "Unenroll Factor", "unenrollFactorModalHeading": "Удаление фактора",
"unenrollFactorModalDescription": "You're about to unenroll this factor. You will not be able to use it to login to your account.", "unenrollFactorModalDescription": "Вы собираетесь удалить этот фактор. Вы больше не сможете использовать его для входа в аккаунт.",
"unenrollFactorModalBody": "You're about to unenroll this factor. You will not be able to use it to login to your account.", "unenrollFactorModalBody": "Вы собираетесь удалить этот фактор. Вы больше не сможете использовать его для входа в аккаунт.",
"unenrollFactorModalButtonLabel": "Yes, unenroll factor", "unenrollFactorModalButtonLabel": "Да, удалить фактор",
"selectFactor": "Choose a factor to verify your identity", "selectFactor": "Выберите фактор для подтверждения личности",
"disableMfa": "Disable Multi-Factor Authentication", "disableMfa": "Отключить многофакторную аутентификацию",
"disableMfaButtonLabel": "Disable MFA", "disableMfaButtonLabel": "Отключить MFA",
"confirmDisableMfaButtonLabel": "Yes, disable MFA", "confirmDisableMfaButtonLabel": "Да, отключить MFA",
"disablingMfa": "Disabling Multi-Factor Authentication. Please wait...", "disablingMfa": "Отключение многофакторной аутентификации. Пожалуйста, подождите...",
"disableMfaSuccess": "Multi-Factor Authentication successfully disabled", "disableMfaSuccess": "Многофакторная аутентификация успешно отключена",
"disableMfaError": "Sorry, we encountered an error. MFA has not been disabled.", "disableMfaError": "Извините, произошла ошибка. MFA не была отключена.",
"sendingEmailVerificationLink": "Sending Email...", "sendingEmailVerificationLink": "Отправка письма...",
"sendEmailVerificationLinkSuccess": "Verification link successfully sent", "sendEmailVerificationLinkSuccess": "Ссылка для подтверждения успешно отправлена",
"sendEmailVerificationLinkError": "Sorry, we weren't able to send you the email", "sendEmailVerificationLinkError": "Извините, не удалось отправить письмо",
"sendVerificationLinkSubmitLabel": "Send Verification Link", "sendVerificationLinkSubmitLabel": "Отправить ссылку для подтверждения",
"sendVerificationLinkSuccessLabel": "Email sent! Check your Inbox", "sendVerificationLinkSuccessLabel": "Письмо отправлено! Проверьте почту",
"verifyEmailAlertHeading": "Please verify your email to enable MFA", "verifyEmailAlertHeading": "Пожалуйста, подтвердите вашу почту, чтобы включить MFA",
"verificationLinkAlertDescription": "Your email is not yet verified. Please verify your email to be able to set up Multi-Factor Authentication.", "verificationLinkAlertDescription": "Ваша почта еще не подтверждена. Пожалуйста, подтвердите ее, чтобы настроить многофакторную аутентификацию.",
"authFactorName": "Factor Name (optional)", "authFactorName": "Имя фактора (необязательно)",
"authFactorNameHint": "Assign a name that helps you remember the phone number used", "authFactorNameHint": "Присвойте имя, которое поможет вам запомнить номер телефона, используемый для входа",
"loadingUser": "Loading user details. Please wait...", "loadingUser": "Загрузка данных пользователя. Пожалуйста, подождите...",
"linkPhoneNumber": "Link Phone Number", "linkPhoneNumber": "Привязать номер телефона",
"dangerZone": "Danger Zone", "dangerZone": "Опасная зона",
"dangerZoneDescription": "Some actions cannot be undone. Please be careful.", "dangerZoneDescription": "Некоторые действия нельзя отменить. Будьте осторожны.",
"deleteAccount": "Delete your Account", "deleteAccount": "Удалить аккаунт",
"deletingAccount": "Deleting account. Please wait...", "deletingAccount": "Удаление аккаунта. Пожалуйста, подождите...",
"deleteAccountDescription": "This will delete your account and the accounts you own. Furthermore, we will immediately cancel any active subscriptions. This action cannot be undone.", "deleteAccountDescription": "Это удалит ваш аккаунт и связанные с ним учетные записи. Также мы немедленно отменим все активные подписки. Это действие нельзя отменить.",
"deleteProfileConfirmationInputLabel": "Type DELETE to confirm", "deleteProfileConfirmationInputLabel": "Введите DELETE для подтверждения",
"deleteAccountErrorHeading": "Sorry, we couldn't delete your account", "deleteAccountErrorHeading": "Извините, не удалось удалить аккаунт",
"needsReauthentication": "Reauthentication Required", "needsReauthentication": "Требуется повторная аутентификация",
"needsReauthenticationDescription": "You need to reauthenticate to change your password. Please sign out and sign in again to change your password.", "needsReauthenticationDescription": "Необходимо повторно войти в систему, чтобы изменить пароль. Пожалуйста, выйдите и войдите снова.",
"language": "Language", "language": "Язык",
"languageDescription": "Choose your preferred language", "languageDescription": "Выберите предпочитаемый язык",
"noTeamsYet": "You don't have any teams yet.", "noTeamsYet": "У вас пока нет команд.",
"createTeam": "Create a team to get started.", "createTeam": "Создайте команду, чтобы начать.",
"createTeamButtonLabel": "Create a Team", "createTeamButtonLabel": "Создать команду",
"createCompanyAccount": "Create Company Account", "createCompanyAccount": "Создать аккаунт компании",
"updateConsentSuccess": "Consent successfully updated", "requestCompanyAccount": {
"updateConsentError": "Encountered an error. Please try again", "title": "Данные компании",
"updateConsentLoading": "Updating consent...", "description": "Чтобы получить предложение, пожалуйста, введите данные компании, с которой вы планируете использовать MedReport.",
"button": "Запросить предложение",
"successTitle": "Запрос успешно отправлен!",
"successDescription": "Мы ответим вам при первой возможности",
"successButton": "Вернуться на главную"
},
"updateAccount": {
"title": "Личные данные",
"description": "Пожалуйста, введите свои личные данные для продолжения",
"button": "Продолжить",
"userConsentLabel": "Я согласен на использование моих персональных данных на платформе",
"userConsentUrlTitle": "Посмотреть политику обработки персональных данных"
},
"consentModal": {
"title": "Прежде чем начать",
"description": "Вы даете согласие на использование ваших медицинских данных в анонимной форме для статистики работодателя? Данные будут обезличены и помогут компании лучше поддерживать здоровье сотрудников.",
"reject": "Не даю согласие",
"accept": "Да, даю согласие"
},
"updateConsentSuccess": "Согласия обновлены",
"updateConsentError": "Что-то пошло не так. Пожалуйста, попробуйте снова",
"updateConsentLoading": "Обновление согласий...",
"consentToAnonymizedCompanyData": { "consentToAnonymizedCompanyData": {
"label": "Consent to be included in employer statistics", "label": "Согласен участвовать в статистике работодателя",
"description": "Consent to be included in anonymized company statistics" "description": "Я согласен на использование моих медицинских данных в анонимной форме для статистики работодателя"
}, },
"analysisResults": { "membershipConfirmation": {
"pageTitle": "My analysis results" "successTitle": "Здравствуйте, {{firstName}} {{lastName}}",
"successDescription": "Ваш медицинский аккаунт активирован и готов к использованию!",
"successButton": "Продолжить"
}, },
"updateRoleSuccess": "Role updated", "updateRoleSuccess": "Роль обновлена",
"updateRoleError": "Something went wrong, please try again", "updateRoleError": "Что-то пошло не так. Пожалуйста, попробуйте снова",
"updateRoleLoading": "Updating role...", "updateRoleLoading": "Обновление роли...",
"updatePreferredLocaleSuccess": "Language preference updated", "updatePreferredLocaleSuccess": "Предпочитаемый язык обновлен",
"updatePreferredLocaleError": "Language preference update failed", "updatePreferredLocaleError": "Не удалось обновить предпочитаемый язык",
"updatePreferredLocaleLoading": "Updating language preference..." "updatePreferredLocaleLoading": "Обновление предпочитаемого языка..."
} }

View File

@@ -0,0 +1,16 @@
{
"pageTitle": "Мои результаты анализов",
"description": "Все результаты анализов появляются в течение 1-3 рабочих дней после их сдачи.",
"descriptionEmpty": "Если вы уже сдали анализы, то вскоре здесь появятся ваши результаты.",
"orderNewAnalysis": "Заказать новые анализы",
"waitingForResults": "Ожидание результатов",
"noAnalysisElements": "Анализы еще не заказаны",
"noAnalysisOrders": "Пока нет заказов на анализы",
"analysisDate": "Дата результата анализа",
"results": {
"range": {
"normal": "Нормальный диапазон"
}
},
"orderTitle": "Заказ {{orderNumber}}"
}

View File

@@ -1,90 +1,90 @@
{ {
"signUpHeading": "Create an account", "signUpHeading": "Создать аккаунт",
"signUp": "Sign Up", "signUp": "Зарегистрироваться",
"signUpSubheading": "Fill the form below to create an account.", "signUpSubheading": "Заполните форму ниже, чтобы создать аккаунт.",
"signInHeading": "Sign in to your account", "signInHeading": "Войдите в свой аккаунт",
"signInSubheading": "Welcome back! Please enter your details", "signInSubheading": "С возвращением! Пожалуйста, введите свои данные",
"signIn": "Sign In", "signIn": "Войти",
"getStarted": "Get started", "getStarted": "Начать",
"updatePassword": "Update Password", "updatePassword": "Обновить пароль",
"signOut": "Sign out", "signOut": "Выйти",
"signingIn": "Signing in...", "signingIn": "Вход...",
"signingUp": "Signing up...", "signingUp": "Регистрация...",
"doNotHaveAccountYet": "Do not have an account yet?", "doNotHaveAccountYet": "Еще нет аккаунта?",
"alreadyHaveAnAccount": "Already have an account?", "alreadyHaveAnAccount": "Уже есть аккаунт?",
"signUpToAcceptInvite": "Please sign in/up to accept the invite", "signUpToAcceptInvite": "Пожалуйста, войдите или зарегистрируйтесь, чтобы принять приглашение",
"clickToAcceptAs": "Click the button below to accept the invite with as <b>{{email}}</b>", "clickToAcceptAs": "Нажмите кнопку ниже, чтобы принять приглашение как <b>{{email}}</b>",
"acceptInvite": "Accept invite", "acceptInvite": "Принять приглашение",
"acceptingInvite": "Accepting Invite...", "acceptingInvite": "Принятие приглашения...",
"acceptInviteSuccess": "Invite successfully accepted", "acceptInviteSuccess": "Приглашение успешно принято",
"acceptInviteError": "Error encountered while accepting invite", "acceptInviteError": "Произошла ошибка при принятии приглашения",
"acceptInviteWithDifferentAccount": "Want to accept the invite with a different account?", "acceptInviteWithDifferentAccount": "Хотите принять приглашение с другим аккаунтом?",
"alreadyHaveAccountStatement": "I already have an account, I want to sign in instead", "alreadyHaveAccountStatement": "У меня уже есть аккаунт, я хочу войти",
"doNotHaveAccountStatement": "I do not have an account, I want to sign up instead", "doNotHaveAccountStatement": "У меня нет аккаунта, я хочу зарегистрироваться",
"signInWithProvider": "Sign in with {{provider}}", "signInWithProvider": "Войти через {{provider}}",
"signInWithPhoneNumber": "Sign in with Phone Number", "signInWithPhoneNumber": "Войти по номеру телефона",
"signInWithEmail": "Sign in with Email", "signInWithEmail": "Войти по Email",
"signUpWithEmail": "Sign up with Email", "signUpWithEmail": "Зарегистрироваться по Email",
"passwordHint": "Ensure it's at least 8 characters", "passwordHint": "Убедитесь, что пароль содержит не менее 8 символов",
"repeatPasswordHint": "Type your password again", "repeatPasswordHint": "Введите пароль еще раз",
"repeatPassword": "Repeat password", "repeatPassword": "Повторите пароль",
"passwordForgottenQuestion": "Password forgotten?", "passwordForgottenQuestion": "Забыли пароль?",
"passwordResetLabel": "Reset Password", "passwordResetLabel": "Сбросить пароль",
"passwordResetSubheading": "Enter your email address below. You will receive a link to reset your password.", "passwordResetSubheading": "Введите ваш email ниже. Вы получите ссылку для сброса пароля.",
"passwordResetSuccessMessage": "Check your Inbox! We emailed you a link for resetting your Password.", "passwordResetSuccessMessage": "Проверьте почту! Мы отправили вам ссылку для сброса пароля.",
"passwordRecoveredQuestion": "Password recovered?", "passwordRecoveredQuestion": "Восстановили пароль?",
"passwordLengthError": "Please provide a password with at least 6 characters", "passwordLengthError": "Пожалуйста, введите пароль длиной не менее 6 символов",
"sendEmailLink": "Send Email Link", "sendEmailLink": "Отправить ссылку на почту",
"sendingEmailLink": "Sending Email Link...", "sendingEmailLink": "Отправка ссылки...",
"sendLinkSuccessDescription": "Check your email, we just sent you a link. Follow the link to sign in.", "sendLinkSuccessDescription": "Проверьте вашу почту, мы только что отправили вам ссылку. Перейдите по ней, чтобы войти.",
"sendLinkSuccess": "We sent you a link by email", "sendLinkSuccess": "Мы отправили вам ссылку по электронной почте",
"sendLinkSuccessToast": "Link successfully sent", "sendLinkSuccessToast": "Ссылка успешно отправлена",
"getNewLink": "Get a new link", "getNewLink": "Получить новую ссылку",
"verifyCodeHeading": "Verify your account", "verifyCodeHeading": "Подтвердите ваш аккаунт",
"verificationCode": "Verification Code", "verificationCode": "Код подтверждения",
"verificationCodeHint": "Enter the code we sent you by SMS", "verificationCodeHint": "Введите код, который мы отправили вам по SMS",
"verificationCodeSubmitButtonLabel": "Submit Verification Code", "verificationCodeSubmitButtonLabel": "Отправить код",
"sendingMfaCode": "Sending Verification Code...", "sendingMfaCode": "Отправка кода подтверждения...",
"verifyingMfaCode": "Verifying code...", "verifyingMfaCode": "Проверка кода...",
"sendMfaCodeError": "Sorry, we couldn't send you a verification code", "sendMfaCodeError": "Извините, не удалось отправить код подтверждения",
"verifyMfaCodeSuccess": "Code verified! Signing you in...", "verifyMfaCodeSuccess": "Код подтвержден! Входим в систему...",
"verifyMfaCodeError": "Ops! It looks like the code is not correct", "verifyMfaCodeError": "Упс! Похоже, код неверный",
"reauthenticate": "Reauthenticate", "reauthenticate": "Повторная аутентификация",
"reauthenticateDescription": "For security reasons, we need you to re-authenticate", "reauthenticateDescription": "В целях безопасности вам нужно пройти повторную аутентификацию",
"errorAlertHeading": "Sorry, we could not authenticate you", "errorAlertHeading": "Извините, не удалось вас аутентифицировать",
"emailConfirmationAlertHeading": "We sent you a confirmation email.", "emailConfirmationAlertHeading": "Мы отправили вам письмо для подтверждения.",
"emailConfirmationAlertBody": "Welcome! Please check your email and click the link to verify your account.", "emailConfirmationAlertBody": "Добро пожаловать! Пожалуйста, проверьте вашу почту и перейдите по ссылке, чтобы подтвердить аккаунт.",
"resendLink": "Resend link", "resendLink": "Отправить ссылку снова",
"resendLinkSuccessDescription": "We sent you a new link to your email! Follow the link to sign in.", "resendLinkSuccessDescription": "Мы отправили новую ссылку на вашу почту! Перейдите по ней, чтобы войти.",
"resendLinkSuccess": "Check your email!", "resendLinkSuccess": "Проверьте вашу почту!",
"authenticationErrorAlertHeading": "Authentication Error", "authenticationErrorAlertHeading": "Ошибка аутентификации",
"authenticationErrorAlertBody": "Sorry, we could not authenticate you. Please try again.", "authenticationErrorAlertBody": "Извините, не удалось вас аутентифицировать. Пожалуйста, попробуйте снова.",
"sendEmailCode": "Get code to your Email", "sendEmailCode": "Получить код на Email",
"sendingEmailCode": "Sending code...", "sendingEmailCode": "Отправка кода...",
"resetPasswordError": "Sorry, we could not reset your password. Please try again.", "resetPasswordError": "Извините, не удалось сбросить пароль. Пожалуйста, попробуйте снова.",
"emailPlaceholder": "your@email.com", "emailPlaceholder": "your@email.com",
"inviteAlertHeading": "You have been invited to join a company", "inviteAlertHeading": "Вас пригласили присоединиться к компании",
"inviteAlertBody": "Please sign in or sign up to accept the invite and join the company.", "inviteAlertBody": "Пожалуйста, войдите или зарегистрируйтесь, чтобы принять приглашение и присоединиться к компании.",
"acceptTermsAndConditions": "I accept the <TermsOfServiceLink /> and <PrivacyPolicyLink />", "acceptTermsAndConditions": "Я принимаю <TermsOfServiceLink /> и <PrivacyPolicyLink />",
"termsOfService": "Terms of Service", "termsOfService": "Условия использования",
"privacyPolicy": "Privacy Policy", "privacyPolicy": "Политика конфиденциальности",
"orContinueWith": "Or continue with", "orContinueWith": "Или продолжить через",
"redirecting": "You're in! Please wait...", "redirecting": "Вы вошли! Пожалуйста, подождите...",
"errors": { "errors": {
"Invalid login credentials": "The credentials entered are invalid", "Invalid login credentials": "Введенные данные недействительны",
"User already registered": "This credential is already in use. Please try with another one.", "User already registered": "Эти данные уже используются. Пожалуйста, попробуйте другие.",
"Email not confirmed": "Please confirm your email address before signing in", "Email not confirmed": "Пожалуйста, подтвердите ваш email перед входом",
"default": "We have encountered an error. Please ensure you have a working internet connection and try again", "default": "Произошла ошибка. Убедитесь, что у вас есть подключение к интернету, и попробуйте снова",
"generic": "Sorry, we weren't able to authenticate you. Please try again.", "generic": "Извините, не удалось вас аутентифицировать. Пожалуйста, попробуйте снова.",
"link": "Sorry, we encountered an error while sending your link. Please try again.", "link": "Извините, произошла ошибка при отправке ссылки. Пожалуйста, попробуйте снова.",
"codeVerifierMismatch": "It looks like you're trying to sign in using a different browser than the one you used to request the sign in link. Please try again using the same browser.", "codeVerifierMismatch": "Похоже, вы пытаетесь войти через другой браузер, чем тот, в котором запрашивали ссылку для входа. Пожалуйста, используйте тот же браузер.",
"minPasswordLength": "Password must be at least 8 characters long", "minPasswordLength": "Пароль должен содержать не менее 8 символов",
"passwordsDoNotMatch": "The passwords do not match", "passwordsDoNotMatch": "Пароли не совпадают",
"minPasswordNumbers": "Password must contain at least one number", "minPasswordNumbers": "Пароль должен содержать хотя бы одну цифру",
"minPasswordSpecialChars": "Password must contain at least one special character", "minPasswordSpecialChars": "Пароль должен содержать хотя бы один специальный символ",
"uppercasePassword": "Password must contain at least one uppercase letter", "uppercasePassword": "Пароль должен содержать хотя бы одну заглавную букву",
"insufficient_aal": "Please sign-in with your current multi-factor authentication to perform this action", "insufficient_aal": "Пожалуйста, войдите с текущей многофакторной аутентификацией, чтобы выполнить это действие",
"otp_expired": "The email link has expired. Please try again.", "otp_expired": "Срок действия ссылки на email истек. Пожалуйста, попробуйте снова.",
"same_password": "The password cannot be the same as the current password" "same_password": "Пароль не может совпадать с текущим паролем"
} }
} }

View File

@@ -1,123 +1,143 @@
{ {
"subscriptionTabSubheading": "Manage your Subscription and Billing", "subscriptionTabSubheading": "Управление подпиской и оплатой",
"planCardTitle": "Your Plan", "planCardTitle": "Ваш тариф",
"planCardDescription": "Below are the details of your current plan. You can change your plan or cancel your subscription at any time.", "planCardDescription": "Ниже приведены детали вашего текущего тарифа. Вы можете изменить тариф или отменить подписку в любое время.",
"planRenewal": "Renews every {{interval}} at {{price}}", "planRenewal": "Продлевается каждые {{interval}} за {{price}}",
"planDetails": "Plan Details", "planDetails": "Детали тарифа",
"checkout": "Proceed to Checkout", "checkout": "Перейти к оплате",
"trialEndsOn": "Your trial ends on", "trialEndsOn": "Пробный период заканчивается",
"billingPortalCardButton": "Visit Billing Portal", "billingPortalCardButton": "Перейти в биллинг-портал",
"billingPortalCardTitle": "Manage your Billing Details", "billingPortalCardTitle": "Управление платежными данными",
"billingPortalCardDescription": "Visit your Billing Portal to manage your subscription and billing. You can update or cancel your plan, or download your invoices.", "billingPortalCardDescription": "Перейдите в биллинг-портал, чтобы управлять подпиской и платежами. Вы можете изменить или отменить тариф, а также скачать счета.",
"cancelAtPeriodEndDescription": "Your subscription is scheduled to be canceled on {{- endDate }}.", "cancelAtPeriodEndDescription": "Ваша подписка будет отменена {{- endDate }}.",
"renewAtPeriodEndDescription": "Your subscription is scheduled to be renewed on {{- endDate }}", "renewAtPeriodEndDescription": "Ваша подписка будет продлена {{- endDate }}",
"noPermissionsAlertHeading": "You don't have permissions to change the billing settings", "noPermissionsAlertHeading": "У вас нет прав для изменения настроек оплаты",
"noPermissionsAlertBody": "Please contact your account admin to change the billing settings for your account.", "noPermissionsAlertBody": "Пожалуйста, свяжитесь с администратором аккаунта, чтобы изменить настройки оплаты.",
"checkoutSuccessTitle": "Done! You're all set.", "checkoutSuccessTitle": "Готово! Всё настроено.",
"checkoutSuccessDescription": "Thank you for subscribing, we have successfully processed your subscription! A confirmation email will be sent to {{ customerEmail }}.", "checkoutSuccessDescription": "Спасибо за подписку! Мы успешно оформили вашу подписку. Подтверждение отправлено на {{ customerEmail }}.",
"checkoutSuccessBackButton": "Proceed to App", "checkoutSuccessBackButton": "Перейти в приложение",
"cannotManageBillingAlertTitle": "You cannot manage billing", "cannotManageBillingAlertTitle": "Вы не можете управлять оплатой",
"cannotManageBillingAlertDescription": "You do not have permissions to manage billing. Please contact your account admin.", "cannotManageBillingAlertDescription": "У вас нет прав для управления оплатой. Пожалуйста, свяжитесь с администратором аккаунта.",
"manageTeamPlan": "Manage your Company Plan", "manageTeamPlan": "Управление корпоративным тарифом",
"manageTeamPlanDescription": "Choose a plan that fits your company's needs. You can upgrade or downgrade your plan at any time.", "manageTeamPlanDescription": "Выберите тариф, который соответствует потребностям вашей компании. Вы можете изменить его в любое время.",
"basePlan": "Base Plan", "basePlan": "Базовый тариф",
"billingInterval": { "billingInterval": {
"label": "Choose your billing interval", "label": "Выберите интервал оплаты",
"month": "Billed monthly", "month": "Оплата ежемесячно",
"year": "Billed yearly" "year": "Оплата ежегодно"
}, },
"perMonth": "month", "perMonth": "месяц",
"custom": "Custom Plan", "custom": "Индивидуальный тариф",
"lifetime": "Lifetime", "lifetime": "Пожизненный",
"trialPeriod": "{{period}} day trial", "trialPeriod": "Пробный период {{period}} дней",
"perPeriod": "per {{period}}", "perPeriod": "за {{period}}",
"redirectingToPayment": "Redirecting to checkout. Please wait...", "redirectingToPayment": "Перенаправление на оплату. Пожалуйста, подождите...",
"proceedToPayment": "Proceed to Payment", "proceedToPayment": "Перейти к оплате",
"startTrial": "Start Trial", "startTrial": "Начать пробный период",
"perTeamMember": "Per company member", "perTeamMember": "На одного сотрудника",
"perUnit": "Per {{unit}} usage", "perUnit": "За использование {{unit}}",
"teamMembers": "Company Members", "teamMembers": "Сотрудники компании",
"includedUpTo": "Up to {{upTo}} {{unit}} included in the plan", "includedUpTo": "До {{upTo}} {{unit}} включено в тариф",
"fromPreviousTierUpTo": "for each {{unit}} for the next {{ upTo }} {{ unit }}", "fromPreviousTierUpTo": "за каждый {{unit}} для следующих {{ upTo }} {{ unit }}",
"andAbove": "above {{ previousTier }} {{ unit }}", "andAbove": "свыше {{ previousTier }} {{ unit }}",
"startingAtPriceUnit": "Starting at {{price}}/{{unit}}", "startingAtPriceUnit": "Начиная с {{price}}/{{unit}}",
"priceUnit": "{{price}}/{{unit}}", "priceUnit": "{{price}}/{{unit}}",
"forEveryUnit": "for every {{ unit }}", "forEveryUnit": "за каждый {{ unit }}",
"setupFee": "plus a {{ setupFee }} setup fee", "setupFee": "плюс плата за установку {{ setupFee }}",
"perUnitIncluded": "({{included}} included)", "perUnitIncluded": "({{included}} включено)",
"featuresLabel": "Features", "featuresLabel": "Функции",
"detailsLabel": "Details", "detailsLabel": "Подробности",
"planPickerLabel": "Pick your preferred plan", "planPickerLabel": "Выберите тариф",
"planCardLabel": "Manage your Plan", "planCardLabel": "Управление тарифом",
"planPickerAlertErrorTitle": "Error requesting checkout", "planPickerAlertErrorTitle": "Ошибка при запросе оплаты",
"planPickerAlertErrorDescription": "There was an error requesting checkout. Please try again later.", "planPickerAlertErrorDescription": "Произошла ошибка при запросе оплаты. Пожалуйста, попробуйте позже.",
"subscriptionCancelled": "Subscription Cancelled", "subscriptionCancelled": "Подписка отменена",
"cancelSubscriptionDate": "Your subscription will be cancelled at the end of the period", "cancelSubscriptionDate": "Ваша подписка будет отменена в конце расчетного периода",
"noPlanChosen": "Please choose a plan", "noPlanChosen": "Пожалуйста, выберите тариф",
"noIntervalPlanChosen": "Please choose a billing interval", "noIntervalPlanChosen": "Пожалуйста, выберите интервал оплаты",
"status": { "status": {
"free": { "free": {
"badge": "Free Plan", "badge": "Бесплатный тариф",
"heading": "You are currently on the Free Plan", "heading": "Вы используете бесплатный тариф",
"description": "You're on a free plan. You can upgrade to a paid plan at any time." "description": "Вы на бесплатном тарифе. Вы можете перейти на платный тариф в любое время."
}, },
"active": { "active": {
"badge": "Active", "badge": "Активен",
"heading": "Your subscription is active", "heading": "Ваша подписка активна",
"description": "Your subscription is active. You can manage your subscription and billing in the Customer Portal." "description": "Ваша подписка активна. Вы можете управлять подпиской и оплатой в клиентском портале."
}, },
"trialing": { "trialing": {
"badge": "Trial", "badge": "Пробный период",
"heading": "You're on a trial", "heading": "Вы используете пробный период",
"description": "You can enjoy the benefits of plan until the trial ends" "description": "Вы можете пользоваться тарифом до окончания пробного периода"
}, },
"past_due": { "past_due": {
"badge": "Past Due", "badge": "Просрочено",
"heading": "Your invoice is past due", "heading": "Ваш счет просрочен",
"description": "Your invoice is past due. Please update your payment method." "description": "Ваш счет просрочен. Пожалуйста, обновите способ оплаты."
}, },
"canceled": { "canceled": {
"badge": "Canceled", "badge": "Отменено",
"heading": "Your subscription is canceled", "heading": "Ваша подписка отменена",
"description": "Your subscription is canceled. It is scheduled to end at end of the billing period." "description": "Ваша подписка отменена и завершится в конце расчетного периода."
}, },
"unpaid": { "unpaid": {
"badge": "Unpaid", "badge": "Неоплачено",
"heading": "Your invoice is unpaid", "heading": "Ваш счет не оплачен",
"description": "Your invoice is unpaid. Please update your payment method." "description": "Ваш счет не оплачен. Пожалуйста, обновите способ оплаты."
}, },
"incomplete": { "incomplete": {
"badge": "Incomplete", "badge": "Не завершено",
"heading": "We're waiting for your payment", "heading": "Мы ждем вашу оплату",
"description": "We're waiting for your payment to go through. Please bear with us." "description": "Мы ждем завершения вашей оплаты. Пожалуйста, подождите."
}, },
"incomplete_expired": { "incomplete_expired": {
"badge": "Expired", "badge": "Истек",
"heading": "Your payment has expired", "heading": "Срок оплаты истек",
"description": "Your payment has expired. Please update your payment method." "description": "Срок оплаты истек. Пожалуйста, обновите способ оплаты."
}, },
"paused": { "paused": {
"badge": "Paused", "badge": "Приостановлено",
"heading": "Your subscription is paused", "heading": "Ваша подписка приостановлена",
"description": "Your subscription is paused. You can resume it at any time." "description": "Ваша подписка приостановлена. Вы можете возобновить ее в любое время."
}, },
"succeeded": { "succeeded": {
"badge": "Succeeded", "badge": "Успешно",
"heading": "Your payment was successful", "heading": "Оплата прошла успешно",
"description": "Your payment was successful. Thank you for subscribing!" "description": "Оплата прошла успешно. Спасибо за подписку!"
}, },
"pending": { "pending": {
"badge": "Pending", "badge": "В ожидании",
"heading": "Your payment is pending", "heading": "Оплата в ожидании",
"description": "Your payment is pending. Please bear with us." "description": "Оплата находится в ожидании. Пожалуйста, подождите."
}, },
"failed": { "failed": {
"badge": "Failed", "badge": "Неудачно",
"heading": "Your payment failed", "heading": "Оплата не прошла",
"description": "Your payment failed. Please update your payment method." "description": "Оплата не прошла. Пожалуйста, обновите способ оплаты."
} }
}, },
"cart": { "cart": {
"label": "Cart ({{ items }})" "label": "Корзина ({{ items }})"
},
"pageTitle": "Бюджет {{companyName}}",
"description": "Выберите подходящую дату в календаре и запишитесь на прием.",
"saveChanges": "Сохранить изменения",
"healthBenefitForm": {
"title": "Форма здоровья",
"description": "Поддержка сотрудника из корпоративного фонда здоровья",
"info": "* К ценам добавляются государственные налоги"
},
"occurance": {
"yearly": "Раз в год",
"quarterly": "Раз в квартал",
"monthly": "Раз в месяц"
},
"expensesOverview": {
"title": "Обзор расходов за 2025 год",
"monthly": "Расход на одного сотрудника в месяц *",
"yearly": "Максимальный расход на одного человека в год *",
"total": "Максимальный расход на {{employeeCount}} сотрудников в год *",
"sum": "Итого"
} }
} }

View File

@@ -1,8 +1,9 @@
{ {
"title": "Select service", "title": "Выберите услугу",
"description": "Select the appropriate service or package according to your health needs or goals.", "description": "Выберите подходящую услугу или пакет в зависимости от вашей проблемы со здоровьем или цели.",
"analysisPackages": { "analysisPackages": {
"title": "Analysis packages", "title": "Пакеты анализов",
"description": "Get to know the personal analysis packages and order" "description": "Ознакомьтесь с персональными пакетами анализов и закажите"
} },
"noCategories": "Список услуг не найден, попробуйте позже"
} }

View File

@@ -0,0 +1,75 @@
{
"title": "Корзина",
"description": "Просмотрите свою корзину",
"emptyCartMessage": "Ваша корзина пуста",
"emptyCartMessageDescription": "Добавьте товары в корзину, чтобы продолжить.",
"subtotal": "Промежуточный итог",
"total": "Сумма",
"table": {
"item": "Товар",
"quantity": "Количество",
"price": "Цена",
"total": "Сумма"
},
"checkout": {
"goToCheckout": "Оформить заказ",
"goToDashboard": "Продолжить",
"error": {
"title": "Что-то пошло не так",
"description": "Пожалуйста, попробуйте позже."
},
"timeLeft": "Осталось времени {{timeLeft}}",
"timeoutTitle": "Бронирование истекло",
"timeoutDescription": "Бронирование товара {{productTitle}} в корзине истекло.",
"timeoutAction": "Продолжить"
},
"discountCode": {
"title": "Подарочная карта или промокод",
"label": "Добавить промокод",
"apply": "Применить",
"subtitle": "Если хотите, можете добавить промокод",
"placeholder": "Введите промокод"
},
"items": {
"synlabAnalyses": {
"productColumnLabel": "Название анализа"
},
"ttoServices": {
"productColumnLabel": "Название услуги"
},
"delete": {
"success": "Товар удален из корзины",
"loading": "Удаление товара из корзины",
"error": "Не удалось удалить товар из корзины"
},
"analysisLocation": {
"success": "Местоположение обновлено",
"loading": "Обновление местоположения",
"error": "Не удалось обновить местоположение"
}
},
"order": {
"title": "Заказ"
},
"orderConfirmed": {
"title": "Заказ успешно оформлен",
"summary": "Услуги",
"subtotal": "Промежуточный итог",
"taxes": "Налоги",
"giftCard": "Подарочная карта",
"total": "Сумма",
"orderDate": "Дата заказа",
"orderNumber": "Номер заказа",
"orderStatus": "Статус заказа",
"paymentStatus": "Статус оплаты"
},
"montonioCallback": {
"title": "Процесс оплаты Montonio",
"description": "Пожалуйста, подождите, пока мы завершим обработку вашего платежа."
},
"locations": {
"title": "Местоположение для сдачи анализов",
"description": "Если у вас нет возможности прийти в выбранное место для сдачи анализов, вы можете посетить любой удобный для вас пункт забора крови.",
"locationSelect": "Выберите местоположение"
}
}

View File

@@ -1,118 +1,140 @@
{ {
"homeTabLabel": "Home", "homeTabLabel": "Главная",
"homeTabDescription": "Welcome to your home page", "homeTabDescription": "Добро пожаловать на вашу домашнюю страницу",
"accountMembers": "Company Members", "accountMembers": "Члены компании",
"membersTabDescription": "Here you can manage the members of your company.", "membersTabDescription": "Здесь вы можете управлять членами вашей компании.",
"billingTabLabel": "Billing", "billingTabLabel": "Оплата",
"billingTabDescription": "Manage your billing and subscription", "billingTabDescription": "Управление оплатой и подпиской",
"dashboardTabLabel": "Dashboard", "dashboardTabLabel": "Панель",
"settingsTabLabel": "Settings", "settingsTabLabel": "Настройки",
"profileSettingsTabLabel": "Profile", "profileSettingsTabLabel": "Профиль",
"subscriptionSettingsTabLabel": "Subscription", "subscriptionSettingsTabLabel": "Подписка",
"dashboardTabDescription": "An overview of your account's activity and performance across all your projects.", "dashboardTabDescription": "Обзор активности и эффективности вашей учетной записи по всем проектам.",
"settingsTabDescription": "Manage your settings and preferences.", "settingsTabDescription": "Управляйте своими настройками и предпочтениями.",
"emailAddress": "Email Address", "emailAddress": "Электронная почта",
"password": "Password", "password": "Пароль",
"modalConfirmationQuestion": "Are you sure you want to continue?", "modalConfirmationQuestion": "Вы уверены, что хотите продолжить?",
"imageInputLabel": "Click here to upload an image", "imageInputLabel": "Нажмите здесь, чтобы загрузить изображение",
"cancel": "Cancel", "cancel": "Отмена",
"clear": "Clear", "clear": "Очистить",
"close": "Close", "close": "Закрыть",
"notFound": "Not Found", "notFound": "Не найдено",
"backToHomePage": "Back to Home Page", "backToHomePage": "Вернуться на главную",
"goBack": "Go Back", "goBack": "Назад",
"genericServerError": "Sorry, something went wrong.", "genericServerError": "Извините, что-то пошло не так.",
"genericServerErrorHeading": "Sorry, something went wrong while processing your request. Please contact us if the issue persists.", "genericServerErrorHeading": "Извините, произошла ошибка при обработке вашего запроса. Пожалуйста, свяжитесь с нами, если проблема не исчезнет.",
"pageNotFound": "Sorry, this page does not exist.", "pageNotFound": "Извините, эта страница не существует.",
"pageNotFoundSubHeading": "Apologies, the page you were looking for was not found", "pageNotFoundSubHeading": "К сожалению, запрашиваемая страница не найдена",
"genericError": "Sorry, something went wrong.", "genericError": "Извините, что-то пошло не так.",
"genericErrorSubHeading": "Apologies, an error occurred while processing your request. Please contact us if the issue persists.", "genericErrorSubHeading": "К сожалению, произошла ошибка при обработке вашего запроса. Пожалуйста, свяжитесь с нами, если проблема не исчезнет.",
"anonymousUser": "Anonymous", "anonymousUser": "Анонимный пользователь",
"tryAgain": "Try Again", "tryAgain": "Попробовать снова",
"theme": "Theme", "theme": "Тема",
"lightTheme": "Light", "lightTheme": "Светлая",
"darkTheme": "Dark", "darkTheme": "Тёмная",
"systemTheme": "System", "systemTheme": "Системная",
"expandSidebar": "Expand Sidebar", "expandSidebar": "Развернуть боковое меню",
"collapseSidebar": "Collapse Sidebar", "collapseSidebar": "Свернуть боковое меню",
"documentation": "Documentation", "documentation": "Документация",
"getStarted": "Get Started", "getStarted": "Начать!",
"getStartedWithPlan": "Get Started with {{plan}}", "getStartedWithPlan": "Начать с {{plan}}",
"retry": "Retry", "retry": "Повторить",
"contactUs": "Contact Us", "contactUs": "Свяжитесь с нами",
"loading": "Loading. Please wait...", "loading": "Загрузка. Пожалуйста, подождите...",
"yourAccounts": "Your Accounts", "yourAccounts": "Ваши аккаунты",
"continue": "Continue", "continue": "Продолжить",
"skip": "Skip", "skip": "Пропустить",
"signedInAs": "Signed in as", "signedInAs": "Вы вошли как",
"pageOfPages": "Page {{page}} of {{total}}", "pageOfPages": "Страница {{page}} / {{total}}",
"noData": "No data available", "noData": "Нет данных",
"pageNotFoundHeading": "Ouch! :|", "pageNotFoundHeading": "Упс! :|",
"errorPageHeading": "Ouch! :|", "errorPageHeading": "Упс! :|",
"notifications": "Notifications", "notifications": "Уведомления",
"noNotifications": "No notifications", "noNotifications": "Нет уведомлений",
"justNow": "Just now", "justNow": "Прямо сейчас",
"newVersionAvailable": "New version available", "newVersionAvailable": "Доступна новая версия",
"newVersionAvailableDescription": "A new version of the app is available. It is recommended to refresh the page to get the latest updates and avoid any issues.", "newVersionAvailableDescription": "Доступна новая версия приложения. Рекомендуется обновить страницу, чтобы получить последние обновления и избежать возможных проблем.",
"newVersionSubmitButton": "Reload and Update", "newVersionSubmitButton": "Перезагрузить и обновить",
"back": "Back", "back": "Назад",
"welcome": "Welcome", "welcome": "Добро пожаловать",
"shoppingCart": "Shopping cart", "shoppingCart": "Корзина",
"search": "Search{{end}}", "shoppingCartCount": "Корзина ({{count}})",
"myActions": "My actions", "search": "Поиск{{end}}",
"myActions": "Мои действия",
"healthPackageComparison": { "healthPackageComparison": {
"label": "Health package comparison", "label": "Сравнение пакетов здоровья",
"description": "Alljärgnevalt on antud eelinfo (sugu, vanus ja kehamassiindeksi) põhjal tehtud personalne terviseauditi valik. Tabelis on võimalik soovitatud terviseuuringute paketile lisada üksikuid uuringuid juurde." "description": "Ниже приведен персональный выбор пакета медицинского обследования на основе предварительной информации (пол, возраст и индекс массы тела). В таблице можно добавить к рекомендуемому пакету отдельные исследования."
}, },
"routes": { "routes": {
"home": "Home", "home": "Главная",
"overview": "Overview", "overview": "Обзор",
"booking": "Booking", "booking": "Забронировать время",
"myOrders": "My orders", "myOrders": "Мои заказы",
"orderAnalysis": "Order analysis", "analysisResults": "Результаты анализов",
"orderAnalysisPackage": "Order analysis package", "orderAnalysisPackage": "Заказать пакет анализов",
"orderHealthAnalysis": "Order health analysis", "orderAnalysis": "Заказать анализ",
"account": "Account", "orderHealthAnalysis": "Заказать обследование",
"members": "Members", "account": "Аккаунт",
"billing": "Billing", "members": "Участники",
"dashboard": "Dashboard", "billing": "Оплата",
"settings": "Settings", "dashboard": "Обзор",
"profile": "Profile", "settings": "Настройки",
"application": "Application" "profile": "Профиль",
"application": "Приложение",
"pickTime": "Выберите время"
}, },
"roles": { "roles": {
"owner": { "owner": {
"label": "Admin" "label": "Администратор"
}, },
"member": { "member": {
"label": "Member" "label": "Участник"
} }
}, },
"otp": { "otp": {
"requestVerificationCode": "Request Verification Code", "requestVerificationCode": "Запросить код подтверждения",
"requestVerificationCodeDescription": "We must verify your identity to continue with this action. We'll send a verification code to the email address {{email}}.", "requestVerificationCodeDescription": "Мы должны подтвердить вашу личность для продолжения. Мы отправим код подтверждения на электронный адрес {{email}}.",
"sendingCode": "Sending Code...", "sendingCode": "Отправка кода...",
"sendVerificationCode": "Send Verification Code", "sendVerificationCode": "Отправить код подтверждения",
"enterVerificationCode": "Enter Verification Code", "enterVerificationCode": "Введите код подтверждения",
"codeSentToEmail": "We've sent a verification code to the email address {{email}}.", "codeSentToEmail": "Мы отправили код подтверждения на электронный адрес {{email}}.",
"verificationCode": "Verification Code", "verificationCode": "Код подтверждения",
"enterCodeFromEmail": "Enter the 6-digit code we sent to your email.", "enterCodeFromEmail": "Введите 6-значный код, который мы отправили на вашу почту.",
"verifying": "Verifying...", "verifying": "Проверка...",
"verifyCode": "Verify Code", "verifyCode": "Подтвердить код",
"requestNewCode": "Request New Code", "requestNewCode": "Запросить новый код",
"errorSendingCode": "Error sending code. Please try again." "errorSendingCode": "Ошибка при отправке кода. Пожалуйста, попробуйте снова."
}, },
"cookieBanner": { "cookieBanner": {
"title": "Hey, we use cookies 🍪", "title": "Привет, мы используем куки 🍪",
"description": "This website uses cookies to ensure you get the best experience on our website.", "description": "Этот сайт использует файлы cookie, чтобы обеспечить вам наилучший опыт.",
"reject": "Reject", "reject": "Отклонить",
"accept": "Accept" "accept": "Принять"
}, },
"doctor": "Doctor", "formField": {
"save": "Save", "companyName": "Название компании",
"saveAsDraft": "Save as draft", "contactPerson": "Контактное лицо",
"confirm": "Confirm", "email": "Электронная почта",
"previous": "Previous", "phone": "Телефон",
"next": "Next", "firstName": "Имя",
"invalidDataError": "Invalid data submitted" "lastName": "Фамилия",
"personalCode": "Личный код",
"city": "Город",
"weight": "Вес",
"height": "Рост",
"occurance": "Частота поддержки",
"amount": "Сумма",
"selectDate": "Выберите дату"
},
"wallet": {
"balance": "Баланс вашего MedReport аккаунта",
"expiredAt": "Действительно до {{expiredAt}}"
},
"doctor": "Врач",
"save": "Сохранить",
"saveAsDraft": "Сохранить как черновик",
"confirm": "Подтвердить",
"previous": "Предыдущий",
"next": "Следующий",
"invalidDataError": "Некорректные данные"
} }

View File

@@ -1,16 +1,22 @@
{ {
"recentlyCheckedDescription": "Super, oled käinud tervist kontrollimas. Siin on sinule olulised näitajad", "recentlyCheckedDescription": "Отлично, вы проверили своё здоровье. Вот важные показатели для вас",
"respondToQuestion": "Respond", "respondToQuestion": "Ответить на вопрос",
"gender": "Gender", "gender": "Пол",
"male": "Male", "male": "Мужчина",
"female": "Female", "female": "Женщина",
"age": "Age", "age": "Возраст",
"height": "Height", "height": "Рост",
"weight": "Weight", "weight": "Вес",
"bmi": "BMI", "bmi": "ИМТ",
"bloodPressure": "Blood pressure", "bloodPressure": "Давление",
"cholesterol": "Cholesterol", "cholesterol": "Холестерин",
"ldlCholesterol": "LDL Cholesterol", "ldlCholesterol": "ЛПНП холестерин",
"smoking": "Smoking", "smoking": "Курение",
"recommendedForYou": "Recommended for you" "recommendedForYou": "Рекомендации для вас",
"heroCard": {
"orderAnalysis": {
"title": "Заказать анализ",
"description": "Закажите подходящий для вас анализ"
}
}
} }

View File

@@ -1,50 +1,51 @@
{ {
"sidebar": { "sidebar": {
"dashboard": "Overview", "dashboard": "Обзор",
"openReviews": "Open jobs", "openReviews": "Свободные задания",
"myReviews": "My jobs", "myReviews": "Мои задания",
"completedReviews": "Completed jobs" "completedReviews": "Выполненные задания"
}, },
"openReviews": "Open jobs", "openReviews": "Свободные задания",
"myReviews": "My jobs", "myReviews": "Мои задания",
"completedReviews": "Completed jobs", "completedReviews": "Выполненные задания",
"otherReviews": "Other jobs", "otherReviews": "Другие задания",
"resultsTable": { "resultsTable": {
"patientName": "Patient name", "patientName": "Имя пациента",
"serviceName": "Service", "serviceName": "Услуга",
"orderNr": "Order number", "orderNr": "Номер заказа",
"time": "Time", "time": "Время",
"assignedTo": "Doctor", "assignedTo": "Врач",
"resultsStatus": "Analysis results", "resultsStatus": "Результаты анализов",
"waitingForNr": "Waiting for {{nr}}", "waitingForNr": "В ожидании {{nr}}",
"responsesReceived": "Results complete" "language": "Предпочтительный язык",
"responsesReceived": "Результаты готовы"
}, },
"otherPatients": "Other patients", "otherPatients": "Другие пациенты",
"analyses": "Analyses", "analyses": "Анализы",
"open": "Open", "open": "Открыть",
"name": "Name", "name": "Имя",
"personalCode": "Personal code", "personalCode": "Личный код",
"dobAndAge": "Date of birth and age", "dobAndAge": "Дата рождения и возраст",
"bmi": "Body mass index", "bmi": "Индекс массы тела",
"smoking": "Smoking", "smoking": "Курение",
"phone": "Phone", "phone": "Телефон",
"email": "Email", "email": "Эл. почта",
"results": "Analysis results", "results": "Результаты анализов",
"feedback": "Summary", "feedback": "Заключение",
"selectJob": "Select", "selectJob": "Выбрать",
"unselectJob": "Unselect", "unselectJob": "Отказаться",
"previousResults": "Previous results ({{date}})", "previousResults": "Предыдущие результаты ({{date}})",
"labComment": "Lab comment", "labComment": "Комментарии лаборатории",
"confirmFeedbackModal": { "confirmFeedbackModal": {
"title": "Confirm publishing summary", "title": "Подтвердите публикацию заключения",
"description": "When confirmed, the summary will be published to the patient." "description": "После подтверждения заключение будет опубликовано для пациента."
}, },
"error": { "error": {
"UNKNOWN": "Something went wrong", "UNKNOWN": "Что-то пошло не так",
"JOB_ASSIGNED": "Job already selected" "JOB_ASSIGNED": "Задание уже занято"
}, },
"updateFeedbackSuccess": "Summary updated", "updateFeedbackSuccess": "Заключение обновлено",
"updateFeedbackLoading": "Updating summary...", "updateFeedbackLoading": "Обновление заключения...",
"updateFeedbackError": "Failed to update summary", "updateFeedbackError": "Не удалось обновить заключение",
"feedbackLengthError": "Summary must be at least 10 characters" "feedbackLengthError": "Заключение должно содержать не менее 10 символов"
} }

View File

@@ -1,41 +1,41 @@
{ {
"blog": "Blog", "blog": "Блог",
"blogSubtitle": "News and updates about the platform", "blogSubtitle": "Новости и обновления о платформе",
"documentation": "Documentation", "documentation": "Документация",
"documentationSubtitle": "Tutorials and guide to get started with the platform", "documentationSubtitle": "Учебники и руководство по началу работы с платформой",
"faq": "FAQ", "faq": "FAQ",
"faqSubtitle": "Frequently asked questions about the platform", "faqSubtitle": "Часто задаваемые вопросы о платформе",
"pricing": "Pricing", "pricing": "Цены",
"pricingSubtitle": "Pricing plans and payment options", "pricingSubtitle": "Тарифные планы и варианты оплаты",
"backToBlog": "Back to blog", "backToBlog": "Назад к блогу",
"noPosts": "No posts found", "noPosts": "Посты не найдены",
"blogPaginationNext": "Next Page", "blogPaginationNext": "Следующая страница",
"blogPaginationPrevious": "Previous Page", "blogPaginationPrevious": "Предыдущая страница",
"readMore": "Read more", "readMore": "Читать далее",
"contactFaq": "If you have any questions, please contact us", "contactFaq": "Если у вас есть вопросы, пожалуйста, свяжитесь с нами",
"contact": "Contact", "contact": "Контакты",
"about": "About", "about": "О нас",
"product": "Product", "product": "Продукт",
"legal": "Legal", "legal": "Правовая информация",
"termsOfService": "Terms of Service", "termsOfService": "Условия обслуживания",
"termsOfServiceDescription": "Our terms and conditions", "termsOfServiceDescription": "Наши правила и условия",
"cookiePolicy": "Cookie Policy", "cookiePolicy": "Политика использования файлов cookie",
"cookiePolicyDescription": "Our cookie policy and how we use them", "cookiePolicyDescription": "Наша политика использования cookie и как мы их применяем",
"privacyPolicy": "Privacy Policy", "privacyPolicy": "Политика конфиденциальности",
"privacyPolicyDescription": "Our privacy policy and how we use your data", "privacyPolicyDescription": "Наша политика конфиденциальности и как мы используем ваши данные",
"contactDescription": "Contact us for any questions or feedback", "contactDescription": "Свяжитесь с нами по любым вопросам или отзывам",
"contactHeading": "Send us a message", "contactHeading": "Отправьте нам сообщение",
"contactSubheading": "We will get back to you as soon as possible", "contactSubheading": "Мы свяжемся с вами как можно скорее",
"contactName": "Your Name", "contactName": "Ваше имя",
"contactEmail": "Your Email", "contactEmail": "Ваш email",
"contactMessage": "Your Message", "contactMessage": "Ваше сообщение",
"sendMessage": "Send Message", "sendMessage": "Отправить сообщение",
"contactSuccess": "Your message has been sent successfully", "contactSuccess": "Ваше сообщение успешно отправлено",
"contactError": "An error occurred while sending your message", "contactError": "Произошла ошибка при отправке сообщения",
"contactSuccessDescription": "We have received your message and will get back to you as soon as possible", "contactSuccessDescription": "Мы получили ваше сообщение и свяжемся с вами в ближайшее время",
"contactErrorDescription": "An error occurred while sending your message. Please try again later", "contactErrorDescription": "Произошла ошибка при отправке сообщения. Пожалуйста, попробуйте позже",
"footerDescription": "Here you can add a description about your company or product", "footerDescription": "Здесь вы можете добавить описание вашей компании или продукта",
"copyright": "© Copyright {{year}} {{product}}. All Rights Reserved.", "copyright": "© Copyright {{year}} {{product}}. Все права защищены.",
"heroSubtitle": "A simple, convenient, and quick overview of your health condition", "heroSubtitle": "Простой, удобный и быстрый обзор вашего здоровья",
"notInterestedInAudit": "Currently not interested in a health audit" "notInterestedInAudit": "Не хочу проходить аудит здоровья в данный момент"
} }

View File

@@ -1,7 +1,9 @@
{ {
"title": "Select analysis package", "title": "Выберите пакет анализов",
"noPackagesAvailable": "No packages available", "noPackagesAvailable": "Список услуг не найден, попробуйте позже",
"selectThisPackage": "Select this package", "selectThisPackage": "Выбрать этот пакет",
"selectPackage": "Select package", "selectPackage": "Выберите пакет",
"comparePackages": "Compare packages" "comparePackages": "Сравнить пакеты",
"analysisPackageAddedToCart": "Пакет анализов добавлен в корзину",
"analysisPackageAddToCartError": "Не удалось добавить пакет анализов в корзину"
} }

View File

@@ -0,0 +1,7 @@
{
"title": "Выберите анализ",
"description": "Результаты всех анализов будут доступны в течение 13 рабочих дней после сдачи крови.",
"analysisNotAvailable": "Заказ анализа в данный момент недоступен",
"analysisAddedToCart": "Анализ добавлен в корзину",
"analysisAddToCartError": "Не удалось добавить анализ в корзину"
}

View File

@@ -0,0 +1,19 @@
{
"title": "Заказы",
"description": "Просмотрите ваши заказы",
"table": {
"analysisPackage": "Пакет анализов",
"otherOrders": "Заказ",
"createdAt": "Дата заказа",
"status": "Статус"
},
"status": {
"QUEUED": "Отправлено",
"PROCESSING": "Передано в Synlab",
"PARTIAL_ANALYSIS_RESPONSE": "Частичные результаты",
"FULL_ANALYSIS_RESPONSE": "Все результаты получены, ожидается заключение врача",
"COMPLETED": "Подтверждено",
"REJECTED": "Возвращено",
"CANCELLED": "Отменено"
}
}

View File

@@ -1,31 +1,31 @@
{ {
"nrOfAnalyses": "{{nr}} analyses", "nrOfAnalyses": "{{nr}} анализов",
"clinicalBloodDraw": { "clinicalBloodDraw": {
"label": "Kliiniline vereanalüüs", "label": "Клинический анализ крови",
"description": "Pending" "description": "Ожидается"
}, },
"crp": { "crp": {
"label": "C-reaktiivne valk (CRP)", "label": "С-реактивный белок (CRP)",
"description": "Pending" "description": "Ожидается"
}, },
"ferritin": { "ferritin": {
"label": "Ferritiin", "label": "Ферритин",
"description": "Pending" "description": "Ожидается"
}, },
"vitaminD": { "vitaminD": {
"label": "D-vitamiin", "label": "Витамин D",
"description": "Pending" "description": "Ожидается"
}, },
"glucose": { "glucose": {
"label": "Glükoos", "label": "Глюкоза",
"description": "Pending" "description": "Ожидается"
}, },
"alat": { "alat": {
"label": "Alaniini aminotransferaas", "label": "Аланинаминотрансфераза",
"description": "Pending" "description": "Ожидается"
}, },
"ast": { "ast": {
"label": "Aspartaadi aminotransferaas", "label": "Аспартатаминотрансфераза",
"description": "Pending" "description": "Ожидается"
} }
} }

View File

@@ -1,164 +1,197 @@
{ {
"home": { "home": {
"pageTitle": "Home" "pageTitle": "Обзор",
"headerTitle": "Обзор Tervisekassa {{companyName}}",
"healthDetails": "Данные о здоровье компании",
"membersSettingsButtonTitle": "Управление сотрудниками",
"membersSettingsButtonDescription": "Добавляйте, редактируйте или удаляйте сотрудников.",
"membersBillingButtonTitle": "Управление бюджетом",
"membersBillingButtonDescription": "Выберите, как распределять бюджет между сотрудниками."
}, },
"settings": { "settings": {
"pageTitle": "Settings", "pageTitle": "Настройки",
"pageDescription": "Manage your Company details", "pageDescription": "Управление данными вашей компании",
"teamLogo": "Company Logo", "teamLogo": "Логотип компании",
"teamLogoDescription": "Update your company's logo to make it easier to identify", "teamLogoDescription": "Обновите логотип вашей компании для упрощения идентификации",
"teamName": "Company Name", "teamName": "Название компании",
"teamNameDescription": "Update your company's name", "teamNameDescription": "Обновите название вашей компании",
"dangerZone": "Danger Zone", "dangerZone": "Опасная зона",
"dangerZoneDescription": "This section contains actions that are irreversible" "dangerZoneDescription": "Этот раздел содержит действия, которые невозможно отменить"
}, },
"members": { "members": {
"pageTitle": "Members" "pageTitle": "Сотрудники"
}, },
"billing": { "billing": {
"pageTitle": "Billing" "pageTitle": "Биллинг"
}, },
"yourTeams": "Your Companies ({{teamsCount}})", "benefitStatistics": {
"createTeam": "Create a Company", "budget": {
"creatingTeam": "Creating Company...", "title": "Баланс Tervisekassa компании",
"personalAccount": "Personal Account", "balance": "Остаток бюджета {{balance}}",
"searchAccount": "Search Account...", "volume": "Объем бюджета {{volume}}"
"membersTabLabel": "Members", },
"memberName": "Name", "data": {
"youLabel": "You", "reservations": "{{value}} услуги",
"emailLabel": "Email", "analysis": "Анализы",
"roleLabel": "Role", "doctorsAndSpecialists": "Врачи и специалисты",
"primaryOwnerLabel": "Primary Admin", "researches": "Исследования",
"joinedAtLabel": "Joined at", "healthResearchPlans": "Пакеты медицинских исследований",
"invitedAtLabel": "Invited at", "serviceUsage": "{{value}} использование услуг",
"inviteMembersPageSubheading": "Invite members to your Company", "serviceSum": "Сумма услуг",
"createTeamModalHeading": "Create Company", "eclinic": "Дигиклиника"
"createTeamModalDescription": "Create a new Company to manage your projects and members.", }
"teamNameLabel": "Company Name", },
"teamNameDescription": "Your company name should be unique and descriptive", "healthDetails": {
"createTeamSubmitLabel": "Create Company", "women": "Женщины",
"createTeamSuccess": "Company created successfully", "men": "Мужчины",
"createTeamError": "Company not created. Please try again.", "avgAge": "Средний возраст",
"createTeamLoading": "Creating company...", "bmi": "ИМТ",
"settingsPageLabel": "General", "cholesterol": "Общий холестерин",
"createTeamDropdownLabel": "New company", "vitaminD": "Витамин D",
"changeRole": "Change Role", "smokers": "Курящие"
"removeMember": "Remove from Account", },
"inviteMembersSuccess": "Members invited successfully!", "yourTeams": "Ваши компании ({{teamsCount}})",
"inviteMembersError": "Sorry, we encountered an error! Please try again", "createTeam": "Создать компанию",
"inviteMembersLoading": "Inviting members...", "creatingTeam": "Создание компании...",
"removeInviteButtonLabel": "Remove invite", "personalAccount": "Личный аккаунт",
"addAnotherMemberButtonLabel": "Add another one", "searchAccount": "Поиск аккаунта...",
"inviteMembersButtonLabel": "Send Invites", "membersTabLabel": "Сотрудники",
"removeMemberModalHeading": "You are removing this user", "memberName": "Имя",
"removeMemberModalDescription": "Remove this member from the company. They will no longer have access to the company.", "youLabel": "Вы",
"removeMemberSuccessMessage": "Member removed successfully", "emailLabel": "E-mail",
"removeMemberErrorMessage": "Sorry, we encountered an error. Please try again", "roleLabel": "Роль",
"removeMemberErrorHeading": "Sorry, we couldn't remove the selected member.", "primaryOwnerLabel": "Главный админ",
"removeMemberLoadingMessage": "Removing member...", "joinedAtLabel": "Присоединился",
"removeMemberSubmitLabel": "Remove User from Company", "invitedAtLabel": "Приглашен",
"chooseDifferentRoleError": "Role is the same as the current one", "inviteMembersPageSubheading": "Пригласите сотрудников в компанию",
"updateRole": "Update Role", "createTeamModalHeading": "Создать компанию",
"updateRoleLoadingMessage": "Updating role...", "createTeamModalDescription": "Создайте новую компанию для управления проектами и сотрудниками.",
"updateRoleSuccessMessage": "Role updated successfully", "teamNameLabel": "Название компании",
"updatingRoleErrorMessage": "Sorry, we encountered an error. Please try again.", "teamNameDescription": "Название вашей компании должно быть уникальным и описательным",
"updateMemberRoleModalHeading": "Update Member's Role", "createTeamSubmitLabel": "Создать компанию",
"updateMemberRoleModalDescription": "Change the role of the selected member. The role determines the permissions of the member.", "createTeamSuccess": "Компания успешно создана",
"roleMustBeDifferent": "Role must be different from the current one", "createTeamError": "Компания не создана. Пожалуйста, попробуйте снова.",
"memberRoleInputLabel": "Member role", "createTeamLoading": "Создание компании...",
"updateRoleDescription": "Pick a role for this member.", "settingsPageLabel": "Основное",
"updateRoleSubmitLabel": "Update Role", "createTeamDropdownLabel": "Новая компания",
"transferOwnership": "Transfer Ownership", "changeRole": "Изменить роль",
"transferOwnershipDescription": "Transfer ownership of the company account to another member.", "removeMember": "Удалить из аккаунта",
"transferOwnershipInputLabel": "Please type TRANSFER to confirm the transfer of ownership.", "inviteMembersSuccess": "Сотрудники успешно приглашены!",
"transferOwnershipInputDescription": "By transferring ownership, you will no longer be the primary admin of the company account.", "inviteMembersError": "Произошла ошибка! Пожалуйста, попробуйте снова",
"deleteInvitation": "Delete Invitation", "inviteMembersLoading": "Приглашение сотрудников...",
"deleteInvitationDialogDescription": "You are about to delete the invitation. The user will no longer be able to join the company account.", "removeInviteButtonLabel": "Удалить приглашение",
"deleteInviteSuccessMessage": "Invite deleted successfully", "addAnotherMemberButtonLabel": "Добавить ещё одного",
"deleteInviteErrorMessage": "Invite not deleted. Please try again.", "inviteMembersButtonLabel": "Отправить приглашения",
"deleteInviteLoadingMessage": "Deleting invite. Please wait...", "removeMemberModalHeading": "Вы удаляете этого пользователя",
"confirmDeletingMemberInvite": "You are deleting the invite to <b>{{ email }}</b>", "removeMemberModalDescription": "Удалите этого сотрудника из компании. Он больше не будет иметь доступ к компании.",
"transferOwnershipDisclaimer": "You are transferring ownership of the selected company account to <b>{{ member }}</b>.", "removeMemberSuccessMessage": "Сотрудник успешно удален",
"transferringOwnership": "Transferring ownership...", "removeMemberErrorMessage": "Произошла ошибка. Пожалуйста, попробуйте снова",
"transferOwnershipSuccess": "Ownership successfully transferred", "removeMemberErrorHeading": "Не удалось удалить выбранного сотрудника.",
"transferOwnershipError": "Sorry, we could not transfer ownership to the selected member. Please try again.", "removeMemberLoadingMessage": "Удаление сотрудника...",
"deleteInviteSubmitLabel": "Delete Invite", "removeMemberSubmitLabel": "Удалить пользователя из компании",
"youBadgeLabel": "You", "chooseDifferentRoleError": "Роль совпадает с текущей",
"updateTeamLoadingMessage": "Updating Company...", "updateRole": "Обновить роль",
"updateTeamSuccessMessage": "Company successfully updated", "updateRoleLoadingMessage": "Обновление роли...",
"updateTeamErrorMessage": "Could not update Company. Please try again.", "updateRoleSuccessMessage": "Роль успешно обновлена",
"updateLogoErrorMessage": "Could not update Logo. Please try again.", "updatingRoleErrorMessage": "Произошла ошибка. Пожалуйста, попробуйте снова.",
"teamNameInputLabel": "Company Name", "updateMemberRoleModalHeading": "Обновить роль сотрудника",
"teamLogoInputHeading": "Upload your company's Logo", "updateMemberRoleModalDescription": "Измените роль выбранного сотрудника. Роль определяет его права.",
"teamLogoInputSubheading": "Please choose a photo to upload as your company logo.", "roleMustBeDifferent": "Роль должна отличаться от текущей",
"updateTeamSubmitLabel": "Update Company", "memberRoleInputLabel": "Роль сотрудника",
"inviteMembersHeading": "Invite Members to your Company", "updateRoleDescription": "Выберите роль для этого сотрудника.",
"inviteMembersDescription": "Invite member to your company by entering their email and role.", "updateRoleSubmitLabel": "Обновить роль",
"transferOwnership": "Передача собственности",
"transferOwnershipDescription": "Передайте права владельца аккаунта компании другому сотруднику.",
"transferOwnershipInputLabel": "Пожалуйста, введите TRANSFER для подтверждения передачи собственности.",
"transferOwnershipInputDescription": "Передав права собственности, вы больше не будете главным администратором компании.",
"deleteInvitation": "Удалить приглашение",
"deleteInvitationDialogDescription": "Вы собираетесь удалить приглашение. Пользователь больше не сможет присоединиться к аккаунту компании.",
"deleteInviteSuccessMessage": "Приглашение успешно удалено",
"deleteInviteErrorMessage": "Приглашение не удалено. Пожалуйста, попробуйте снова.",
"deleteInviteLoadingMessage": "Удаление приглашения...",
"confirmDeletingMemberInvite": "Вы удаляете приглашение для <b>{{ email }}</b>",
"transferOwnershipDisclaimer": "Вы передаете права собственности выбранного аккаунта компании <b>{{ member }}</b>.",
"transferringOwnership": "Передача собственности...",
"transferOwnershipSuccess": "Права собственности успешно переданы",
"transferOwnershipError": "Не удалось передать права собственности выбранному сотруднику. Пожалуйста, попробуйте снова.",
"deleteInviteSubmitLabel": "Удалить приглашение",
"youBadgeLabel": "Вы",
"updateTeamLoadingMessage": "Обновление компании...",
"updateTeamSuccessMessage": "Компания успешно обновлена",
"updateTeamErrorMessage": "Не удалось обновить компанию. Пожалуйста, попробуйте снова.",
"updateLogoErrorMessage": "Не удалось обновить логотип. Пожалуйста, попробуйте снова.",
"teamNameInputLabel": "Название компании",
"teamLogoInputHeading": "Загрузите логотип вашей компании",
"teamLogoInputSubheading": "Выберите фото для загрузки в качестве логотипа компании.",
"updateTeamSubmitLabel": "Обновить компанию",
"inviteMembersHeading": "Пригласить сотрудников в компанию",
"inviteMembersDescription": "Пригласите сотрудника в компанию, указав его email и роль.",
"emailPlaceholder": "member@email.com", "emailPlaceholder": "member@email.com",
"membersPageHeading": "Members", "membersPageHeading": "Сотрудники",
"inviteMembersButton": "Invite Members", "inviteMembersButton": "Пригласить сотрудников",
"invitingMembers": "Inviting members...", "invitingMembers": "Приглашение сотрудников...",
"inviteMembersSuccessMessage": "Members invited successfully", "inviteMembersSuccessMessage": "Сотрудники успешно приглашены",
"inviteMembersErrorMessage": "Sorry, members could not be invited. Please try again.", "inviteMembersErrorMessage": "Не удалось пригласить сотрудников. Пожалуйста, попробуйте снова.",
"pendingInvitesHeading": "Pending Invites", "pendingInvitesHeading": "Ожидающие приглашения",
"pendingInvitesDescription": " Here you can manage the pending invitations to your company.", "pendingInvitesDescription": "Здесь можно управлять ожидающими приглашениями в вашу компанию.",
"noPendingInvites": "No pending invites found", "noPendingInvites": "Нет ожидающих приглашений",
"loadingMembers": "Loading members...", "loadingMembers": "Загрузка сотрудников...",
"loadMembersError": "Sorry, we couldn't fetch your company's members.", "loadMembersError": "Не удалось получить список сотрудников компании.",
"loadInvitedMembersError": "Sorry, we couldn't fetch your company's invited members.", "loadInvitedMembersError": "Не удалось получить список приглашённых сотрудников компании.",
"loadingInvitedMembers": "Loading invited members...", "loadingInvitedMembers": "Загрузка приглашённых сотрудников...",
"invitedBadge": "Invited", "invitedBadge": "Приглашён",
"duplicateInviteEmailError": "You have already entered this email address", "duplicateInviteEmailError": "Вы уже добавили этот адрес электронной почты",
"invitingOwnAccountError": "Hey, that's your email!", "invitingOwnAccountError": "Эй, это ваш email!",
"dangerZone": "Danger Zone", "dangerZone": "Опасная зона",
"dangerZoneSubheading": "Delete or leave your company", "dangerZoneSubheading": "Удалить или покинуть компанию",
"deleteTeam": "Delete Company", "deleteTeam": "Удалить компанию",
"deleteTeamDescription": "This action cannot be undone. All data associated with this company will be deleted.", "deleteTeamDescription": "Это действие невозможно отменить. Все данные, связанные с этой компанией, будут удалены.",
"deletingTeam": "Deleting company", "deletingTeam": "Удаление компании...",
"deleteTeamModalHeading": "Deleting Company", "deleteTeamModalHeading": "Удаление компании",
"deletingTeamDescription": "You are about to delete the company {{ teamName }}. This action cannot be undone.", "deletingTeamDescription": "Вы собираетесь удалить компанию {{ teamName }}. Это действие невозможно отменить.",
"deleteTeamInputField": "Type the name of the company to confirm", "deleteTeamInputField": "Введите название компании для подтверждения",
"leaveTeam": "Leave Company", "leaveTeam": "Покинуть компанию",
"leavingTeamModalHeading": "Leaving Company", "leavingTeamModalHeading": "Покидание компании",
"leavingTeamModalDescription": "You are about to leave this company. You will no longer have access to it.", "leavingTeamModalDescription": "Вы собираетесь покинуть эту компанию. У вас больше не будет к ней доступа.",
"leaveTeamDescription": "Click the button below to leave the company. Remember, you will no longer have access to it and will need to be re-invited to join.", "leaveTeamDescription": "Нажмите кнопку ниже, чтобы покинуть компанию. Помните, что вы больше не будете иметь к ней доступа и вам потребуется повторное приглашение для присоединения.",
"deleteTeamDisclaimer": "You are deleting the company {{ teamName }}. This action cannot be undone.", "deleteTeamDisclaimer": "Вы удаляете компанию {{ teamName }}. Это действие невозможно отменить.",
"leaveTeamDisclaimer": "You are leaving the company {{ teamName }}. You will no longer have access to it.", "leaveTeamDisclaimer": "Вы покидаете компанию {{ teamName }}. У вас больше не будет к ней доступа.",
"deleteTeamErrorHeading": "Sorry, we couldn't delete your company.", "deleteTeamErrorHeading": "Не удалось удалить вашу компанию.",
"leaveTeamErrorHeading": "Sorry, we couldn't leave your company.", "leaveTeamErrorHeading": "Не удалось покинуть вашу компанию.",
"searchMembersPlaceholder": "Search members", "searchMembersPlaceholder": "Поиск сотрудников",
"createTeamErrorHeading": "Sorry, we couldn't create your company.", "createTeamErrorHeading": "Не удалось создать вашу компанию.",
"createTeamErrorMessage": "We encountered an error creating your company. Please try again.", "createTeamErrorMessage": "Произошла ошибка при создании компании. Пожалуйста, попробуйте снова.",
"transferTeamErrorHeading": "Sorry, we couldn't transfer ownership of your company account.", "transferTeamErrorHeading": "Не удалось передать права собственности аккаунта компании.",
"transferTeamErrorMessage": "We encountered an error transferring ownership of your company account. Please try again.", "transferTeamErrorMessage": "Произошла ошибка при передаче прав собственности аккаунта компании. Пожалуйста, попробуйте снова.",
"updateRoleErrorHeading": "Sorry, we couldn't update the role of the selected member.", "updateRoleErrorHeading": "Не удалось обновить роль выбранного сотрудника.",
"updateRoleErrorMessage": "We encountered an error updating the role of the selected member. Please try again.", "updateRoleErrorMessage": "Произошла ошибка при обновлении роли выбранного сотрудника. Пожалуйста, попробуйте снова.",
"searchInvitations": "Search Invitations", "searchInvitations": "Поиск приглашений",
"updateInvitation": "Update Invitation", "updateInvitation": "Обновить приглашение",
"removeInvitation": "Remove Invitation", "removeInvitation": "Удалить приглашение",
"acceptInvitation": "Accept Invitation", "acceptInvitation": "Принять приглашение",
"renewInvitation": "Renew Invitation", "renewInvitation": "Продлить приглашение",
"resendInvitation": "Resend Invitation", "resendInvitation": "Отправить приглашение повторно",
"expiresAtLabel": "Expires at", "expiresAtLabel": "Истекает",
"expired": "Expired", "expired": "Истекло",
"active": "Active", "active": "Активно",
"inviteStatus": "Status", "inviteStatus": "Статус",
"inviteNotFoundOrExpired": "Invite not found or expired", "inviteNotFoundOrExpired": "Приглашение не найдено или истекло",
"inviteNotFoundOrExpiredDescription": "The invite you are looking for is either expired or does not exist. Please contact the company admin to renew the invite.", "inviteNotFoundOrExpiredDescription": "Приглашение, которое вы ищете, либо истекло, либо не существует. Пожалуйста, свяжитесь с администратором компании для продления приглашения.",
"backToHome": "Back to Home", "backToHome": "Назад на главную",
"renewInvitationDialogDescription": "You are about to renew the invitation to {{ email }}. The user will be able to join the company.", "renewInvitationDialogDescription": "Вы собираетесь продлить приглашение для {{ email }}. Пользователь сможет присоединиться к компании.",
"renewInvitationErrorTitle": "Sorry, we couldn't renew the invitation.", "renewInvitationErrorTitle": "Не удалось продлить приглашение.",
"renewInvitationErrorDescription": "We encountered an error renewing the invitation. Please try again.", "renewInvitationErrorDescription": "Произошла ошибка при продлении приглашения. Пожалуйста, попробуйте снова.",
"signInWithDifferentAccount": "Sign in with a different account", "signInWithDifferentAccount": "Войти с другим аккаунтом",
"signInWithDifferentAccountDescription": "If you wish to accept the invitation with a different account, please sign out and back in with the account you wish to use.", "signInWithDifferentAccountDescription": "Если вы хотите принять приглашение с другим аккаунтом, выйдите из системы и войдите с нужным аккаунтом.",
"acceptInvitationHeading": "Accept Invitation to join {{accountName}}", "acceptInvitationHeading": "Принять приглашение для присоединения к {{accountName}}",
"acceptInvitationDescription": "You have been invited to join the company {{accountName}}. If you wish to accept the invitation, please click the button below.", "acceptInvitationDescription": "Вас пригласили присоединиться к компании {{accountName}}. Чтобы принять приглашение, нажмите кнопку ниже.",
"continueAs": "Continue as {{email}}", "continueAs": "Продолжить как {{email}}",
"joinTeamAccount": "Join Company", "joinTeamAccount": "Присоединиться к компании",
"joiningTeam": "Joining company...", "joiningTeam": "Присоединение к компании...",
"leaveTeamInputLabel": "Please type LEAVE to confirm leaving the company.", "leaveTeamInputLabel": "Пожалуйста, введите LEAVE для подтверждения выхода из компании.",
"leaveTeamInputDescription": "By leaving the company, you will no longer have access to it.", "leaveTeamInputDescription": "При выходе из компании у вас больше не будет к ней доступа.",
"reservedNameError": "This name is reserved. Please choose a different one.", "reservedNameError": "Это имя зарезервировано. Пожалуйста, выберите другое.",
"specialCharactersError": "This name cannot contain special characters. Please choose a different one.", "specialCharactersError": "Это имя не может содержать специальные символы. Пожалуйста, выберите другое.",
"personalCode": "Личный код" "personalCode": "Идентификационный код",
"teamOwnerPersonalCodeLabel": "Идентификационный код владельца"
} }

View File

@@ -135,6 +135,7 @@
--animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out;
--breakpoint-2xs: 36rem;
--breakpoint-xs: 48rem; --breakpoint-xs: 48rem;
--breakpoint-sm: 64rem; --breakpoint-sm: 64rem;
--breakpoint-md: 70rem; --breakpoint-md: 70rem;

View File

@@ -0,0 +1,17 @@
create extension if not exists pg_cron;
create extension if not exists pg_net;
select
cron.schedule(
'send emails with new unassigned jobs 4x a day',
'0 4,9,14,18 * * 1-5', -- Run at 07:00, 12:00, 17:00 and 21:00 (GMT +3) on weekdays only
$$
select
net.http_post(
url := 'https://test.medreport.ee/api/job/send-open-jobs-emails',
headers := jsonb_build_object(
'x-jobs-api-key', 'fd26ec26-70ed-11f0-9e95-431ac3b15a84'
)
) as request_id;
$$
);

View File

@@ -0,0 +1,8 @@
grant select on table "medreport"."doctor_analysis_feedback" to "service_role";
create policy "service_role_select"
on "medreport"."doctor_analysis_feedback"
as permissive
for select
to service_role
using (true);

View File

@@ -0,0 +1,10 @@
create trigger "trigger_doctor_notification" after update
on "medreport"."analysis_orders" for each row
execute function "supabase_functions"."http_request"(
'http://host.docker.internal:3000/api/db/webhook',
'POST',
'{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}',
'{}',
'5000'
);

View File

@@ -0,0 +1,10 @@
alter table audit.notification_entries enable row level security;
create policy "service_role_insert"
on "audit"."notification_entries"
as permissive
for insert
to service_role
with check (true);
grant insert on table "audit"."notification_entries" to "service_role";