Merge branch 'main' into MED-57

This commit is contained in:
Danel Kungla
2025-08-26 11:30:34 +03:00
22 changed files with 507 additions and 225 deletions

View File

@@ -19,7 +19,7 @@ import {
} from '@kit/doctor/schema/doctor-analysis-detail-view.schema'; } from '@kit/doctor/schema/doctor-analysis-detail-view.schema';
import { import {
DoctorAnalysisFeedbackForm, DoctorAnalysisFeedbackForm,
doctorAnalysisFeedbackSchema, 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 { getFullName } from '@kit/shared/utils'; import { getFullName } from '@kit/shared/utils';
@@ -36,9 +36,11 @@ import { toast } from '@kit/ui/sonner';
import { Textarea } from '@kit/ui/textarea'; import { Textarea } from '@kit/ui/textarea';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import Analysis from '~/home/(user)/(dashboard)/analysis-results/_components/analysis';
import { bmiFromMetric } from '~/lib/utils'; import { bmiFromMetric } from '~/lib/utils';
import DoctorAnalysisWrapper from './doctor-analysis-wrapper';
import DoctorJobSelect from './doctor-job-select';
export default function AnalysisView({ export default function AnalysisView({
patient, patient,
order, order,
@@ -54,16 +56,20 @@ export default function AnalysisView({
const { data: user } = useUser(); const { data: user } = useUser();
const isInProgress = const isInProgress = !!(
!!feedback?.status && !!feedback?.status &&
feedback?.doctor_user_id && feedback?.doctor_user_id &&
feedback?.status !== 'COMPLETED'; feedback?.status !== 'COMPLETED'
);
const isCurrentDoctorJob =
!!feedback?.doctor_user_id && feedback?.doctor_user_id === user?.id;
const isReadOnly = const isReadOnly =
!isInProgress || !isInProgress ||
(!!feedback?.doctor_user_id && feedback?.doctor_user_id !== user?.id); (!!feedback?.doctor_user_id && feedback?.doctor_user_id !== user?.id);
const form = useForm({ const form = useForm({
resolver: zodResolver(doctorAnalysisFeedbackSchema), resolver: zodResolver(doctorAnalysisFeedbackFormSchema),
reValidateMode: 'onChange',
defaultValues: { defaultValues: {
feedbackValue: feedback?.value ?? '', feedbackValue: feedback?.value ?? '',
userId: patient.userId, userId: patient.userId,
@@ -103,12 +109,22 @@ export default function AnalysisView({
} }
}; };
const handleDraftSubmit = () => { const handleDraftSubmit = async (e: React.FormEvent) => {
e.preventDefault();
form.formState.errors.feedbackValue = undefined;
const formData = form.getValues(); const formData = form.getValues();
onSubmit(formData, 'DRAFT'); onSubmit(formData, 'DRAFT');
}; };
const handleCompleteSubmit = () => { const handleCompleteSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const isValid = await form.trigger();
if (!isValid) {
return;
}
setIsConfirmOpen(true); setIsConfirmOpen(true);
}; };
@@ -119,16 +135,31 @@ export default function AnalysisView({
return ( return (
<> <>
<h3> <div className="xs:flex xs:justify-between">
<Trans <h3>
i18nKey={getResultSetName( <Trans
order.title, i18nKey={getResultSetName(
order.isPackage, order.title,
Object.keys(analyses)?.length, order.isPackage,
)} Object.keys(analyses)?.length,
/> )}
</h3> />
<div className="grid grid-cols-2"> </h3>
<div className="xs:flex hidden">
<DoctorJobSelect
analysisOrderId={order.analysisOrderId}
userId={patient.userId}
doctorUserId={feedback?.doctor_user_id}
isRemovable={isCurrentDoctorJob && isInProgress}
onJobUpdate={() =>
queryClient.invalidateQueries({
predicate: (query) => query.queryKey.includes('doctor-jobs'),
})
}
/>
</div>
</div>
<div className="xs:grid-cols-2 grid">
<div className="font-bold"> <div className="font-bold">
<Trans i18nKey="doctor:name" /> <Trans i18nKey="doctor:name" />
</div> </div>
@@ -152,11 +183,11 @@ export default function AnalysisView({
<div className="font-bold"> <div className="font-bold">
<Trans i18nKey="doctor:bmi" /> <Trans i18nKey="doctor:bmi" />
</div> </div>
<div>{bmiFromMetric(patient?.height ?? 0, patient?.weight ?? 0)}</div> <div>{bmiFromMetric(patient?.weight ?? 0, patient?.height ?? 0)}</div>
<div className="font-bold"> <div className="font-bold">
<Trans i18nKey="doctor:smoking" /> <Trans i18nKey="doctor:smoking" />
</div> </div>
<div></div> <div>-</div>
<div className="font-bold"> <div className="font-bold">
<Trans i18nKey="doctor:phone" /> <Trans i18nKey="doctor:phone" />
</div> </div>
@@ -166,30 +197,37 @@ export default function AnalysisView({
</div> </div>
<div>{patient.email}</div> <div>{patient.email}</div>
</div> </div>
<div className="xs:hidden block">
<DoctorJobSelect
className="w-full"
analysisOrderId={order.analysisOrderId}
userId={patient.userId}
doctorUserId={feedback?.doctor_user_id}
isRemovable={isCurrentDoctorJob && isInProgress}
onJobUpdate={() =>
queryClient.invalidateQueries({
predicate: (query) => query.queryKey.includes('doctor-jobs'),
})
}
/>
</div>
<h3> <h3>
<Trans i18nKey="doctor:results" /> <Trans i18nKey="doctor:results" />
</h3> </h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{analyses.map((analysisData) => { {analyses.map((analysisData) => {
return ( return (
<Analysis <DoctorAnalysisWrapper
key={analysisData.id} key={analysisData.id}
analysisElement={{ analysisData={analysisData}
analysis_name_lab: analysisData.analysis_name,
}}
results={analysisData}
/> />
); );
})} })}
</div> </div>
<h3> <h3>
<Trans i18nKey="doctor:feedback" /> <Trans i18nKey="doctor:feedback" />
</h3> </h3>
<p>{feedback?.value ?? '-'}</p> <p>{feedback?.value ?? '-'}</p>
{!isReadOnly && ( {!isReadOnly && (
<Form {...form}> <Form {...form}>
<form className="space-y-4 lg:w-1/2"> <form className="space-y-4 lg:w-1/2">
@@ -206,23 +244,21 @@ export default function AnalysisView({
)} )}
/> />
<div className="flex gap-2"> <div className="xs:flex block justify-end gap-2 space-y-2">
<Button <Button
type="button"
variant="outline" variant="outline"
onClick={(e) => { onClick={handleDraftSubmit}
e.preventDefault();
handleDraftSubmit();
}}
disabled={isReadOnly} disabled={isReadOnly}
className="xs:w-1/4 w-full"
> >
<Trans i18nKey="common:saveAsDraft" /> <Trans i18nKey="common:saveAsDraft" />
</Button> </Button>
<Button <Button
onClick={(e) => { type="button"
e.preventDefault(); onClick={handleCompleteSubmit}
handleCompleteSubmit();
}}
disabled={isReadOnly} disabled={isReadOnly}
className="xs:w-1/4 w-full"
> >
<Trans i18nKey="common:save" /> <Trans i18nKey="common:save" />
</Button> </Button>

View File

@@ -0,0 +1,103 @@
'use client';
import { CaretDownIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons';
import { useTranslation } from 'react-i18next';
import { AnalysisResponse } from '@kit/doctor/schema/doctor-analysis-detail-view.schema';
import { InfoTooltip } from '@kit/shared/components/ui/info-tooltip';
import { formatDate } from '@kit/shared/utils';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@kit/ui/collapsible';
import { Trans } from '@kit/ui/trans';
import Analysis from '~/home/(user)/(dashboard)/analysis-results/_components/analysis';
export default function DoctorAnalysisWrapper({
analysisData,
}: {
analysisData: AnalysisResponse;
}) {
const { t } = useTranslation();
return (
<Collapsible className="w-full" key={analysisData.id}>
<CollapsibleTrigger
disabled={!analysisData.latestPreviousAnalysis}
asChild
>
<div className="[&[data-state=open]_.caret-icon]:rotate-180">
<Analysis
startIcon={
analysisData.latestPreviousAnalysis && (
<CaretDownIcon className="caret-icon transition-transform duration-200" />
)
}
endIcon={
analysisData.comment && (
<>
<div className="xs:flex hidden">
<InfoTooltip
content={analysisData.comment}
icon={
<QuestionMarkCircledIcon className="mx-2 text-blue-800" />
}
/>
</div>
<p className="xs:hidden">
<strong>
<Trans i18nKey="doctor:labComment" />:
</strong>{' '}
{analysisData.comment}
</p>
</>
)
}
analysisElement={{
analysis_name_lab: analysisData.analysis_name,
}}
results={analysisData}
/>
</div>
</CollapsibleTrigger>
{analysisData.latestPreviousAnalysis && (
<CollapsibleContent>
<div className="my-1 flex flex-col">
<Analysis
endIcon={
analysisData.latestPreviousAnalysis.comment && (
<>
<div className="xs:flex hidden">
<InfoTooltip
content={analysisData.latestPreviousAnalysis.comment}
icon={
<QuestionMarkCircledIcon className="mx-2 text-blue-800" />
}
/>
</div>
<p className="xs:hidden">
<strong>
<Trans i18nKey="doctor:labComment" />:{' '}
</strong>
{analysisData.latestPreviousAnalysis.comment}
</p>
</>
)
}
analysisElement={{
analysis_name_lab: t('doctor:previousResults', {
date: formatDate(
analysisData.latestPreviousAnalysis.response_time,
),
}),
}}
results={analysisData.latestPreviousAnalysis}
/>
</div>
</CollapsibleContent>
)}
</Collapsible>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { LoaderCircle } from 'lucide-react';
import {
selectJobAction,
unselectJobAction,
} from '@kit/doctor/actions/doctor-server-actions';
import { Button, ButtonProps } from '@kit/ui/button';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
export default function DoctorJobSelect({
className,
size = 'sm',
doctorUserId,
doctorName,
analysisOrderId,
userId,
isRemovable,
onJobUpdate,
linkTo,
}: {
className?: string;
size?: ButtonProps['size'];
doctorUserId?: string | null;
doctorName?: string;
analysisOrderId: number;
userId: string;
isRemovable?: boolean;
linkTo?: string;
onJobUpdate: () => void;
}) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleSelectJob = () => {
startTransition(async () => {
const result = await selectJobAction({
analysisOrderId,
userId,
});
if (result?.success) {
onJobUpdate();
linkTo && router.push(linkTo);
} else {
toast.error('common.genericServerError');
}
});
};
const handleUnselectJob = () => {
startTransition(async () => {
const result = await unselectJobAction({
analysisOrderId,
});
if (result?.success) {
onJobUpdate();
} else {
toast.error('common.genericServerError');
}
});
};
if (isRemovable) {
return (
<Button
className={cn('w-16', className)}
size={size}
variant="destructive"
onClick={handleUnselectJob}
disabled={isPending}
>
{isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Trans i18nKey="doctor:unselectJob" />
)}
</Button>
);
}
if (!doctorUserId) {
return (
<Button
className={cn('w-16', className)}
size={size}
onClick={handleSelectJob}
disabled={isPending}
>
{isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Trans i18nKey="doctor:selectJob" />
)}
</Button>
);
}
return <>{doctorName}</>;
}

View File

@@ -6,6 +6,9 @@ import { usePathname } from 'next/navigation';
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace'; import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
import { LayoutDashboard } from 'lucide-react'; import { LayoutDashboard } from 'lucide-react';
import { AppLogo } from '@kit/shared/components/app-logo';
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
import { pathsConfig } from '@kit/shared/config';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -20,11 +23,6 @@ import {
} from '@kit/ui/shadcn-sidebar'; } from '@kit/ui/shadcn-sidebar';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { AppLogo } from '@kit/shared/components/app-logo';
import { ProfileAccountDropdownContainer } from '@kit/shared/components/personal-account-dropdown-container';
import { pathsConfig } from '@kit/shared/config';
export function DoctorSidebar({ export function DoctorSidebar({
accounts, accounts,
}: { }: {
@@ -75,10 +73,7 @@ export function DoctorSidebar({
isActive={path === pathsConfig.app.myJobs} isActive={path === pathsConfig.app.myJobs}
asChild asChild
> >
<Link <Link className={'flex gap-2.5'} href={pathsConfig.app.myJobs}>
className={'flex gap-2.5'}
href={pathsConfig.app.myJobs}
>
<LayoutDashboard className={'h-4'} /> <LayoutDashboard className={'h-4'} />
<Trans i18nKey={'doctor:sidebar.myReviews'} /> <Trans i18nKey={'doctor:sidebar.myReviews'} />
</Link> </Link>

View File

@@ -3,15 +3,10 @@
import { useTransition } from 'react'; import { useTransition } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Eye, LoaderCircle } from 'lucide-react'; import { Eye } from 'lucide-react';
import {
selectJobAction,
unselectJobAction,
} from '@kit/doctor/actions/doctor-server-actions';
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';
@@ -28,94 +23,7 @@ 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';
function DoctorCell({
doctorUserId,
doctorName,
analysisOrderId,
userId,
isRemovable,
onJobUpdate,
linkTo,
}: {
doctorUserId?: string;
doctorName?: string;
analysisOrderId: number;
userId: string;
isRemovable?: boolean;
linkTo: string;
onJobUpdate: () => void;
}) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleSelectJob = () => {
startTransition(async () => {
const result = await selectJobAction({
analysisOrderId,
userId,
});
if (result?.success) {
onJobUpdate();
router.push(linkTo);
} else {
toast.error('common.genericServerError');
}
});
};
const handleUnselectJob = () => {
startTransition(async () => {
const result = await unselectJobAction({
analysisOrderId,
});
if (result?.success) {
onJobUpdate();
} else {
toast.error('common.genericServerError');
}
});
};
if (isRemovable) {
return (
<Button
className="w-16"
size="sm"
variant="destructive"
onClick={handleUnselectJob}
disabled={isPending}
>
{isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Trans i18nKey="doctor:unselectJob" />
)}
</Button>
);
}
if (!doctorUserId) {
return (
<Button
className="w-16"
size="sm"
onClick={handleSelectJob}
disabled={isPending}
>
{isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Trans i18nKey="doctor:selectJob" />
)}
</Button>
);
}
return <>{doctorName}</>;
}
export default function ResultsTable({ export default function ResultsTable({
results = [], results = [],
@@ -272,7 +180,7 @@ export default function ResultsTable({
/> />
</TableCell> </TableCell>
<TableCell> <TableCell>
<DoctorCell <DoctorJobSelect
doctorUserId={result.doctor?.primary_owner_user_id} doctorUserId={result.doctor?.primary_owner_user_id}
doctorName={getFullName( doctorName={getFullName(
result.doctor?.name, result.doctor?.name,

View File

@@ -2,6 +2,7 @@ import { cache } from 'react';
import { getAnalysisResultsForDoctor } from '@kit/doctor/services/doctor-analysis.service'; import { getAnalysisResultsForDoctor } from '@kit/doctor/services/doctor-analysis.service';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import AnalysisView from '../../_components/analysis-view'; import AnalysisView from '../../_components/analysis-view';
import { DoctorGuard } from '../../_components/doctor-guard'; import { DoctorGuard } from '../../_components/doctor-guard';

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useMemo, useState } from 'react'; import React, { ReactElement, ReactNode, useMemo, useState } from 'react';
import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts'; import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts';
import { format } from 'date-fns'; import { format } from 'date-fns';
@@ -37,9 +37,13 @@ export enum AnalysisStatus {
const Analysis = ({ const Analysis = ({
analysisElement, analysisElement,
results, results,
startIcon,
endIcon,
}: { }: {
analysisElement: Pick<AnalysisElement, 'analysis_name_lab'>; analysisElement: Pick<AnalysisElement, 'analysis_name_lab'>;
results?: AnalysisResultForDisplay; results?: AnalysisResultForDisplay;
startIcon?: ReactElement | null;
endIcon?: ReactNode | null;
}) => { }) => {
const name = analysisElement.analysis_name_lab || ''; const name = analysisElement.analysis_name_lab || '';
const status = results?.norm_status || AnalysisStatus.NORMAL; const status = results?.norm_status || AnalysisStatus.NORMAL;
@@ -76,59 +80,66 @@ const Analysis = ({
}, [results, value, normLower]); }, [results, value, normLower]);
return ( return (
<div className="border-border flex flex-col items-center justify-between gap-2 rounded-lg border px-5 px-12 py-3 sm:h-[65px] sm:flex-row sm:gap-0"> <div className="border-border rounded-lg border px-5">
<div className="flex items-center gap-2 font-semibold"> <div className="flex flex-col items-center justify-between gap-2 py-3 sm:h-[65px] sm:flex-row sm:gap-0">
{name} <div className="flex items-center gap-2 font-semibold">
{results?.response_time && ( {startIcon || <div className="w-4" />}
<div {name}
className="group/tooltip relative" {results?.response_time && (
onClick={() => setShowTooltip(!showTooltip)}
onMouseLeave={() => setShowTooltip(false)}
>
<Info className="hover" />{' '}
<div <div
className={cn( className="group/tooltip relative"
'absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 rounded border bg-white p-4 text-sm whitespace-nowrap group-hover/tooltip:block', onClick={(e) => {
{ block: showTooltip }, e.stopPropagation();
)} setShowTooltip(!showTooltip);
}}
onMouseLeave={() => setShowTooltip(false)}
> >
<Trans i18nKey="analysis-results:analysisDate" /> <Info className="hover" />{' '}
{': '} <div
{format(new Date(results.response_time), 'dd.MM.yyyy HH:mm')} className={cn(
'absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 rounded border bg-white p-4 text-sm whitespace-nowrap group-hover/tooltip:block',
{ block: showTooltip },
)}
>
<Trans i18nKey="analysis-results:analysisDate" />
{': '}
{format(new Date(results.response_time), 'dd.MM.yyyy HH:mm')}
</div>
</div> </div>
</div> )}
</div>
{results ? (
<>
<div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">{value}</div>
<div className="text-muted-foreground text-sm">{unit}</div>
</div>
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
{normLower} - {normUpper}
<div>
<Trans i18nKey="analysis-results:results.range.normal" />
</div>
</div>
<AnalysisLevelBar
results={results}
normLowerIncluded={normLowerIncluded}
normUpperIncluded={normUpperIncluded}
level={analysisResultLevel!}
/>
{endIcon || <div className="mx-2 w-4" />}
</>
) : (
<>
<div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">
<Trans i18nKey="analysis-results:waitingForResults" />
</div>
</div>
<div className="mx-8 w-[60px]"></div>
<AnalysisLevelBarSkeleton />
</>
)} )}
</div> </div>
{results ? (
<>
<div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">{value}</div>
<div className="text-muted-foreground text-sm">{unit}</div>
</div>
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
{normLower} - {normUpper}
<div>
<Trans i18nKey="analysis-results:results.range.normal" />
</div>
</div>
<AnalysisLevelBar
results={results}
normLowerIncluded={normLowerIncluded}
normUpperIncluded={normUpperIncluded}
level={analysisResultLevel!}
/>
</>
) : (
<>
<div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">
<Trans i18nKey="analysis-results:waitingForResults" />
</div>
</div>
<div className="mx-8 w-[60px]"></div>
<AnalysisLevelBarSkeleton />
</>
)}
</div> </div>
); );
}; };

View File

@@ -637,6 +637,7 @@ async function syncPrivateMessage({
unit: element.Mootyhik ?? null, unit: element.Mootyhik ?? null,
original_response_element: element, original_response_element: element,
analysis_name: element.UuringNimi || element.KNimetus, analysis_name: element.UuringNimi || element.KNimetus,
comment: element.UuringuKommentaar
})), })),
); );
} }

View File

@@ -35,6 +35,7 @@ export const selectJobAction = doctorAction(
logger.info({ analysisOrderId }, `Successfully selected`); logger.info({ analysisOrderId }, `Successfully selected`);
revalidateDoctorAnalysis();
return { success: true }; return { success: true };
}, },
{ {
@@ -60,7 +61,7 @@ export const unselectJobAction = doctorAction(
{ analysisOrderId }, { analysisOrderId },
`Successfully removed current doctor from job`, `Successfully removed current doctor from job`,
); );
revalidateDoctorAnalysis();
return { success: true }; return { success: true };
}, },
{ {
@@ -101,6 +102,7 @@ export const giveFeedbackAction = doctorAction(
); );
logger.info({ analysisOrderId }, `Successfully submitted feedback`); logger.info({ analysisOrderId }, `Successfully submitted feedback`);
revalidateDoctorAnalysis();
return result; return result;
}, },
{ {
@@ -108,3 +110,7 @@ export const giveFeedbackAction = doctorAction(
}, },
), ),
); );
function revalidateDoctorAnalysis() {
revalidatePath('/doctor/analysis');
}

View File

@@ -66,6 +66,27 @@ export const AnalysisResponseSchema = z.object({
updated_at: z.string().nullable(), updated_at: z.string().nullable(),
analysis_name: z.string().nullable(), analysis_name: z.string().nullable(),
analysis_responses: AnalysisResponsesSchema, analysis_responses: AnalysisResponsesSchema,
comment: z.string().nullable(),
latestPreviousAnalysis: z
.object({
id: z.number(),
analysis_response_id: z.number(),
analysis_element_original_id: z.string(),
unit: z.string().nullable(),
response_value: z.number(),
response_time: z.string(),
norm_upper: z.number().nullable(),
norm_upper_included: z.boolean().nullable(),
norm_lower: z.number().nullable(),
norm_lower_included: z.boolean().nullable(),
norm_status: z.number().nullable(),
created_at: z.string(),
updated_at: z.string().nullable(),
analysis_name: z.string().nullable(),
comment: z.string().nullable(),
})
.optional()
.nullable(),
}); });
export type AnalysisResponse = z.infer<typeof AnalysisResponseSchema>; export type AnalysisResponse = z.infer<typeof AnalysisResponseSchema>;

View File

@@ -15,18 +15,19 @@ export type DoctorJobUnselect = z.infer<typeof doctorJobUnselectSchema>;
export const FeedbackStatus = z.enum(['STARTED', 'DRAFT', 'COMPLETED']); export const FeedbackStatus = z.enum(['STARTED', 'DRAFT', 'COMPLETED']);
export const doctorAnalysisFeedbackFormSchema = z.object({ export const doctorAnalysisFeedbackFormSchema = z.object({
feedbackValue: z.string().min(15), feedbackValue: z.string().min(10, { message: 'doctor:feedbackLengthError' }),
userId: z.string().uuid(), userId: z.string().uuid(),
}); });
export type DoctorAnalysisFeedbackForm = z.infer< export type DoctorAnalysisFeedbackForm = z.infer<
typeof doctorAnalysisFeedbackFormSchema typeof doctorAnalysisFeedbackFormSchema
>; >;
export const doctorAnalysisFeedbackSchema = z.object({ export const doctorAnalysisFeedbackSchema = z.object({
feedbackValue: z.string().min(15), feedbackValue: z.string(),
userId: z.string().uuid(), userId: z.string().uuid(),
analysisOrderId: z.number(), analysisOrderId: z.number(),
status: FeedbackStatus, status: FeedbackStatus,
}); });
export type DoctorAnalysisFeedback = z.infer< export type DoctorAnalysisFeedback = z.infer<
typeof doctorAnalysisFeedbackSchema typeof doctorAnalysisFeedbackSchema
>; >;

View File

@@ -1,5 +1,7 @@
import 'server-only'; import 'server-only';
import { isBefore } from 'date-fns';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema'; import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema';
@@ -360,7 +362,7 @@ export async function getAnalysisResultsForDoctor(
): Promise<AnalysisResultDetails> { ): Promise<AnalysisResultDetails> {
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerClient();
const { data: analysisResponse, error } = await supabase const { data: analysisResponseElements, error } = await supabase
.schema('medreport') .schema('medreport')
.from(`analysis_response_elements`) .from(`analysis_response_elements`)
.select( .select(
@@ -373,20 +375,26 @@ export async function getAnalysisResultsForDoctor(
throw new Error('Something went wrong.'); throw new Error('Something went wrong.');
} }
const firstAnalysisResponse = analysisResponse?.[0]; const firstAnalysisResponse = analysisResponseElements?.[0];
const userId = firstAnalysisResponse?.analysis_responses.user_id; const userId = firstAnalysisResponse?.analysis_responses.user_id;
const medusaOrderId = const medusaOrderId =
firstAnalysisResponse?.analysis_responses?.analysis_order_id firstAnalysisResponse?.analysis_responses?.analysis_order_id
?.medusa_order_id; ?.medusa_order_id;
if (!analysisResponse?.length || !userId || !medusaOrderId) { if (!analysisResponseElements?.length || !userId || !medusaOrderId) {
throw new Error('Failed to retrieve full analysis data.'); throw new Error('Failed to retrieve full analysis data.');
} }
const responseElementAnalysisElementOriginalIds =
analysisResponseElements.map(
({ analysis_element_original_id }) => analysis_element_original_id,
);
const [ const [
{ data: medusaOrderItems, error: medusaOrderError }, { data: medusaOrderItems, error: medusaOrderError },
{ data: accountWithParams, error: accountError }, { data: accountWithParams, error: accountError },
{ data: doctorFeedback, error: feedbackError }, { data: doctorFeedback, error: feedbackError },
{ data: previousAnalyses, error: previousAnalysesError },
] = await Promise.all([ ] = await Promise.all([
supabase supabase
.schema('public') .schema('public')
@@ -403,7 +411,7 @@ export async function getAnalysisResultsForDoctor(
.eq('is_personal_account', true) .eq('is_personal_account', true)
.eq('primary_owner_user_id', userId) .eq('primary_owner_user_id', userId)
.limit(1), .limit(1),
await supabase supabase
.schema('medreport') .schema('medreport')
.from('doctor_analysis_feedback') .from('doctor_analysis_feedback')
.select(`*`) .select(`*`)
@@ -412,9 +420,39 @@ export async function getAnalysisResultsForDoctor(
firstAnalysisResponse.analysis_responses.analysis_order_id.id, firstAnalysisResponse.analysis_responses.analysis_order_id.id,
) )
.limit(1), .limit(1),
supabase
.schema('medreport')
.from('analysis_response_elements')
.select(
`
*,
analysis_responses!inner(
user_id
)
`,
)
.in(
'analysis_element_original_id',
responseElementAnalysisElementOriginalIds,
)
.not(
'analysis_response_id',
'eq',
firstAnalysisResponse.analysis_response_id,
)
.eq(
'analysis_responses.user_id',
firstAnalysisResponse.analysis_responses.user_id,
)
.order('response_time'),
]); ]);
if (medusaOrderError || accountError || feedbackError) { if (
medusaOrderError ||
accountError ||
feedbackError ||
previousAnalysesError
) {
throw new Error('Something went wrong.'); throw new Error('Something went wrong.');
} }
@@ -433,8 +471,25 @@ export async function getAnalysisResultsForDoctor(
account_params, account_params,
} = accountWithParams[0]; } = accountWithParams[0];
const analysisResponseElementsWithPreviousData = [];
for (const analysisResponseElement of analysisResponseElements) {
const latestPreviousAnalysis = previousAnalyses.find(
({ analysis_element_original_id, response_time }) =>
analysis_element_original_id ===
analysisResponseElement.analysis_element_original_id &&
isBefore(
new Date(response_time),
new Date(analysisResponseElement.response_time),
),
);
analysisResponseElementsWithPreviousData.push({
...analysisResponseElement,
latestPreviousAnalysis,
});
}
return { return {
analysisResponse, analysisResponse: analysisResponseElementsWithPreviousData,
order: { order: {
title: medusaOrderItems?.[0]?.item_id.product_title ?? '-', title: medusaOrderItems?.[0]?.item_id.product_title ?? '-',
isPackage: isPackage:

View File

@@ -13,7 +13,7 @@ export function InfoTooltip({
content, content,
icon, icon,
}: { }: {
content?: string; content?: string | null;
icon?: JSX.Element; icon?: JSX.Element;
}) { }) {
if (!content) return null; if (!content) return null;

View File

@@ -549,6 +549,7 @@ export type Database = {
analysis_element_original_id: string analysis_element_original_id: string
analysis_name: string | null analysis_name: string | null
analysis_response_id: number analysis_response_id: number
comment: string | null
created_at: string created_at: string
id: number id: number
norm_lower: number | null norm_lower: number | null
@@ -566,6 +567,7 @@ export type Database = {
analysis_element_original_id: string analysis_element_original_id: string
analysis_name?: string | null analysis_name?: string | null
analysis_response_id: number analysis_response_id: number
comment?: string | null
created_at?: string created_at?: string
id?: number id?: number
norm_lower?: number | null norm_lower?: number | null
@@ -583,6 +585,7 @@ export type Database = {
analysis_element_original_id?: string analysis_element_original_id?: string
analysis_name?: string | null analysis_name?: string | null
analysis_response_id?: number analysis_response_id?: number
comment?: string | null
created_at?: string created_at?: string
id?: number id?: number
norm_lower?: number | null norm_lower?: number | null
@@ -1723,7 +1726,9 @@ export type Database = {
Returns: Json Returns: Json
} }
create_team_account: { create_team_account: {
Args: { account_name: string; new_personal_code: string } Args:
| { account_name: string }
| { account_name: string; new_personal_code: string }
Returns: { Returns: {
application_role: Database["medreport"]["Enums"]["application_role"] application_role: Database["medreport"]["Enums"]["application_role"]
city: string | null city: string | null
@@ -1911,6 +1916,22 @@ export type Database = {
} }
Returns: undefined Returns: undefined
} }
update_analysis_order_status: {
Args: {
order_id: number
medusa_order_id_param: string
status_param: Database["medreport"]["Enums"]["analysis_order_status"]
}
Returns: {
analysis_element_ids: number[] | null
analysis_ids: number[] | null
created_at: string
id: number
medusa_order_id: string
status: Database["medreport"]["Enums"]["analysis_order_status"]
user_id: string
}
}
upsert_order: { upsert_order: {
Args: { Args: {
target_account_id: string target_account_id: string

View File

@@ -21,6 +21,7 @@ export function useSignUpWithEmailAndPassword() {
const response = await client.auth.signUp({ const response = await client.auth.signUp({
...credentials, ...credentials,
options: { options: {
emailRedirectTo,
captchaToken, captchaToken,
}, },
}); });

View File

@@ -115,5 +115,6 @@
"saveAsDraft": "Save as draft", "saveAsDraft": "Save as draft",
"confirm": "Confirm", "confirm": "Confirm",
"previous": "Previous", "previous": "Previous",
"next": "Next" "next": "Next",
"invalidDataError": "Invalid data submitted"
} }

View File

@@ -30,14 +30,17 @@
"phone": "Phone", "phone": "Phone",
"email": "Email", "email": "Email",
"results": "Analysis results", "results": "Analysis results",
"feedback": "Feedback", "feedback": "Summary",
"selectJob": "Select", "selectJob": "Select",
"unselectJob": "Deselect", "unselectJob": "Unselect",
"previousResults": "Previous results ({{date}})",
"labComment": "Lab comment",
"confirmFeedbackModal": { "confirmFeedbackModal": {
"title": "Confirm publishing feedback", "title": "Confirm publishing summary",
"description": "When confirmed, the feedback will be published to the patient." "description": "When confirmed, the summary will be published to the patient."
}, },
"updateFeedbackSuccess": "Feedback updated", "updateFeedbackSuccess": "Summary updated",
"updateFeedbackLoading": "Updating feedback...", "updateFeedbackLoading": "Updating summary...",
"updateFeedbackError": "Failed to update feedback" "updateFeedbackError": "Failed to update summary",
} "feedbackLengthError": "Summary must be at least 10 characters"
}

View File

@@ -134,5 +134,6 @@
"saveAsDraft": "Salvesta mustandina", "saveAsDraft": "Salvesta mustandina",
"confirm": "Kinnita", "confirm": "Kinnita",
"previous": "Eelmine", "previous": "Eelmine",
"next": "Järgmine" "next": "Järgmine",
"invalidDataError": "Vigased andmed"
} }

View File

@@ -30,14 +30,17 @@
"phone": "Telefon", "phone": "Telefon",
"email": "E-mail", "email": "E-mail",
"results": "Analüüside tulemused", "results": "Analüüside tulemused",
"feedback": "Tagasiside", "feedback": "Kokkuvõte",
"selectJob": "Vali", "selectJob": "Vali",
"unselectJob": "Loobu", "unselectJob": "Loobu",
"previousResults": "Eelnevad tulemused ({{date}})",
"labComment": "Labori kommentaarid",
"confirmFeedbackModal": { "confirmFeedbackModal": {
"title": "Kinnita tagasiside avaldamine", "title": "Kinnita kokkuvõtte avaldamine",
"description": "Tagasiside kinnitamisel avaldatakse see patsiendile." "description": "Kinnitamisel avaldatakse kokkuvõte patsiendile."
}, },
"updateFeedbackSuccess": "Tagasiside uuendatud", "updateFeedbackSuccess": "Kokkuvõte uuendatud",
"updateFeedbackLoading": "Tagasiside uuendatakse...", "updateFeedbackLoading": "Kokkuvõtet uuendatakse...",
"updateFeedbackError": "Tagasiside uuendamine ebaõnnestus" "updateFeedbackError": "Kokkuvõtte uuendamine ebaõnnestus",
"feedbackLengthError": "Kokkuvõte peab olema vähemalt 10 tähemärki pikk"
} }

View File

@@ -113,5 +113,6 @@
"saveAsDraft": "Save as draft", "saveAsDraft": "Save as draft",
"confirm": "Confirm", "confirm": "Confirm",
"previous": "Previous", "previous": "Previous",
"next": "Next" "next": "Next",
"invalidDataError": "Invalid data submitted"
} }

View File

@@ -30,14 +30,17 @@
"phone": "Phone", "phone": "Phone",
"email": "Email", "email": "Email",
"results": "Analysis results", "results": "Analysis results",
"feedback": "Feedback", "feedback": "Summary",
"selectJob": "Select", "selectJob": "Select",
"unselectJob": "Deselect", "unselectJob": "Unselect",
"previousResults": "Previous results ({{date}})",
"labComment": "Lab comment",
"confirmFeedbackModal": { "confirmFeedbackModal": {
"title": "Confirm publishing feedback", "title": "Confirm publishing summary",
"description": "When confirmed, the feedback will be published to the patient." "description": "When confirmed, the summary will be published to the patient."
}, },
"updateFeedbackSuccess": "Feedback updated", "updateFeedbackSuccess": "Summary updated",
"updateFeedbackLoading": "Updating feedback...", "updateFeedbackLoading": "Updating summary...",
"updateFeedbackError": "Failed to update feedback" "updateFeedbackError": "Failed to update summary",
"feedbackLengthError": "Summary must be at least 10 characters"
} }

View File

@@ -0,0 +1,2 @@
ALTER TABLE medreport.analysis_response_elements
ADD comment text;