Add user analysis view bars + nested element logic to doctor view also

This commit is contained in:
2025-11-12 12:21:53 +02:00
parent 2c0634f444
commit 1b17dd845a
5 changed files with 348 additions and 101 deletions

View File

@@ -2,7 +2,6 @@
import React, { ReactElement, ReactNode, 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'; import { format } from 'date-fns';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
@@ -10,23 +9,24 @@ import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { AnalysisElement } from '~/lib/services/analysis-element.service'; import { AnalysisElement } from '~/lib/services/analysis-element.service';
import { NestedAnalysisElement } from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema';
import AnalysisLevelBar, { import AnalysisLevelBar, {
AnalysisLevelBarSkeleton, AnalysisLevelBarSkeleton,
AnalysisResultLevel, AnalysisResultLevel,
} from './analysis-level-bar'; } from './analysis-level-bar';
export type AnalysisResultForDisplay = Pick< export type AnalysisResultForDisplay = {
UserAnalysisElement, norm_status?: number | null;
| 'norm_status' response_value?: number | null;
| 'response_value' unit?: string | null;
| 'unit' norm_lower_included?: boolean | null;
| 'norm_lower_included' norm_upper_included?: boolean | null;
| 'norm_upper_included' norm_lower?: number | null;
| 'norm_lower' norm_upper?: number | null;
| 'norm_upper' response_time?: string | null;
| 'response_time' nestedElements?: NestedAnalysisElement[];
>; };
const AnalysisDoctor = ({ const AnalysisDoctor = ({
analysisElement, analysisElement,
@@ -72,6 +72,12 @@ const AnalysisDoctor = ({
return `${normLower ?? '...'} - ${normUpper ?? '...'}`; return `${normLower ?? '...'} - ${normUpper ?? '...'}`;
}, [normLower, normUpper]); }, [normLower, normUpper]);
const nestedElements = results?.nestedElements ?? null;
const hasNestedElements =
Array.isArray(nestedElements) && nestedElements.length > 0;
const isAnalysisLevelBarHidden = isCancelled || !results || hasNestedElements;
return ( return (
<div className="border-border rounded-lg border px-5"> <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 flex-col items-center justify-between gap-2 py-3 sm:h-[65px] sm:flex-row sm:gap-0">
@@ -101,7 +107,7 @@ const AnalysisDoctor = ({
</div> </div>
)} )}
</div> </div>
{results ? ( {isAnalysisLevelBarHidden ? null : (
<> <>
<div className="flex items-center gap-3 sm:ml-auto"> <div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">{value}</div> <div className="font-semibold">{value}</div>
@@ -120,7 +126,22 @@ const AnalysisDoctor = ({
/> />
{endIcon || <div className="mx-2 w-4" />} {endIcon || <div className="mx-2 w-4" />}
</> </>
) : isCancelled ? null : ( )}
{(() => {
// If parent has nested elements, don't show anything
if (hasNestedElements) {
return null;
}
// If we're showing the level bar, don't show waiting
if (!isAnalysisLevelBarHidden) {
return null;
}
// If cancelled, don't show waiting
if (isCancelled) {
return null;
}
// Otherwise, show waiting for results
return (
<> <>
<div className="flex items-center gap-3 sm:ml-auto"> <div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold"> <div className="font-semibold">
@@ -130,7 +151,8 @@ const AnalysisDoctor = ({
<div className="mx-8 w-[60px]"></div> <div className="mx-8 w-[60px]"></div>
<AnalysisLevelBarSkeleton /> <AnalysisLevelBarSkeleton />
</> </>
)} );
})()}
</div> </div>
</div> </div>
); );

View File

@@ -105,35 +105,38 @@ const AnalysisLevelBar = ({
// If only upper bound exists // If only upper bound exists
if (lower === null && upper !== null) { if (lower === null && upper !== null) {
if (value <= upper) { if (value <= upper!) {
return Math.min(75, (value / upper) * 75); // Show in left 75% of normal range return Math.min(75, (value / upper!) * 75); // Show in left 75% of normal range
} }
return 100; // Beyond upper bound return 100; // Beyond upper bound
} }
// If only lower bound exists // If only lower bound exists
if (upper === null && lower !== null) { if (upper === null && lower !== null) {
if (value >= lower) { if (value >= lower!) {
// Value is in normal range (above lower bound) // Value is in normal range (above lower bound)
// Position proportionally in the normal range section // Position proportionally in the normal range section
const normalizedPosition = Math.min((value - lower) / (lower * 0.5), 1); // Use 50% of lower as scale const normalizedPosition = Math.min(
(value - lower!) / (lower! * 0.5),
1,
); // Use 50% of lower as scale
return normalizedPosition * 100; return normalizedPosition * 100;
} }
// Value is below lower bound - position in the "below normal" section // Value is below lower bound - position in the "below normal" section
const belowPosition = Math.max(0, Math.min(1, value / lower)); const belowPosition = Math.max(0, Math.min(1, value / lower!));
return belowPosition * 100; return belowPosition * 100;
} }
// Both bounds exist // Both bounds exist
if (lower !== null && upper !== null) { if (lower !== null && upper !== null) {
if (value < lower) { if (value < lower!) {
return 0; // Below normal range return 0; // Below normal range
} }
if (value > upper) { if (value > upper!) {
return 100; // Above normal range return 100; // Above normal range
} }
// Within normal range // Within normal range
return ((value - lower) / (upper - lower)) * 100; return ((value - lower!) / (upper! - lower!)) * 100;
} }
return 50; // Fallback return 50; // Fallback
@@ -145,18 +148,10 @@ const AnalysisLevelBar = ({
const isCritical = level === AnalysisResultLevel.CRITICAL; const isCritical = level === AnalysisResultLevel.CRITICAL;
const isPending = level === null; const isPending = level === null;
// If pending results, show gray bar
if (isPending) {
return (
<div className="w-60% mt-4 flex h-3 max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
<Level color="gray-200" isFirst isLast />
</div>
);
}
// Show appropriate levels based on available norm bounds // Show appropriate levels based on available norm bounds
const hasLowerBound = lower !== null; const hasLowerBound = lower !== null;
// Calculate level configuration (must be called before any returns)
const [first, second, third] = useMemo(() => { const [first, second, third] = useMemo(() => {
const [warning, normal, critical] = [ const [warning, normal, critical] = [
{ {
@@ -196,6 +191,15 @@ const AnalysisLevelBar = ({
hasLowerBound, hasLowerBound,
]); ]);
// If pending results, show gray bar
if (isPending) {
return (
<div className="w-60% mt-4 flex h-3 max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
<Level color="gray-200" isFirst isLast />
</div>
);
}
return ( return (
<div <div
className={cn( className={cn(

View File

@@ -1,5 +1,7 @@
'use client'; 'use client';
import React from 'react';
import { CaretDownIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons'; import { CaretDownIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -23,6 +25,7 @@ export default function DoctorAnalysisWrapper({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<>
<Collapsible className="w-full" key={analysisData.id}> <Collapsible className="w-full" key={analysisData.id}>
<CollapsibleTrigger <CollapsibleTrigger
disabled={!analysisData.latestPreviousAnalysis} disabled={!analysisData.latestPreviousAnalysis}
@@ -64,7 +67,7 @@ export default function DoctorAnalysisWrapper({
</CollapsibleTrigger> </CollapsibleTrigger>
{analysisData.latestPreviousAnalysis && ( {analysisData.latestPreviousAnalysis && (
<CollapsibleContent> <CollapsibleContent>
<div className="my-1 flex flex-col"> <div className="my-1 flex flex-col gap-2">
<AnalysisDoctor <AnalysisDoctor
endIcon={ endIcon={
analysisData.latestPreviousAnalysis.comment && ( analysisData.latestPreviousAnalysis.comment && (
@@ -89,15 +92,62 @@ export default function DoctorAnalysisWrapper({
analysisElement={{ analysisElement={{
analysis_name_lab: t('doctor:previousResults', { analysis_name_lab: t('doctor:previousResults', {
date: formatDate( date: formatDate(
analysisData.latestPreviousAnalysis.response_time, analysisData.latestPreviousAnalysis.response_time!,
), ),
}), }),
}} }}
results={analysisData.latestPreviousAnalysis} results={analysisData.latestPreviousAnalysis}
/> />
{analysisData.latestPreviousAnalysis.nestedElements?.map(
(nestedElement, nestedIndex) => (
<div
key={`prev-nested-${nestedElement.analysisElementOriginalId}-${nestedIndex}`}
className="ml-8"
>
<AnalysisDoctor
analysisElement={{
analysis_name_lab: nestedElement.analysisNameLab ?? '',
}}
results={{
norm_status: nestedElement.normStatus,
response_value: nestedElement.responseValue,
unit: nestedElement.unit,
norm_lower: nestedElement.normLower,
norm_upper: nestedElement.normUpper,
norm_lower_included: nestedElement.normLowerIncluded,
norm_upper_included: nestedElement.normUpperIncluded,
response_time: nestedElement.responseTime,
}}
/>
</div>
),
)}
</div> </div>
</CollapsibleContent> </CollapsibleContent>
)} )}
</Collapsible> </Collapsible>
{analysisData.nestedElements?.map((nestedElement, nestedIndex) => (
<div
key={`nested-${nestedElement.analysisElementOriginalId}-${nestedIndex}`}
className="ml-8"
>
<AnalysisDoctor
analysisElement={{
analysis_name_lab: nestedElement.analysisNameLab ?? '',
}}
results={{
norm_status: nestedElement.normStatus,
response_value: nestedElement.responseValue,
unit: nestedElement.unit,
norm_lower: nestedElement.normLower,
norm_upper: nestedElement.normUpper,
norm_lower_included: nestedElement.normLowerIncluded,
norm_upper_included: nestedElement.normUpperIncluded,
response_time: nestedElement.responseTime,
}}
/>
</div>
))}
</>
); );
} }

View File

@@ -52,6 +52,23 @@ export const AnalysisResponsesSchema = z.object({
}); });
export type AnalysisResponses = z.infer<typeof AnalysisResponsesSchema>; export type AnalysisResponses = z.infer<typeof AnalysisResponsesSchema>;
// Nested element schema (used recursively)
export const NestedAnalysisElementSchema = z.object({
analysisElementOriginalId: z.string(),
analysisName: z.string().optional().nullable(),
unit: z.string().nullable(),
normLower: z.number().nullable(),
normUpper: z.number().nullable(),
normStatus: z.number().nullable(),
responseTime: z.string().nullable(),
responseValue: z.number().nullable(),
normLowerIncluded: z.boolean(),
normUpperIncluded: z.boolean(),
status: z.number(),
analysisNameLab: z.string().optional().nullable(),
});
export type NestedAnalysisElement = z.infer<typeof NestedAnalysisElementSchema>;
export const AnalysisResponseSchema = z.object({ export const AnalysisResponseSchema = z.object({
id: z.number(), id: z.number(),
analysis_response_id: z.number(), analysis_response_id: z.number(),
@@ -69,6 +86,7 @@ export const AnalysisResponseSchema = z.object({
analysis_name: z.string().nullable(), analysis_name: z.string().nullable(),
analysis_responses: AnalysisResponsesSchema, analysis_responses: AnalysisResponsesSchema,
comment: z.string().nullable(), comment: z.string().nullable(),
nestedElements: z.array(NestedAnalysisElementSchema).optional(),
latestPreviousAnalysis: z latestPreviousAnalysis: z
.object({ .object({
id: z.number(), id: z.number(),
@@ -86,6 +104,7 @@ 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(),
comment: z.string().nullable(), comment: z.string().nullable(),
nestedElements: z.array(NestedAnalysisElementSchema).optional(),
}) })
.optional() .optional()
.nullable(), .nullable(),

View File

@@ -5,12 +5,16 @@ import { isBefore } from 'date-fns';
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates'; import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
import { getLogger } from '@kit/shared/logger'; import { getLogger } from '@kit/shared/logger';
import { getFullName } from '@kit/shared/utils'; import type { UuringuVastus, ResponseUuring } from '@kit/shared/types/medipost-analysis';
import { getFullName, toArray } from '@kit/shared/utils';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createUserAnalysesApi } from '@kit/user-analyses/api'; import { createUserAnalysesApi } from '@kit/user-analyses/api';
import { sendEmailFromTemplate } from '../../../../../../../lib/services/mailer.service'; import { sendEmailFromTemplate } from '../../../../../../../lib/services/mailer.service';
import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema'; import {
AnalysisResultDetails,
NestedAnalysisElement,
} from '../schema/doctor-analysis-detail-view.schema';
import { import {
AnalysisResponseBase, AnalysisResponseBase,
DoctorAnalysisFeedbackTable, DoctorAnalysisFeedbackTable,
@@ -20,6 +24,63 @@ import {
} from '../schema/doctor-analysis.schema'; } from '../schema/doctor-analysis.schema';
import { ErrorReason } from '../schema/error.type'; import { ErrorReason } from '../schema/error.type';
function mapUuringVastus({ uuringVastus }: { uuringVastus?: UuringuVastus }) {
const vastuseVaartus = uuringVastus?.VastuseVaartus;
const responseValue = (() => {
const valueAsNumber = Number(vastuseVaartus);
if (isNaN(valueAsNumber)) {
return null;
}
return valueAsNumber;
})();
const responseValueNumber = Number(responseValue);
const responseValueIsNumeric = !isNaN(responseValueNumber);
return {
normLower: uuringVastus?.NormAlum?.['#text'] ?? null,
normUpper: uuringVastus?.NormYlem?.['#text'] ?? null,
normStatus: (uuringVastus?.NormiStaatus ?? null) as number | null,
responseTime: uuringVastus?.VastuseAeg ?? null,
responseValue:
responseValueIsNumeric ? (responseValueNumber ?? null) : null,
normLowerIncluded:
uuringVastus?.NormAlum?.['@_kaasaarvatud']?.toLowerCase() === 'jah',
normUpperIncluded:
uuringVastus?.NormYlem?.['@_kaasaarvatud']?.toLowerCase() === 'jah',
};
}
function parseNestedElements(
originalResponseElement: ResponseUuring | null | undefined,
status: number,
): NestedAnalysisElement[] {
if (!originalResponseElement?.UuringuElement) {
return [];
}
const nestedElements = toArray(originalResponseElement.UuringuElement);
return nestedElements.map<NestedAnalysisElement>((element) => {
const mappedResponse = mapUuringVastus({
uuringVastus: element.UuringuVastus as UuringuVastus | undefined,
});
return {
analysisElementOriginalId: element.UuringId,
analysisName: undefined, // Will be populated later from analysis_elements table
unit: element.Mootyhik ?? null,
normLower: mappedResponse.normLower,
normUpper: mappedResponse.normUpper,
normStatus: mappedResponse.normStatus,
responseTime: mappedResponse.responseTime,
responseValue: mappedResponse.responseValue,
normLowerIncluded: mappedResponse.normLowerIncluded,
normUpperIncluded: mappedResponse.normUpperIncluded,
status,
analysisNameLab: element.UuringNimi,
};
});
}
async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) { async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerClient();
@@ -388,7 +449,8 @@ export async function getAnalysisResultsForDoctor(
.from(`analysis_response_elements`) .from(`analysis_response_elements`)
.select( .select(
`*, `*,
analysis_responses(user_id, analysis_order:analysis_order_id(id,medusa_order_id, analysis_element_ids))`, analysis_responses(user_id, analysis_order:analysis_order_id(id,medusa_order_id, analysis_element_ids)),
original_response_element`,
) )
.eq('analysis_response_id', analysisResponseId); .eq('analysis_response_id', analysisResponseId);
@@ -452,7 +514,8 @@ export async function getAnalysisResultsForDoctor(
*, *,
analysis_responses!inner( analysis_responses!inner(
user_id user_id
) ),
original_response_element
`, `,
) )
.in( .in(
@@ -491,8 +554,14 @@ export async function getAnalysisResultsForDoctor(
preferred_locale, preferred_locale,
} = accountWithParams[0]; } = accountWithParams[0];
// Parse nested elements for current and previous analyses
const analysisResponseElementsWithPreviousData = []; const analysisResponseElementsWithPreviousData = [];
for (const analysisResponseElement of analysisResponsesData) { for (const analysisResponseElement of analysisResponsesData) {
const nestedElements = parseNestedElements(
analysisResponseElement.original_response_element as ResponseUuring,
Number(analysisResponseElement.status),
);
const latestPreviousAnalysis = previousAnalyses.find( const latestPreviousAnalysis = previousAnalyses.find(
({ analysis_element_original_id, response_time }) => { ({ analysis_element_original_id, response_time }) => {
if (response_time && analysisResponseElement.response_time) { if (response_time && analysisResponseElement.response_time) {
@@ -507,12 +576,95 @@ export async function getAnalysisResultsForDoctor(
} }
}, },
); );
// Parse nested elements for previous analysis if it exists
const latestPreviousAnalysisWithNested = latestPreviousAnalysis
? {
...latestPreviousAnalysis,
nestedElements: parseNestedElements(
latestPreviousAnalysis.original_response_element as ResponseUuring,
Number(latestPreviousAnalysis.status),
),
}
: undefined;
analysisResponseElementsWithPreviousData.push({ analysisResponseElementsWithPreviousData.push({
...analysisResponseElement, ...analysisResponseElement,
latestPreviousAnalysis, nestedElements,
latestPreviousAnalysis: latestPreviousAnalysisWithNested,
}); });
} }
// Collect all nested element IDs to fetch their names
const nestedElementIds = analysisResponseElementsWithPreviousData
.flatMap((element) => [
...(element.nestedElements?.map((ne) => ne.analysisElementOriginalId) ??
[]),
...(element.latestPreviousAnalysis?.nestedElements?.map(
(ne) => ne.analysisElementOriginalId,
) ?? []),
])
.filter(Boolean);
// Fetch analysis names for nested elements
if (nestedElementIds.length > 0) {
const { data: nestedAnalysisElements, error: nestedAnalysisElementsError } =
await supabase
.schema('medreport')
.from('analysis_elements')
.select('*')
.in('analysis_id_original', nestedElementIds);
if (!nestedAnalysisElementsError && nestedAnalysisElements) {
// Populate analysis names for current nested elements
for (const element of analysisResponseElementsWithPreviousData) {
if (element.nestedElements) {
for (const nestedElement of element.nestedElements) {
const analysisElement = nestedAnalysisElements.find(
(ae) =>
ae.analysis_id_original ===
nestedElement.analysisElementOriginalId,
);
if (analysisElement) {
nestedElement.analysisName =
analysisElement.analysis_name_lab as string | undefined;
}
}
// Sort nested elements by name
element.nestedElements.sort(
(a, b) => a.analysisName?.localeCompare(b.analysisName ?? '') ?? 0,
);
}
// Populate analysis names for previous nested elements
if (element.latestPreviousAnalysis?.nestedElements) {
for (const nestedElement of element.latestPreviousAnalysis
.nestedElements) {
const analysisElement = nestedAnalysisElements.find(
(ae) =>
ae.analysis_id_original ===
nestedElement.analysisElementOriginalId,
);
if (analysisElement) {
nestedElement.analysisName =
analysisElement.analysis_name_lab as string | undefined;
}
}
// Sort nested elements by name
element.latestPreviousAnalysis.nestedElements.sort(
(a, b) => a.analysisName?.localeCompare(b.analysisName ?? '') ?? 0,
);
}
}
} else {
console.error(
'Failed to get nested analysis elements by ids=',
nestedElementIds,
nestedAnalysisElementsError,
);
}
}
return { return {
analysisResponse: analysisResponseElementsWithPreviousData, analysisResponse: analysisResponseElementsWithPreviousData,
order: { order: {