diff --git a/app/doctor/_components/analysis-doctor.tsx b/app/doctor/_components/analysis-doctor.tsx index 4b7e4ee..5166e26 100644 --- a/app/doctor/_components/analysis-doctor.tsx +++ b/app/doctor/_components/analysis-doctor.tsx @@ -2,7 +2,6 @@ import React, { ReactElement, ReactNode, useMemo, useState } from 'react'; -import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts'; import { format } from 'date-fns'; import { Info } from 'lucide-react'; @@ -10,23 +9,24 @@ import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; 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, { AnalysisLevelBarSkeleton, AnalysisResultLevel, } from './analysis-level-bar'; -export type AnalysisResultForDisplay = Pick< - UserAnalysisElement, - | 'norm_status' - | 'response_value' - | 'unit' - | 'norm_lower_included' - | 'norm_upper_included' - | 'norm_lower' - | 'norm_upper' - | 'response_time' ->; +export type AnalysisResultForDisplay = { + norm_status?: number | null; + response_value?: number | null; + unit?: string | null; + norm_lower_included?: boolean | null; + norm_upper_included?: boolean | null; + norm_lower?: number | null; + norm_upper?: number | null; + response_time?: string | null; + nestedElements?: NestedAnalysisElement[]; +}; const AnalysisDoctor = ({ analysisElement, @@ -72,6 +72,12 @@ const AnalysisDoctor = ({ return `${normLower ?? '...'} - ${normUpper ?? '...'}`; }, [normLower, normUpper]); + const nestedElements = results?.nestedElements ?? null; + const hasNestedElements = + Array.isArray(nestedElements) && nestedElements.length > 0; + + const isAnalysisLevelBarHidden = isCancelled || !results || hasNestedElements; + return (
@@ -101,7 +107,7 @@ const AnalysisDoctor = ({
)}
- {results ? ( + {isAnalysisLevelBarHidden ? null : ( <>
{value}
@@ -120,17 +126,33 @@ const AnalysisDoctor = ({ /> {endIcon ||
} - ) : 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 ( + <> +
+
+ +
+
+
+ + + ); + })()}
); diff --git a/app/doctor/_components/analysis-level-bar.tsx b/app/doctor/_components/analysis-level-bar.tsx index c22e357..f868ccb 100644 --- a/app/doctor/_components/analysis-level-bar.tsx +++ b/app/doctor/_components/analysis-level-bar.tsx @@ -105,35 +105,38 @@ const AnalysisLevelBar = ({ // If only upper bound exists if (lower === null && upper !== null) { - if (value <= upper) { - return Math.min(75, (value / upper) * 75); // Show in left 75% of normal range + if (value <= upper!) { + return Math.min(75, (value / upper!) * 75); // Show in left 75% of normal range } return 100; // Beyond upper bound } // If only lower bound exists if (upper === null && lower !== null) { - if (value >= lower) { + if (value >= lower!) { // Value is in normal range (above lower bound) // 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; } // 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; } // Both bounds exist if (lower !== null && upper !== null) { - if (value < lower) { + if (value < lower!) { return 0; // Below normal range } - if (value > upper) { + if (value > upper!) { return 100; // Above normal range } // Within normal range - return ((value - lower) / (upper - lower)) * 100; + return ((value - lower!) / (upper! - lower!)) * 100; } return 50; // Fallback @@ -145,18 +148,10 @@ const AnalysisLevelBar = ({ const isCritical = level === AnalysisResultLevel.CRITICAL; const isPending = level === null; - // If pending results, show gray bar - if (isPending) { - return ( -
- -
- ); - } - // Show appropriate levels based on available norm bounds const hasLowerBound = lower !== null; + // Calculate level configuration (must be called before any returns) const [first, second, third] = useMemo(() => { const [warning, normal, critical] = [ { @@ -196,6 +191,15 @@ const AnalysisLevelBar = ({ hasLowerBound, ]); + // If pending results, show gray bar + if (isPending) { + return ( +
+ +
+ ); + } + return (
- -
- - ) - } - endIcon={ - analysisData.comment && ( - <> -
- - } - /> -
-

- - : - {' '} - {analysisData.comment} -

- - ) - } - analysisElement={{ - analysis_name_lab: analysisData.analysis_name, - }} - results={analysisData} - /> -
-
- {analysisData.latestPreviousAnalysis && ( - -
+ <> + + +
+ ) + } endIcon={ - analysisData.latestPreviousAnalysis.comment && ( + analysisData.comment && ( <>
} @@ -79,25 +51,103 @@ export default function DoctorAnalysisWrapper({

- :{' '} - - {analysisData.latestPreviousAnalysis.comment} + : + {' '} + {analysisData.comment}

) } analysisElement={{ - analysis_name_lab: t('doctor:previousResults', { - date: formatDate( - analysisData.latestPreviousAnalysis.response_time, - ), - }), + analysis_name_lab: analysisData.analysis_name, }} - results={analysisData.latestPreviousAnalysis} + results={analysisData} />
- - )} -
+ + {analysisData.latestPreviousAnalysis && ( + +
+ +
+ + } + /> +
+

+ + :{' '} + + {analysisData.latestPreviousAnalysis.comment} +

+ + ) + } + analysisElement={{ + analysis_name_lab: t('doctor:previousResults', { + date: formatDate( + analysisData.latestPreviousAnalysis.response_time!, + ), + }), + }} + results={analysisData.latestPreviousAnalysis} + /> + {analysisData.latestPreviousAnalysis.nestedElements?.map( + (nestedElement, nestedIndex) => ( +
+ +
+ ), + )} +
+
+ )} + + {analysisData.nestedElements?.map((nestedElement, nestedIndex) => ( +
+ +
+ ))} + ); } diff --git a/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema.ts b/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema.ts index 6730a10..3afa65b 100644 --- a/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema.ts +++ b/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema.ts @@ -52,6 +52,23 @@ export const AnalysisResponsesSchema = z.object({ }); export type AnalysisResponses = z.infer; +// 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; + export const AnalysisResponseSchema = z.object({ id: z.number(), analysis_response_id: z.number(), @@ -69,6 +86,7 @@ export const AnalysisResponseSchema = z.object({ analysis_name: z.string().nullable(), analysis_responses: AnalysisResponsesSchema, comment: z.string().nullable(), + nestedElements: z.array(NestedAnalysisElementSchema).optional(), latestPreviousAnalysis: z .object({ id: z.number(), @@ -86,6 +104,7 @@ export const AnalysisResponseSchema = z.object({ updated_at: z.string().nullable(), analysis_name: z.string().nullable(), comment: z.string().nullable(), + nestedElements: z.array(NestedAnalysisElementSchema).optional(), }) .optional() .nullable(), diff --git a/packages/features/doctor/src/lib/server/services/doctor-analysis.service.ts b/packages/features/doctor/src/lib/server/services/doctor-analysis.service.ts index 983241d..6ed99fd 100644 --- a/packages/features/doctor/src/lib/server/services/doctor-analysis.service.ts +++ b/packages/features/doctor/src/lib/server/services/doctor-analysis.service.ts @@ -5,12 +5,16 @@ import { isBefore } from 'date-fns'; import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates'; 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 { createUserAnalysesApi } from '@kit/user-analyses/api'; 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 { AnalysisResponseBase, DoctorAnalysisFeedbackTable, @@ -20,6 +24,63 @@ import { } from '../schema/doctor-analysis.schema'; 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((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[]) { const supabase = getSupabaseServerClient(); @@ -388,7 +449,8 @@ export async function getAnalysisResultsForDoctor( .from(`analysis_response_elements`) .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); @@ -452,7 +514,8 @@ export async function getAnalysisResultsForDoctor( *, analysis_responses!inner( user_id - ) + ), + original_response_element `, ) .in( @@ -491,8 +554,14 @@ export async function getAnalysisResultsForDoctor( preferred_locale, } = accountWithParams[0]; + // Parse nested elements for current and previous analyses const analysisResponseElementsWithPreviousData = []; for (const analysisResponseElement of analysisResponsesData) { + const nestedElements = parseNestedElements( + analysisResponseElement.original_response_element as ResponseUuring, + Number(analysisResponseElement.status), + ); + const latestPreviousAnalysis = previousAnalyses.find( ({ analysis_element_original_id, 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({ ...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 { analysisResponse: analysisResponseElementsWithPreviousData, order: {