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

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

View File

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

View File

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

View File

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