Merge branch 'main' into MED-57
This commit is contained in:
@@ -19,7 +19,7 @@ import {
|
||||
} from '@kit/doctor/schema/doctor-analysis-detail-view.schema';
|
||||
import {
|
||||
DoctorAnalysisFeedbackForm,
|
||||
doctorAnalysisFeedbackSchema,
|
||||
doctorAnalysisFeedbackFormSchema,
|
||||
} from '@kit/doctor/schema/doctor-analysis.schema';
|
||||
import ConfirmationModal from '@kit/shared/components/confirmation-modal';
|
||||
import { getFullName } from '@kit/shared/utils';
|
||||
@@ -36,9 +36,11 @@ import { toast } from '@kit/ui/sonner';
|
||||
import { Textarea } from '@kit/ui/textarea';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import Analysis from '~/home/(user)/(dashboard)/analysis-results/_components/analysis';
|
||||
import { bmiFromMetric } from '~/lib/utils';
|
||||
|
||||
import DoctorAnalysisWrapper from './doctor-analysis-wrapper';
|
||||
import DoctorJobSelect from './doctor-job-select';
|
||||
|
||||
export default function AnalysisView({
|
||||
patient,
|
||||
order,
|
||||
@@ -54,16 +56,20 @@ export default function AnalysisView({
|
||||
|
||||
const { data: user } = useUser();
|
||||
|
||||
const isInProgress =
|
||||
const isInProgress = !!(
|
||||
!!feedback?.status &&
|
||||
feedback?.doctor_user_id &&
|
||||
feedback?.status !== 'COMPLETED';
|
||||
feedback?.status !== 'COMPLETED'
|
||||
);
|
||||
const isCurrentDoctorJob =
|
||||
!!feedback?.doctor_user_id && feedback?.doctor_user_id === user?.id;
|
||||
const isReadOnly =
|
||||
!isInProgress ||
|
||||
(!!feedback?.doctor_user_id && feedback?.doctor_user_id !== user?.id);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(doctorAnalysisFeedbackSchema),
|
||||
resolver: zodResolver(doctorAnalysisFeedbackFormSchema),
|
||||
reValidateMode: 'onChange',
|
||||
defaultValues: {
|
||||
feedbackValue: feedback?.value ?? '',
|
||||
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();
|
||||
onSubmit(formData, 'DRAFT');
|
||||
};
|
||||
|
||||
const handleCompleteSubmit = () => {
|
||||
const handleCompleteSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmOpen(true);
|
||||
};
|
||||
|
||||
@@ -119,16 +135,31 @@ export default function AnalysisView({
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<Trans
|
||||
i18nKey={getResultSetName(
|
||||
order.title,
|
||||
order.isPackage,
|
||||
Object.keys(analyses)?.length,
|
||||
)}
|
||||
/>
|
||||
</h3>
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="xs:flex xs:justify-between">
|
||||
<h3>
|
||||
<Trans
|
||||
i18nKey={getResultSetName(
|
||||
order.title,
|
||||
order.isPackage,
|
||||
Object.keys(analyses)?.length,
|
||||
)}
|
||||
/>
|
||||
</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">
|
||||
<Trans i18nKey="doctor:name" />
|
||||
</div>
|
||||
@@ -152,11 +183,11 @@ export default function AnalysisView({
|
||||
<div className="font-bold">
|
||||
<Trans i18nKey="doctor:bmi" />
|
||||
</div>
|
||||
<div>{bmiFromMetric(patient?.height ?? 0, patient?.weight ?? 0)}</div>
|
||||
<div>{bmiFromMetric(patient?.weight ?? 0, patient?.height ?? 0)}</div>
|
||||
<div className="font-bold">
|
||||
<Trans i18nKey="doctor:smoking" />
|
||||
</div>
|
||||
<div></div>
|
||||
<div>-</div>
|
||||
<div className="font-bold">
|
||||
<Trans i18nKey="doctor:phone" />
|
||||
</div>
|
||||
@@ -166,30 +197,37 @@ export default function AnalysisView({
|
||||
</div>
|
||||
<div>{patient.email}</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>
|
||||
<Trans i18nKey="doctor:results" />
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{analyses.map((analysisData) => {
|
||||
return (
|
||||
<Analysis
|
||||
<DoctorAnalysisWrapper
|
||||
key={analysisData.id}
|
||||
analysisElement={{
|
||||
analysis_name_lab: analysisData.analysis_name,
|
||||
}}
|
||||
results={analysisData}
|
||||
analysisData={analysisData}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h3>
|
||||
<Trans i18nKey="doctor:feedback" />
|
||||
</h3>
|
||||
|
||||
<p>{feedback?.value ?? '-'}</p>
|
||||
|
||||
{!isReadOnly && (
|
||||
<Form {...form}>
|
||||
<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
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDraftSubmit();
|
||||
}}
|
||||
onClick={handleDraftSubmit}
|
||||
disabled={isReadOnly}
|
||||
className="xs:w-1/4 w-full"
|
||||
>
|
||||
<Trans i18nKey="common:saveAsDraft" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleCompleteSubmit();
|
||||
}}
|
||||
type="button"
|
||||
onClick={handleCompleteSubmit}
|
||||
disabled={isReadOnly}
|
||||
className="xs:w-1/4 w-full"
|
||||
>
|
||||
<Trans i18nKey="common:save" />
|
||||
</Button>
|
||||
|
||||
103
app/doctor/_components/doctor-analysis-wrapper.tsx
Normal file
103
app/doctor/_components/doctor-analysis-wrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
app/doctor/_components/doctor-job-select.tsx
Normal file
108
app/doctor/_components/doctor-job-select.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import { usePathname } from 'next/navigation';
|
||||
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
|
||||
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 {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -20,11 +23,6 @@ import {
|
||||
} from '@kit/ui/shadcn-sidebar';
|
||||
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({
|
||||
accounts,
|
||||
}: {
|
||||
@@ -75,10 +73,7 @@ export function DoctorSidebar({
|
||||
isActive={path === pathsConfig.app.myJobs}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
className={'flex gap-2.5'}
|
||||
href={pathsConfig.app.myJobs}
|
||||
>
|
||||
<Link className={'flex gap-2.5'} href={pathsConfig.app.myJobs}>
|
||||
<LayoutDashboard className={'h-4'} />
|
||||
<Trans i18nKey={'doctor:sidebar.myReviews'} />
|
||||
</Link>
|
||||
|
||||
@@ -3,15 +3,10 @@
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
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 { ResponseTable } from '@kit/doctor/schema/doctor-analysis.schema';
|
||||
import { pathsConfig } from '@kit/shared/config';
|
||||
@@ -28,94 +23,7 @@ import {
|
||||
TableRow,
|
||||
} from '@kit/ui/table';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
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}</>;
|
||||
}
|
||||
import DoctorJobSelect from './doctor-job-select';
|
||||
|
||||
export default function ResultsTable({
|
||||
results = [],
|
||||
@@ -272,7 +180,7 @@ export default function ResultsTable({
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DoctorCell
|
||||
<DoctorJobSelect
|
||||
doctorUserId={result.doctor?.primary_owner_user_id}
|
||||
doctorName={getFullName(
|
||||
result.doctor?.name,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { cache } from 'react';
|
||||
|
||||
import { getAnalysisResultsForDoctor } from '@kit/doctor/services/doctor-analysis.service';
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
import AnalysisView from '../../_components/analysis-view';
|
||||
import { DoctorGuard } from '../../_components/doctor-guard';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'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 { format } from 'date-fns';
|
||||
@@ -37,9 +37,13 @@ export enum AnalysisStatus {
|
||||
const Analysis = ({
|
||||
analysisElement,
|
||||
results,
|
||||
startIcon,
|
||||
endIcon,
|
||||
}: {
|
||||
analysisElement: Pick<AnalysisElement, 'analysis_name_lab'>;
|
||||
results?: AnalysisResultForDisplay;
|
||||
startIcon?: ReactElement | null;
|
||||
endIcon?: ReactNode | null;
|
||||
}) => {
|
||||
const name = analysisElement.analysis_name_lab || '';
|
||||
const status = results?.norm_status || AnalysisStatus.NORMAL;
|
||||
@@ -76,59 +80,66 @@ const Analysis = ({
|
||||
}, [results, value, normLower]);
|
||||
|
||||
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="flex items-center gap-2 font-semibold">
|
||||
{name}
|
||||
{results?.response_time && (
|
||||
<div
|
||||
className="group/tooltip relative"
|
||||
onClick={() => setShowTooltip(!showTooltip)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
<Info className="hover" />{' '}
|
||||
<div className="border-border rounded-lg border px-5">
|
||||
<div className="flex flex-col items-center justify-between gap-2 py-3 sm:h-[65px] sm:flex-row sm:gap-0">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
{startIcon || <div className="w-4" />}
|
||||
{name}
|
||||
{results?.response_time && (
|
||||
<div
|
||||
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 },
|
||||
)}
|
||||
className="group/tooltip relative"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowTooltip(!showTooltip);
|
||||
}}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
<Trans i18nKey="analysis-results:analysisDate" />
|
||||
{': '}
|
||||
{format(new Date(results.response_time), 'dd.MM.yyyy HH:mm')}
|
||||
<Info className="hover" />{' '}
|
||||
<div
|
||||
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>
|
||||
{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>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -637,6 +637,7 @@ async function syncPrivateMessage({
|
||||
unit: element.Mootyhik ?? null,
|
||||
original_response_element: element,
|
||||
analysis_name: element.UuringNimi || element.KNimetus,
|
||||
comment: element.UuringuKommentaar
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export const selectJobAction = doctorAction(
|
||||
|
||||
logger.info({ analysisOrderId }, `Successfully selected`);
|
||||
|
||||
revalidateDoctorAnalysis();
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
@@ -60,7 +61,7 @@ export const unselectJobAction = doctorAction(
|
||||
{ analysisOrderId },
|
||||
`Successfully removed current doctor from job`,
|
||||
);
|
||||
|
||||
revalidateDoctorAnalysis();
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
@@ -101,6 +102,7 @@ export const giveFeedbackAction = doctorAction(
|
||||
);
|
||||
logger.info({ analysisOrderId }, `Successfully submitted feedback`);
|
||||
|
||||
revalidateDoctorAnalysis();
|
||||
return result;
|
||||
},
|
||||
{
|
||||
@@ -108,3 +110,7 @@ export const giveFeedbackAction = doctorAction(
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function revalidateDoctorAnalysis() {
|
||||
revalidatePath('/doctor/analysis');
|
||||
}
|
||||
|
||||
@@ -66,6 +66,27 @@ export const AnalysisResponseSchema = z.object({
|
||||
updated_at: z.string().nullable(),
|
||||
analysis_name: z.string().nullable(),
|
||||
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>;
|
||||
|
||||
|
||||
@@ -15,18 +15,19 @@ export type DoctorJobUnselect = z.infer<typeof doctorJobUnselectSchema>;
|
||||
|
||||
export const FeedbackStatus = z.enum(['STARTED', 'DRAFT', 'COMPLETED']);
|
||||
export const doctorAnalysisFeedbackFormSchema = z.object({
|
||||
feedbackValue: z.string().min(15),
|
||||
feedbackValue: z.string().min(10, { message: 'doctor:feedbackLengthError' }),
|
||||
userId: z.string().uuid(),
|
||||
});
|
||||
export type DoctorAnalysisFeedbackForm = z.infer<
|
||||
typeof doctorAnalysisFeedbackFormSchema
|
||||
>;
|
||||
export const doctorAnalysisFeedbackSchema = z.object({
|
||||
feedbackValue: z.string().min(15),
|
||||
feedbackValue: z.string(),
|
||||
userId: z.string().uuid(),
|
||||
analysisOrderId: z.number(),
|
||||
status: FeedbackStatus,
|
||||
});
|
||||
|
||||
export type DoctorAnalysisFeedback = z.infer<
|
||||
typeof doctorAnalysisFeedbackSchema
|
||||
>;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'server-only';
|
||||
|
||||
import { isBefore } from 'date-fns';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema';
|
||||
@@ -360,7 +362,7 @@ export async function getAnalysisResultsForDoctor(
|
||||
): Promise<AnalysisResultDetails> {
|
||||
const supabase = getSupabaseServerClient();
|
||||
|
||||
const { data: analysisResponse, error } = await supabase
|
||||
const { data: analysisResponseElements, error } = await supabase
|
||||
.schema('medreport')
|
||||
.from(`analysis_response_elements`)
|
||||
.select(
|
||||
@@ -373,20 +375,26 @@ export async function getAnalysisResultsForDoctor(
|
||||
throw new Error('Something went wrong.');
|
||||
}
|
||||
|
||||
const firstAnalysisResponse = analysisResponse?.[0];
|
||||
const firstAnalysisResponse = analysisResponseElements?.[0];
|
||||
const userId = firstAnalysisResponse?.analysis_responses.user_id;
|
||||
const medusaOrderId =
|
||||
firstAnalysisResponse?.analysis_responses?.analysis_order_id
|
||||
?.medusa_order_id;
|
||||
|
||||
if (!analysisResponse?.length || !userId || !medusaOrderId) {
|
||||
if (!analysisResponseElements?.length || !userId || !medusaOrderId) {
|
||||
throw new Error('Failed to retrieve full analysis data.');
|
||||
}
|
||||
|
||||
const responseElementAnalysisElementOriginalIds =
|
||||
analysisResponseElements.map(
|
||||
({ analysis_element_original_id }) => analysis_element_original_id,
|
||||
);
|
||||
|
||||
const [
|
||||
{ data: medusaOrderItems, error: medusaOrderError },
|
||||
{ data: accountWithParams, error: accountError },
|
||||
{ data: doctorFeedback, error: feedbackError },
|
||||
{ data: previousAnalyses, error: previousAnalysesError },
|
||||
] = await Promise.all([
|
||||
supabase
|
||||
.schema('public')
|
||||
@@ -403,7 +411,7 @@ export async function getAnalysisResultsForDoctor(
|
||||
.eq('is_personal_account', true)
|
||||
.eq('primary_owner_user_id', userId)
|
||||
.limit(1),
|
||||
await supabase
|
||||
supabase
|
||||
.schema('medreport')
|
||||
.from('doctor_analysis_feedback')
|
||||
.select(`*`)
|
||||
@@ -412,9 +420,39 @@ export async function getAnalysisResultsForDoctor(
|
||||
firstAnalysisResponse.analysis_responses.analysis_order_id.id,
|
||||
)
|
||||
.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.');
|
||||
}
|
||||
|
||||
@@ -433,8 +471,25 @@ export async function getAnalysisResultsForDoctor(
|
||||
account_params,
|
||||
} = 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 {
|
||||
analysisResponse,
|
||||
analysisResponse: analysisResponseElementsWithPreviousData,
|
||||
order: {
|
||||
title: medusaOrderItems?.[0]?.item_id.product_title ?? '-',
|
||||
isPackage:
|
||||
|
||||
@@ -13,7 +13,7 @@ export function InfoTooltip({
|
||||
content,
|
||||
icon,
|
||||
}: {
|
||||
content?: string;
|
||||
content?: string | null;
|
||||
icon?: JSX.Element;
|
||||
}) {
|
||||
if (!content) return null;
|
||||
|
||||
@@ -549,6 +549,7 @@ export type Database = {
|
||||
analysis_element_original_id: string
|
||||
analysis_name: string | null
|
||||
analysis_response_id: number
|
||||
comment: string | null
|
||||
created_at: string
|
||||
id: number
|
||||
norm_lower: number | null
|
||||
@@ -566,6 +567,7 @@ export type Database = {
|
||||
analysis_element_original_id: string
|
||||
analysis_name?: string | null
|
||||
analysis_response_id: number
|
||||
comment?: string | null
|
||||
created_at?: string
|
||||
id?: number
|
||||
norm_lower?: number | null
|
||||
@@ -583,6 +585,7 @@ export type Database = {
|
||||
analysis_element_original_id?: string
|
||||
analysis_name?: string | null
|
||||
analysis_response_id?: number
|
||||
comment?: string | null
|
||||
created_at?: string
|
||||
id?: number
|
||||
norm_lower?: number | null
|
||||
@@ -1723,7 +1726,9 @@ export type Database = {
|
||||
Returns: Json
|
||||
}
|
||||
create_team_account: {
|
||||
Args: { account_name: string; new_personal_code: string }
|
||||
Args:
|
||||
| { account_name: string }
|
||||
| { account_name: string; new_personal_code: string }
|
||||
Returns: {
|
||||
application_role: Database["medreport"]["Enums"]["application_role"]
|
||||
city: string | null
|
||||
@@ -1911,6 +1916,22 @@ export type Database = {
|
||||
}
|
||||
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: {
|
||||
Args: {
|
||||
target_account_id: string
|
||||
|
||||
@@ -21,6 +21,7 @@ export function useSignUpWithEmailAndPassword() {
|
||||
const response = await client.auth.signUp({
|
||||
...credentials,
|
||||
options: {
|
||||
emailRedirectTo,
|
||||
captchaToken,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -115,5 +115,6 @@
|
||||
"saveAsDraft": "Save as draft",
|
||||
"confirm": "Confirm",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
"next": "Next",
|
||||
"invalidDataError": "Invalid data submitted"
|
||||
}
|
||||
@@ -30,14 +30,17 @@
|
||||
"phone": "Phone",
|
||||
"email": "Email",
|
||||
"results": "Analysis results",
|
||||
"feedback": "Feedback",
|
||||
"feedback": "Summary",
|
||||
"selectJob": "Select",
|
||||
"unselectJob": "Deselect",
|
||||
"unselectJob": "Unselect",
|
||||
"previousResults": "Previous results ({{date}})",
|
||||
"labComment": "Lab comment",
|
||||
"confirmFeedbackModal": {
|
||||
"title": "Confirm publishing feedback",
|
||||
"description": "When confirmed, the feedback will be published to the patient."
|
||||
"title": "Confirm publishing summary",
|
||||
"description": "When confirmed, the summary will be published to the patient."
|
||||
},
|
||||
"updateFeedbackSuccess": "Feedback updated",
|
||||
"updateFeedbackLoading": "Updating feedback...",
|
||||
"updateFeedbackError": "Failed to update feedback"
|
||||
"updateFeedbackSuccess": "Summary updated",
|
||||
"updateFeedbackLoading": "Updating summary...",
|
||||
"updateFeedbackError": "Failed to update summary",
|
||||
"feedbackLengthError": "Summary must be at least 10 characters"
|
||||
}
|
||||
@@ -134,5 +134,6 @@
|
||||
"saveAsDraft": "Salvesta mustandina",
|
||||
"confirm": "Kinnita",
|
||||
"previous": "Eelmine",
|
||||
"next": "Järgmine"
|
||||
"next": "Järgmine",
|
||||
"invalidDataError": "Vigased andmed"
|
||||
}
|
||||
|
||||
@@ -30,14 +30,17 @@
|
||||
"phone": "Telefon",
|
||||
"email": "E-mail",
|
||||
"results": "Analüüside tulemused",
|
||||
"feedback": "Tagasiside",
|
||||
"feedback": "Kokkuvõte",
|
||||
"selectJob": "Vali",
|
||||
"unselectJob": "Loobu",
|
||||
"previousResults": "Eelnevad tulemused ({{date}})",
|
||||
"labComment": "Labori kommentaarid",
|
||||
"confirmFeedbackModal": {
|
||||
"title": "Kinnita tagasiside avaldamine",
|
||||
"description": "Tagasiside kinnitamisel avaldatakse see patsiendile."
|
||||
"title": "Kinnita kokkuvõtte avaldamine",
|
||||
"description": "Kinnitamisel avaldatakse kokkuvõte patsiendile."
|
||||
},
|
||||
"updateFeedbackSuccess": "Tagasiside uuendatud",
|
||||
"updateFeedbackLoading": "Tagasiside uuendatakse...",
|
||||
"updateFeedbackError": "Tagasiside uuendamine ebaõnnestus"
|
||||
"updateFeedbackSuccess": "Kokkuvõte uuendatud",
|
||||
"updateFeedbackLoading": "Kokkuvõtet uuendatakse...",
|
||||
"updateFeedbackError": "Kokkuvõtte uuendamine ebaõnnestus",
|
||||
"feedbackLengthError": "Kokkuvõte peab olema vähemalt 10 tähemärki pikk"
|
||||
}
|
||||
@@ -113,5 +113,6 @@
|
||||
"saveAsDraft": "Save as draft",
|
||||
"confirm": "Confirm",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
"next": "Next",
|
||||
"invalidDataError": "Invalid data submitted"
|
||||
}
|
||||
@@ -30,14 +30,17 @@
|
||||
"phone": "Phone",
|
||||
"email": "Email",
|
||||
"results": "Analysis results",
|
||||
"feedback": "Feedback",
|
||||
"feedback": "Summary",
|
||||
"selectJob": "Select",
|
||||
"unselectJob": "Deselect",
|
||||
"unselectJob": "Unselect",
|
||||
"previousResults": "Previous results ({{date}})",
|
||||
"labComment": "Lab comment",
|
||||
"confirmFeedbackModal": {
|
||||
"title": "Confirm publishing feedback",
|
||||
"description": "When confirmed, the feedback will be published to the patient."
|
||||
"title": "Confirm publishing summary",
|
||||
"description": "When confirmed, the summary will be published to the patient."
|
||||
},
|
||||
"updateFeedbackSuccess": "Feedback updated",
|
||||
"updateFeedbackLoading": "Updating feedback...",
|
||||
"updateFeedbackError": "Failed to update feedback"
|
||||
"updateFeedbackSuccess": "Summary updated",
|
||||
"updateFeedbackLoading": "Updating summary...",
|
||||
"updateFeedbackError": "Failed to update summary",
|
||||
"feedbackLengthError": "Summary must be at least 10 characters"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE medreport.analysis_response_elements
|
||||
ADD comment text;
|
||||
Reference in New Issue
Block a user