feat(MED-161): update analysis results view

This commit is contained in:
2025-09-17 11:17:36 +03:00
parent 2019c2c1fc
commit bfadf56173
21 changed files with 384 additions and 228 deletions

View File

@@ -48,7 +48,7 @@ const Level = ({
export const AnalysisLevelBarSkeleton = () => { export const AnalysisLevelBarSkeleton = () => {
return ( return (
<div className="mt-4 flex h-3 w-[35%] max-w-[360px] gap-1 sm:mt-0"> <div className="mt-4 flex h-3 w-[60%] sm:w-[35%] max-w-[360px] gap-1 sm:mt-0">
<Level color="gray-200" /> <Level color="gray-200" />
</div> </div>
); );
@@ -95,7 +95,7 @@ const AnalysisLevelBar = ({
const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh; const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh;
return ( return (
<div className="mt-4 flex h-3 w-[35%] max-w-[360px] gap-1 sm:mt-0"> <div className="mt-4 flex h-3 w-[60%] sm:w-[35%] max-w-[360px] gap-1 sm:mt-0">
{normLowerIncluded && ( {normLowerIncluded && (
<> <>
<Level <Level

View File

@@ -13,7 +13,6 @@ import {
} from '@kit/ui/collapsible'; } from '@kit/ui/collapsible';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import Analysis from '~/home/(user)/(dashboard)/analysis-results/_components/analysis';
import AnalysisDoctor from './analysis-doctor'; import AnalysisDoctor from './analysis-doctor';
export default function DoctorAnalysisWrapper({ export default function DoctorAnalysisWrapper({
@@ -30,7 +29,7 @@ export default function DoctorAnalysisWrapper({
asChild asChild
> >
<div className="[&[data-state=open]_.caret-icon]:rotate-180"> <div className="[&[data-state=open]_.caret-icon]:rotate-180">
<Analysis <AnalysisDoctor
startIcon={ startIcon={
analysisData.latestPreviousAnalysis && ( analysisData.latestPreviousAnalysis && (
<CaretDownIcon className="caret-icon transition-transform duration-200" /> <CaretDownIcon className="caret-icon transition-transform duration-200" />

View File

@@ -52,6 +52,8 @@ export default async function AnalysisResultsPage({
); );
} }
const orderedAnalysisElements = analysisResponse.orderedAnalysisElements;
return ( return (
<> <>
<PageHeader /> <PageHeader />
@@ -80,7 +82,7 @@ export default async function AnalysisResultsPage({
<h4> <h4>
<Trans <Trans
i18nKey="analysis-results:orderTitle" i18nKey="analysis-results:orderTitle"
values={{ orderNumber: analysisResponse.order.medusa_order_id }} values={{ orderNumber: analysisResponse.order.medusaOrderId }}
/> />
</h4> </h4>
<h5> <h5>
@@ -88,7 +90,7 @@ export default async function AnalysisResultsPage({
i18nKey={`orders:status.${analysisResponse.order.status}`} i18nKey={`orders:status.${analysisResponse.order.status}`}
/> />
<ButtonTooltip <ButtonTooltip
content={`${analysisResponse.order.created_at ? new Date(analysisResponse?.order?.created_at).toLocaleString() : ''}`} content={`${analysisResponse.order.createdAt ? new Date(analysisResponse?.order?.createdAt).toLocaleString() : ''}`}
className="ml-6" className="ml-6"
/> />
</h5> </h5>
@@ -102,13 +104,9 @@ export default async function AnalysisResultsPage({
</div> </div>
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{analysisResponse.elements ? ( {orderedAnalysisElements ? (
analysisResponse.elements.map((element, index) => ( orderedAnalysisElements.map((element, index) => (
<Analysis <Analysis key={index} element={element} />
key={index}
analysisElement={{ analysis_name_lab: element.analysis_name }}
results={element}
/>
)) ))
) : ( ) : (
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">

View File

@@ -3,14 +3,12 @@ import { useMemo } from 'react';
import { ArrowDown } from 'lucide-react'; import { ArrowDown } from 'lucide-react';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { AnalysisResultForDisplay } from './analysis'; import { AnalysisResultDetailsElementResults } from '@/packages/features/accounts/src/types/analysis-results';
export enum AnalysisResultLevel { export enum AnalysisResultLevel {
VERY_LOW = 0, NORMAL = 0,
LOW = 1, WARNING = 1,
NORMAL = 2, CRITICAL = 2,
HIGH = 3,
VERY_HIGH = 4,
} }
const Level = ({ const Level = ({
@@ -19,17 +17,19 @@ const Level = ({
isFirst = false, isFirst = false,
isLast = false, isLast = false,
arrowLocation, arrowLocation,
normRangeText,
}: { }: {
isActive?: boolean; isActive?: boolean;
color: 'destructive' | 'success' | 'warning' | 'gray-200'; color: 'destructive' | 'success' | 'warning' | 'gray-200';
isFirst?: boolean; isFirst?: boolean;
isLast?: boolean; isLast?: boolean;
arrowLocation?: number; arrowLocation?: number;
normRangeText?: string | null;
}) => { }) => {
return ( return (
<div <div
className={cn(`bg-${color} relative h-3 flex-1`, { className={cn(`bg-${color} relative h-3 flex-1`, {
'opacity-20': !isActive, 'opacity-60': !isActive,
'rounded-l-lg': isFirst, 'rounded-l-lg': isFirst,
'rounded-r-lg': isLast, 'rounded-r-lg': isLast,
})} })}
@@ -37,96 +37,176 @@ const Level = ({
{isActive && ( {isActive && (
<div <div
className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]" className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]"
style={{ left: `${arrowLocation}%` }} {...(arrowLocation ? {
style: {
left: `${arrowLocation}%`,
...(arrowLocation > 92.5 && { left: '92.5%' }),
...(arrowLocation < 7.5 && { left: '7.5%' }),
}
} : {})}
> >
<ArrowDown strokeWidth={2} /> <ArrowDown strokeWidth={2} />
</div> </div>
)} )}
{color === 'success' && typeof normRangeText === 'string' && (
<p className={cn("absolute bottom-[-18px] left-3/8 text-xs text-muted-foreground font-bold", {
'opacity-60': isActive,
})}>
{normRangeText}
</p>
)}
</div> </div>
); );
}; };
export const AnalysisLevelBarSkeleton = () => { export const AnalysisLevelBarSkeleton = () => {
return ( return (
<div className="mt-4 flex h-3 w-[35%] max-w-[360px] gap-1 sm:mt-0"> <div className="mt-4 flex h-3 w-[60%] sm:w-[35%] max-w-[360px] gap-1 sm:mt-0">
<Level color="gray-200" /> <Level color="gray-200" />
</div> </div>
); );
}; };
const AnalysisLevelBar = ({ const AnalysisLevelBar = ({
normLowerIncluded = true,
normUpperIncluded = true,
level, level,
results, results,
normRangeText,
}: { }: {
normLowerIncluded?: boolean;
normUpperIncluded?: boolean;
level: AnalysisResultLevel; level: AnalysisResultLevel;
results: AnalysisResultForDisplay; results: AnalysisResultDetailsElementResults;
normRangeText: string | null;
}) => { }) => {
const { norm_lower: lower, norm_upper: upper, response_value: value } = results; const { normLower: lower, normUpper: upper, responseValue: value, normStatus } = results;
const normLowerIncluded = results?.normLowerIncluded || false;
const normUpperIncluded = results?.normUpperIncluded || false;
// Calculate arrow position based on value within normal range
const arrowLocation = useMemo(() => { const arrowLocation = useMemo(() => {
if (value < lower!) { // If no response value, center the arrow
return 0; if (value === null || value === undefined) {
}
if (normLowerIncluded || normUpperIncluded) {
return 50; return 50;
} }
const calculated = ((value - lower!) / (upper! - lower!)) * 100; // If no normal ranges defined, center the arrow
if (lower === null && upper === null) {
if (calculated > 100) { return 50;
return 100;
} }
return calculated; // 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
}
return 100; // Beyond upper bound
}
// If only lower bound exists
if (upper === null && lower !== null) {
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
return normalizedPosition * 100;
}
// Value is below lower bound - position in the "below normal" section
const belowPosition = Math.max(0, Math.min(1, value / lower));
return belowPosition * 100;
}
// Both bounds exist
if (lower !== null && upper !== null) {
if (value < lower) {
return 0; // Below normal range
}
if (value > upper) {
return 100; // Above normal range
}
// Within normal range
return ((value - lower) / (upper - lower)) * 100;
}
return 50; // Fallback
}, [value, upper, lower]); }, [value, upper, lower]);
const [isVeryLow, isLow, isHigh, isVeryHigh] = useMemo(() => [ // Determine level states based on normStatus
level === AnalysisResultLevel.VERY_LOW, const isNormal = level === AnalysisResultLevel.NORMAL;
level === AnalysisResultLevel.LOW, const isWarning = level === AnalysisResultLevel.WARNING;
level === AnalysisResultLevel.HIGH, const isCritical = level === AnalysisResultLevel.CRITICAL;
level === AnalysisResultLevel.VERY_HIGH, const isPending = level === null;
], [level, value, upper, lower]);
const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh; // If pending results, show gray bar
if (isPending) {
return (
<div className="mt-4 flex h-3 w-60% sm:w-[35%] max-w-[360px] gap-1 sm:mt-0">
<Level color="gray-200" isFirst isLast />
</div>
);
}
// Show appropriate levels based on available norm bounds
const hasLowerBound = lower !== null;
const isLowerBoundZero = hasLowerBound && lower === 0;
console.info('isLowerBoundZero', results.analysisElementOriginalId, { isLowerBoundZero, hasLowerBound, lower });
const hasUpperBound = upper !== null;
// Determine which section the value falls into
const isValueBelowLower = hasLowerBound && value !== null && value < lower!;
const isValueAboveUpper = hasUpperBound && value !== null && value > upper!;
const isValueInNormalRange = !isValueBelowLower && !isValueAboveUpper;
const [first, second, third] = useMemo(() => {
if (!hasLowerBound) {
return [
{
isActive: isNormal,
color: "success",
isFirst: true,
normRangeText,
...(isNormal ? { arrowLocation } : {}),
},
{
isActive: isWarning,
color: "warning",
...(isWarning ? { arrowLocation } : {}),
},
{
isActive: isCritical,
color: "destructive",
isLast: true,
...(isCritical ? { arrowLocation } : {}),
},
] as const;
}
return [
{
isActive: isWarning,
color: "warning",
isFirst: true,
...(isWarning ? { arrowLocation } : {}),
},
{
isActive: isNormal,
color: "success",
normRangeText,
...(isNormal ? { arrowLocation } : {}),
},
{
isActive: isCritical,
color: "destructive",
isLast: true,
...(isCritical ? { arrowLocation } : {}),
},
] as const;
}, [isValueBelowLower, isValueAboveUpper, isValueInNormalRange, arrowLocation, normRangeText, isNormal, isWarning, isCritical]);
return ( return (
<div className="mt-4 flex h-3 w-[35%] max-w-[360px] gap-1 sm:mt-0"> <div className="mt-4 flex h-3 w-[60%] sm:w-[35%] max-w-[360px] gap-1 sm:mt-0">
{normLowerIncluded && ( <Level {...first} />
<> <Level {...second} />
<Level <Level {...third} />
isActive={isVeryLow}
color="destructive"
isFirst
/>
<Level isActive={isLow} color="warning" />
</>
)}
<Level
isFirst={!normLowerIncluded}
isLast={!normUpperIncluded}
{...(hasAbnormalLevel ? { color: "warning", isActive: false } : { color: "success", isActive: true })}
arrowLocation={arrowLocation}
/>
{normUpperIncluded && (
<>
<Level
isActive={isHigh}
color="warning"
/>
<Level
isActive={isVeryHigh}
color="destructive"
isLast
/>
</>
)}
</div> </div>
); );
}; };

View File

@@ -1,33 +1,19 @@
'use client'; 'use client';
import React, { ReactElement, ReactNode, useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts'; import { AnalysisResultDetailsElement } from '@/packages/features/accounts/src/types/analysis-results';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { Trans } from '@kit/ui/trans'; 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 AnalysisLevelBar, { import AnalysisLevelBar, {
AnalysisLevelBarSkeleton,
AnalysisResultLevel, AnalysisResultLevel,
} from './analysis-level-bar'; } 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 enum AnalysisStatus { export enum AnalysisStatus {
NORMAL = 0, NORMAL = 0,
MEDIUM = 1, MEDIUM = 1,
@@ -35,26 +21,45 @@ export enum AnalysisStatus {
} }
const Analysis = ({ const Analysis = ({
analysisElement, element,
results,
startIcon,
endIcon,
isCancelled,
}: { }: {
analysisElement: Pick<AnalysisElement, 'analysis_name_lab'>; element: AnalysisResultDetailsElement;
results?: AnalysisResultForDisplay;
isCancelled?: boolean;
startIcon?: ReactElement | null;
endIcon?: ReactNode | null;
}) => { }) => {
const name = analysisElement.analysis_name_lab || ''; const { t } = useTranslation();
const status = results?.norm_status || AnalysisStatus.NORMAL;
const value = results?.response_value || 0; const name = element.analysisName || '';
const results = element.results;
const hasIsWithinNorm = results?.responseValueIsWithinNorm !== null;
const hasIsNegative = results?.responseValueIsNegative !== null;
const value = (() => {
if (!results) {
return null;
}
const { responseValue, responseValueIsNegative, responseValueIsWithinNorm } = results;
if (responseValue === null || responseValue === undefined) {
if (hasIsNegative) {
if (responseValueIsNegative) {
return t('analysis-results:results.value.negative');
}
return t('analysis-results:results.value.positive');
}
if (hasIsWithinNorm) {
if (responseValueIsWithinNorm) {
return t('analysis-results:results.value.isWithinNorm');
}
return t('analysis-results:results.value.isNotWithinNorm');
}
return null;
}
return responseValue;
})();
const unit = results?.unit || ''; const unit = results?.unit || '';
const normLowerIncluded = results?.norm_lower_included || false; const normLower = results?.normLower;
const normUpperIncluded = results?.norm_upper_included || false; const normUpper = results?.normUpper;
const normLower = results?.norm_lower || 0;
const normUpper = results?.norm_upper || 0;
const [showTooltip, setShowTooltip] = useState(false); const [showTooltip, setShowTooltip] = useState(false);
const analysisResultLevel = useMemo(() => { const analysisResultLevel = useMemo(() => {
@@ -62,32 +67,34 @@ const Analysis = ({
return null; return null;
} }
const isUnderNorm = value < normLower; if (results.responseValue === null || results.responseValue === undefined) {
if (isUnderNorm) { return null;
switch (status) {
case AnalysisStatus.MEDIUM:
return AnalysisResultLevel.LOW;
default:
return AnalysisResultLevel.VERY_LOW;
}
} }
switch (status) {
case AnalysisStatus.MEDIUM: const normStatus = results.normStatus;
return AnalysisResultLevel.HIGH;
case AnalysisStatus.HIGH: switch (normStatus) {
return AnalysisResultLevel.VERY_HIGH; case 1:
return AnalysisResultLevel.WARNING;
case 2:
return AnalysisResultLevel.CRITICAL;
case 0:
default: default:
return AnalysisResultLevel.NORMAL; return AnalysisResultLevel.NORMAL;
} }
}, [results, value, normLower]); }, [results]);
const isCancelled = Number(results?.status) === 5;
const hasNestedElements = results?.nestedElements.length > 0;
const normRangeText = normLower !== null ? `${normLower} - ${normUpper || ''}` : null;
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 pt-3 pb-6 sm:py-3 sm:h-[65px] sm:flex-row sm:gap-0">
<div className="flex items-center gap-2 font-semibold"> <div className="flex items-center gap-2 font-semibold">
{startIcon || <div className="w-4" />}
{name} {name}
{results?.response_time && ( {results?.responseTime && (
<div <div
className="group/tooltip relative" className="group/tooltip relative"
onClick={(e) => { onClick={(e) => {
@@ -105,42 +112,41 @@ const Analysis = ({
> >
<Trans i18nKey="analysis-results:analysisDate" /> <Trans i18nKey="analysis-results:analysisDate" />
{': '} {': '}
{format(new Date(results.response_time), 'dd.MM.yyyy HH:mm')} {format(new Date(results.responseTime), 'dd.MM.yyyy HH:mm')}
</div> </div>
</div> </div>
)} )}
</div> </div>
{results ? (
{isCancelled && (
<div className="text-red-600 font-semibold text-sm">
<Trans i18nKey="analysis-results:cancelled" />
</div>
)}
{isCancelled || !results || hasNestedElements ? 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>
<div className="text-muted-foreground text-sm">{unit}</div> <div className="text-muted-foreground text-sm">{unit}</div>
</div> </div>
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0"> {!(hasIsNegative || hasIsWithinNorm) && (
{normLower} - {normUpper} <>
<div> <div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
<Trans i18nKey="analysis-results:results.range.normal" /> {normRangeText}
</div> <div>
</div> <Trans i18nKey="analysis-results:results.range.normal" />
<AnalysisLevelBar </div>
results={results} </div>
normLowerIncluded={normLowerIncluded} <AnalysisLevelBar
normUpperIncluded={normUpperIncluded} results={results}
level={analysisResultLevel!} level={analysisResultLevel!}
/> normRangeText={normRangeText}
{endIcon || <div className="mx-2 w-4" />} />
</>
)}
</> </>
) : (isCancelled ? null : ( )}
<>
<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>
</div> </div>
); );

View File

@@ -1,8 +1,8 @@
import { cache } from 'react'; import { cache } from 'react';
import { createAccountsApi } from '@kit/accounts/api'; import { AnalysisResultDetailsMapped } from '@kit/accounts/types/analysis-results';
import { AnalysisResultDetails } from '@kit/accounts/types/accounts';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api';
export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>; export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>;
@@ -15,9 +15,9 @@ export const loadUserAnalysis = cache(analysisLoader);
async function analysisLoader( async function analysisLoader(
analysisOrderId: number, analysisOrderId: number,
): Promise<AnalysisResultDetails | null> { ): Promise<AnalysisResultDetailsMapped | null> {
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const api = createAccountsApi(client); const api = createUserAnalysesApi(client);
return api.getUserAnalysis(analysisOrderId); return api.getUserAnalysis(analysisOrderId);
} }

View File

@@ -1,12 +1,13 @@
import { getSupabaseServerAdminClient } from "@/packages/supabase/src/clients/server-admin-client"; import { getSupabaseServerAdminClient } from "@/packages/supabase/src/clients/server-admin-client";
import type { AnalysisResponseElement } from "../types/analysis-response-element";
import { AnalysisOrderStatus } from '@/packages/shared/src/types/medipost-analysis'; import { AnalysisOrderStatus } from '@/packages/shared/src/types/medipost-analysis';
import type { AnalysisResponseElement } from "../types/analysis-response-element";
export async function getExistingAnalysisResponseElements({ export async function getExistingAnalysisResponseElements({
analysisResponseId, analysisResponseId,
}: { }: {
analysisResponseId: number; analysisResponseId: number;
}) { }): Promise<AnalysisResponseElement[]> {
const { data } = await getSupabaseServerAdminClient() const { data } = await getSupabaseServerAdminClient()
.schema('medreport') .schema('medreport')
.from('analysis_response_elements') .from('analysis_response_elements')
@@ -17,6 +18,18 @@ export async function getExistingAnalysisResponseElements({
return data as AnalysisResponseElement[]; return data as AnalysisResponseElement[];
} }
export async function createAnalysisResponseElement({
element,
}: {
element: Omit<AnalysisResponseElement, 'created_at' | 'updated_at' | 'id'>;
}) {
await getSupabaseServerAdminClient()
.schema('medreport')
.from('analysis_response_elements')
.insert(element)
.throwOnError();
}
export async function upsertAnalysisResponse({ export async function upsertAnalysisResponse({
analysisOrderId, analysisOrderId,
orderNumber, orderNumber,

View File

@@ -68,7 +68,7 @@ export interface IMedipostPublicMessageDataParsed {
Koefitsient: number; Koefitsient: number;
Hind: number; Hind: number;
}[]; }[];
UuringuElement: IUuringElement; UuringuElement?: IUuringElement[];
}[]; }[];
MaterjalideGrupp: IMaterialGroup[]; MaterjalideGrupp: IMaterialGroup[];
Kood: { Kood: {

View File

@@ -4,7 +4,7 @@ import type { Message } from '@/lib/types/medipost';
import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client';
export function getLatestMessage({ export async function getLatestMessage({
messages, messages,
excludedMessageIds, excludedMessageIds,
}: { }: {

View File

@@ -11,7 +11,7 @@ import { AnalysisOrderStatus } from '@/packages/shared/src/types/medipost-analys
import type { import type {
ResponseUuringuGrupp, ResponseUuringuGrupp,
MedipostOrderResponse, MedipostOrderResponse,
ResponseUuring, UuringElement,
} from '@/packages/shared/src/types/medipost-analysis'; } from '@/packages/shared/src/types/medipost-analysis';
import { toArray } from '@/lib/utils'; import { toArray } from '@/lib/utils';
import type { AnalysisOrder } from '~/lib/types/analysis-order'; import type { AnalysisOrder } from '~/lib/types/analysis-order';
@@ -29,7 +29,7 @@ import { composeOrderXML, OrderedAnalysisElement } from './medipostXML.service';
import { getAccountAdmin } from '../account.service'; import { getAccountAdmin } from '../account.service';
import { logMedipostDispatch } from '../audit.service'; import { logMedipostDispatch } from '../audit.service';
import { MedipostValidationError } from './MedipostValidationError'; import { MedipostValidationError } from './MedipostValidationError';
import { getExistingAnalysisResponseElements, upsertAnalysisResponse } from '../analysis-order.service'; import { createAnalysisResponseElement, getExistingAnalysisResponseElements, upsertAnalysisResponse } from '../analysis-order.service';
const BASE_URL = process.env.MEDIPOST_URL!; const BASE_URL = process.env.MEDIPOST_URL!;
const USER = process.env.MEDIPOST_USER!; const USER = process.env.MEDIPOST_USER!;
@@ -55,7 +55,7 @@ export async function getLatestPrivateMessageListItem({
throw new Error('Failed to get private message list'); throw new Error('Failed to get private message list');
} }
return getLatestMessage({ messages: data?.messages, excludedMessageIds }); return await getLatestMessage({ messages: data?.messages, excludedMessageIds });
} }
const logger = (analysisOrder: AnalysisOrder, externalId: string, analysisResponseId: string) => (message: string, error?: PostgrestError | null) => { const logger = (analysisOrder: AnalysisOrder, externalId: string, analysisResponseId: string) => (message: string, error?: PostgrestError | null) => {
@@ -67,7 +67,7 @@ const logger = (analysisOrder: AnalysisOrder, externalId: string, analysisRespon
} }
}; };
function canCreateAnalysisResponseElement({ export async function canCreateAnalysisResponseElement({
existingElements, existingElements,
groupUuring: { groupUuring: {
UuringuElement: { UuringuElement: {
@@ -78,8 +78,8 @@ function canCreateAnalysisResponseElement({
responseValue, responseValue,
log, log,
}: { }: {
existingElements: AnalysisResponseElement[]; existingElements: Pick<AnalysisResponseElement, 'analysis_element_original_id' | 'status' | 'response_value'>[];
groupUuring: ResponseUuring; groupUuring: { UuringuElement: Pick<UuringElement, 'UuringOlek' | 'UuringId'> };
responseValue: number | null; responseValue: number | null;
log: ReturnType<typeof logger>; log: ReturnType<typeof logger>;
}) { }) {
@@ -102,21 +102,19 @@ function canCreateAnalysisResponseElement({
} }
async function getAnalysisResponseElementsForGroup({ export async function getAnalysisResponseElementsForGroup({
analysisResponseId,
analysisGroup, analysisGroup,
existingElements,
log, log,
}: { }: {
analysisResponseId: number; analysisGroup: Pick<ResponseUuringuGrupp, 'UuringuGruppNimi' | 'Uuring'>;
analysisGroup: ResponseUuringuGrupp; existingElements: Pick<AnalysisResponseElement, 'analysis_element_original_id' | 'status' | 'response_value'>[];
log: ReturnType<typeof logger>; log: ReturnType<typeof logger>;
}) { }) {
const groupUuringItems = toArray(analysisGroup.Uuring as ResponseUuringuGrupp['Uuring']); const groupUuringItems = toArray(analysisGroup.Uuring as ResponseUuringuGrupp['Uuring']);
log(`Order has results in group '${analysisGroup.UuringuGruppNimi}' for ${groupUuringItems.length} analysis elements`); log(`Order has results in group '${analysisGroup.UuringuGruppNimi}' for ${groupUuringItems.length} analysis elements`);
const existingElements = await getExistingAnalysisResponseElements({ analysisResponseId }); const results: Omit<AnalysisResponseElement, 'created_at' | 'updated_at' | 'id' | 'analysis_response_id'>[] = [];
const results: Omit<AnalysisResponseElement, 'created_at' | 'updated_at' | 'id'>[] = [];
for (const groupUuring of groupUuringItems) { for (const groupUuring of groupUuringItems) {
const groupUuringElement = groupUuring.UuringuElement; const groupUuringElement = groupUuring.UuringuElement;
@@ -127,21 +125,25 @@ async function getAnalysisResponseElementsForGroup({
for (const response of elementAnalysisResponses) { for (const response of elementAnalysisResponses) {
const analysisElementOriginalId = groupUuringElement.UuringId; const analysisElementOriginalId = groupUuringElement.UuringId;
const vastuseVaartus = response.VastuseVaartus;
const responseValue = (() => { const responseValue = (() => {
const valueAsNumber = Number(response.VastuseVaartus); const valueAsNumber = Number(vastuseVaartus);
if (isNaN(valueAsNumber)) { if (isNaN(valueAsNumber)) {
return null; return null;
} }
return valueAsNumber; return valueAsNumber;
})(); })();
if (!canCreateAnalysisResponseElement({ existingElements, groupUuring, responseValue, log })) { if (!await canCreateAnalysisResponseElement({ existingElements, groupUuring, responseValue, log })) {
continue; continue;
} }
const responseValueIsNumeric = responseValue !== null;
const responseValueIsNegative = vastuseVaartus === 'Negatiivne';
const responseValueIsWithinNorm = vastuseVaartus === 'Normi piires';
results.push({ results.push({
analysis_element_original_id: analysisElementOriginalId, analysis_element_original_id: analysisElementOriginalId,
analysis_response_id: analysisResponseId,
norm_lower: response.NormAlum?.['#text'] ?? null, norm_lower: response.NormAlum?.['#text'] ?? null,
norm_lower_included: norm_lower_included:
response.NormAlum?.['@_kaasaarvatud'].toLowerCase() === 'jah', response.NormAlum?.['@_kaasaarvatud'].toLowerCase() === 'jah',
@@ -156,6 +158,8 @@ async function getAnalysisResponseElementsForGroup({
analysis_name: groupUuringElement.UuringNimi || groupUuringElement.KNimetus, analysis_name: groupUuringElement.UuringNimi || groupUuringElement.KNimetus,
comment: groupUuringElement.UuringuKommentaar ?? null, comment: groupUuringElement.UuringuKommentaar ?? null,
status: status.toString(), status: status.toString(),
response_value_is_within_norm: responseValueIsNumeric ? null : responseValueIsWithinNorm,
response_value_is_negative: responseValueIsNumeric ? null : responseValueIsNegative,
}); });
} }
} }
@@ -163,18 +167,55 @@ async function getAnalysisResponseElementsForGroup({
return results; return results;
} }
export async function syncPrivateMessage({ async function getNewAnalysisResponseElements({
messageResponse, analysisGroups,
existingElements,
log,
}: {
analysisGroups: ResponseUuringuGrupp[];
existingElements: AnalysisResponseElement[];
log: ReturnType<typeof logger>;
}) {
const newElements: Omit<AnalysisResponseElement, 'created_at' | 'updated_at' | 'id' | 'analysis_response_id'>[] = [];
for (const analysisGroup of analysisGroups) {
log(`[${analysisGroups.indexOf(analysisGroup) + 1}/${analysisGroups.length}] Syncing analysis group '${analysisGroup.UuringuGruppNimi}'`);
const elements = await getAnalysisResponseElementsForGroup({
analysisGroup,
existingElements,
log,
});
newElements.push(...elements);
}
return newElements;
}
async function hasAllAnalysisResponseElements({
analysisResponseId,
order, order,
}: { }: {
messageResponse: NonNullable<MedipostOrderResponse['Saadetis']['Vastus']>; analysisResponseId: number;
order: Pick<AnalysisOrder, 'analysis_element_ids'>;
}) {
const allOrderResponseElements = await getExistingAnalysisResponseElements({ analysisResponseId });
const expectedOrderResponseElements = order.analysis_element_ids?.length ?? 0;
return allOrderResponseElements.length === expectedOrderResponseElements;
}
export async function syncPrivateMessage({
messageResponse: {
ValisTellimuseId: externalId,
TellimuseNumber: orderNumber,
TellimuseOlek,
UuringuGrupp,
},
order,
}: {
messageResponse: Pick<NonNullable<MedipostOrderResponse['Saadetis']['Vastus']>, 'ValisTellimuseId' | 'TellimuseNumber' | 'TellimuseOlek' | 'UuringuGrupp'>;
order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; order: Tables<{ schema: 'medreport' }, 'analysis_orders'>;
}) { }) {
const supabase = getSupabaseServerAdminClient(); const supabase = getSupabaseServerAdminClient();
const externalId = messageResponse.ValisTellimuseId; const orderStatus = AnalysisOrderStatus[TellimuseOlek];
const orderNumber = messageResponse.TellimuseNumber;
const orderStatus = AnalysisOrderStatus[messageResponse.TellimuseOlek];
const log = logger(order, externalId, orderNumber); const log = logger(order, externalId, orderNumber);
@@ -193,37 +234,28 @@ export async function syncPrivateMessage({
userId: analysisOrder.user_id, userId: analysisOrder.user_id,
}); });
const analysisGroups = toArray(messageResponse.UuringuGrupp); const existingElements = await getExistingAnalysisResponseElements({ analysisResponseId });
const analysisGroups = toArray(UuringuGrupp);
log(`Order has results for ${analysisGroups.length} analysis groups`); log(`Order has results for ${analysisGroups.length} analysis groups`);
const newElements = await getNewAnalysisResponseElements({ analysisGroups, existingElements, log });
for (const analysisGroup of analysisGroups) { for (const element of newElements) {
log(`[${analysisGroups.indexOf(analysisGroup) + 1}/${analysisGroups.length}] Syncing analysis group '${analysisGroup.UuringuGruppNimi}'`); try {
await createAnalysisResponseElement({
const elements = await getAnalysisResponseElementsForGroup({ element: {
analysisResponseId, ...element,
analysisGroup, analysis_response_id: analysisResponseId,
log, },
}); });
} catch (e) {
for (const element of elements) { log(`Failed to create order response element for response id ${analysisResponseId}, element id '${element.analysis_element_original_id}' (order id: ${order.id})`, e as PostgrestError);
const { error } = await supabase
.schema('medreport')
.from('analysis_response_elements')
.insert(element);
if (error) {
log(`Failed to insert order response elements for response id ${analysisResponseId} (order id: ${analysisOrder.id})`, error);
}
} }
} }
const allOrderResponseElements = await getExistingAnalysisResponseElements({ analysisResponseId }); return await hasAllAnalysisResponseElements({ analysisResponseId, order })
const expectedOrderResponseElements = order.analysis_element_ids?.length ?? 0; ? { isCompleted: orderStatus === 'COMPLETED' }
if (allOrderResponseElements.length !== expectedOrderResponseElements) { : { isPartial: true };
return { isPartial: true };
}
return { isCompleted: orderStatus === 'COMPLETED' };
} }
export async function readPrivateMessageResponse({ export async function readPrivateMessageResponse({
@@ -297,6 +329,9 @@ export async function readPrivateMessageResponse({
analysisOrder = await getAnalysisOrder({ analysisOrderId }) analysisOrder = await getAnalysisOrder({ analysisOrderId })
medusaOrderId = analysisOrder.medusa_order_id; medusaOrderId = analysisOrder.medusa_order_id;
} catch (e) { } catch (e) {
if (IS_ENABLED_DELETE_PRIVATE_MESSAGE) {
await deletePrivateMessage(privateMessageId);
}
throw new Error(`No analysis order found for Medipost message ValisTellimuseId=${medipostExternalOrderId}`); throw new Error(`No analysis order found for Medipost message ValisTellimuseId=${medipostExternalOrderId}`);
} }
@@ -305,17 +340,7 @@ export async function readPrivateMessageResponse({
throw new Error(`Order person personal code does not match Medipost message Patsient.Isikukood=${patientPersonalCode}, orderPerson.personal_code=${orderPerson.personal_code}`); throw new Error(`Order person personal code does not match Medipost message Patsient.Isikukood=${patientPersonalCode}, orderPerson.personal_code=${orderPerson.personal_code}`);
} }
let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; const status = await syncPrivateMessage({ messageResponse, order: analysisOrder });
try {
order = await getAnalysisOrder({ medusaOrderId });
} catch (e) {
if (IS_ENABLED_DELETE_PRIVATE_MESSAGE) {
await deletePrivateMessage(privateMessageId);
}
throw new Error(`Order not found by Medipost message ValisTellimuseId=${medusaOrderId}`);
}
const status = await syncPrivateMessage({ messageResponse, order });
await createMedipostActionLog({ await createMedipostActionLog({
action: 'sync_analysis_results_from_medipost', action: 'sync_analysis_results_from_medipost',
@@ -394,7 +419,7 @@ export async function getPrivateMessage(messageId: string) {
await validateMedipostResponse(data, { canHaveEmptyCode: true }); await validateMedipostResponse(data, { canHaveEmptyCode: true });
return { return {
message: parseXML(data) as MedipostOrderResponse, message: (await parseXML(data)) as MedipostOrderResponse,
xml: data as string, xml: data as string,
}; };
} }

View File

@@ -29,5 +29,5 @@ export async function getLatestPublicMessageListItem() {
throw new Error('Failed to get public message list'); throw new Error('Failed to get public message list');
} }
return getLatestMessage({ messages: data?.messages }); return await getLatestMessage({ messages: data?.messages });
} }

View File

@@ -8,7 +8,7 @@ import { MedipostValidationError } from './MedipostValidationError';
import { parseXML } from '../util/xml.service'; import { parseXML } from '../util/xml.service';
export async function validateMedipostResponse(response: string, { canHaveEmptyCode = false }: { canHaveEmptyCode?: boolean } = {}) { export async function validateMedipostResponse(response: string, { canHaveEmptyCode = false }: { canHaveEmptyCode?: boolean } = {}) {
const parsed: IMedipostResponseXMLBase = parseXML(response); const parsed: IMedipostResponseXMLBase = await parseXML(response);
const code = parsed.ANSWER?.CODE; const code = parsed.ANSWER?.CODE;
if (canHaveEmptyCode) { if (canHaveEmptyCode) {
if (code && code !== 0) { if (code && code !== 0) {

View File

@@ -2,7 +2,7 @@
import { XMLParser } from 'fast-xml-parser'; import { XMLParser } from 'fast-xml-parser';
export function parseXML(xml: string) { export async function parseXML(xml: string) {
const parser = new XMLParser({ ignoreAttributes: false }); const parser = new XMLParser({ ignoreAttributes: false });
return parser.parse(xml); return parser.parse(xml);
} }

View File

@@ -78,6 +78,7 @@ export type AnalysisResultDetailsElementResults = {
responseTime: string | null; responseTime: string | null;
responseValue: number | null; responseValue: number | null;
responseValueIsNegative: boolean | null; responseValueIsNegative: boolean | null;
responseValueIsWithinNorm: boolean | null;
normLowerIncluded: boolean; normLowerIncluded: boolean;
normUpperIncluded: boolean; normUpperIncluded: boolean;
status: string; status: string;

View File

@@ -80,7 +80,7 @@ class UserAnalysesApi {
.from('analysis_responses') .from('analysis_responses')
.select( .select(
`*, `*,
elements:analysis_response_elements(analysis_name,norm_status,response_value,unit,norm_lower_included,norm_upper_included,norm_lower,norm_upper,response_time,status,analysis_element_original_id,original_response_element,response_value_is_negative), elements:analysis_response_elements(analysis_name,norm_status,response_value,unit,norm_lower_included,norm_upper_included,norm_lower,norm_upper,response_time,status,analysis_element_original_id,original_response_element,response_value_is_negative,response_value_is_within_norm),
summary:analysis_order_id(doctor_analysis_feedback(*))`, summary:analysis_order_id(doctor_analysis_feedback(*))`,
) )
.eq('user_id', user.id) .eq('user_id', user.id)
@@ -192,6 +192,10 @@ class UserAnalysesApi {
} }
return nestedElements.map((element) => { return nestedElements.map((element) => {
const elementVastus = element.UuringuVastus as UuringuVastus | undefined; const elementVastus = element.UuringuVastus as UuringuVastus | undefined;
const responseValue = elementVastus?.VastuseVaartus;
const responseValueIsNumeric = !isNaN(Number(responseValue));
const responseValueIsNegative = responseValue === 'Negatiivne';
const responseValueIsWithinNorm = responseValue === 'Normi piires';
return { return {
status: element.UuringOlek, status: element.UuringOlek,
unit: element.Mootyhik, unit: element.Mootyhik,
@@ -199,8 +203,9 @@ class UserAnalysesApi {
normUpper: elementVastus?.NormYlem?.['#text'], normUpper: elementVastus?.NormYlem?.['#text'],
normStatus: elementVastus?.NormiStaatus, normStatus: elementVastus?.NormiStaatus,
responseTime: elementVastus?.VastuseAeg, responseTime: elementVastus?.VastuseAeg,
responseValue: elementVastus?.VastuseVaartus, response_value: responseValueIsNegative || !responseValueIsNumeric ? null : (responseValue ?? null),
responseValueIsNegative: elementVastus?.VastuseVaartus === 'Negatiivne', response_value_is_negative: responseValueIsNumeric ? null : responseValueIsNegative,
response_value_is_within_norm: responseValueIsNumeric ? null : responseValueIsWithinNorm,
normLowerIncluded: elementVastus?.NormAlum?.['@_kaasaarvatud'] === 'JAH', normLowerIncluded: elementVastus?.NormAlum?.['@_kaasaarvatud'] === 'JAH',
normUpperIncluded: elementVastus?.NormYlem?.['@_kaasaarvatud'] === 'JAH', normUpperIncluded: elementVastus?.NormYlem?.['@_kaasaarvatud'] === 'JAH',
analysisElementOriginalId: element.UuringId, analysisElementOriginalId: element.UuringId,
@@ -216,6 +221,7 @@ class UserAnalysesApi {
responseTime: elementResponse.response_time, responseTime: elementResponse.response_time,
responseValue: elementResponse.response_value, responseValue: elementResponse.response_value,
responseValueIsNegative: elementResponse.response_value_is_negative === true, responseValueIsNegative: elementResponse.response_value_is_negative === true,
responseValueIsWithinNorm: elementResponse.response_value_is_within_norm === true,
normLowerIncluded: elementResponse.norm_lower_included, normLowerIncluded: elementResponse.norm_lower_included,
normUpperIncluded: elementResponse.norm_upper_included, normUpperIncluded: elementResponse.norm_upper_included,
status: elementResponse.status, status: elementResponse.status,

View File

@@ -19,6 +19,7 @@ const ElementSchema = z.object({
response_time: z.string(), response_time: z.string(),
response_value: z.number(), response_value: z.number(),
response_value_is_negative: z.boolean(), response_value_is_negative: z.boolean(),
response_value_is_within_norm: z.boolean(),
norm_lower_included: z.boolean(), norm_lower_included: z.boolean(),
norm_upper_included: z.boolean(), norm_upper_included: z.boolean(),
status: z.string(), status: z.string(),
@@ -78,6 +79,7 @@ export type AnalysisResultDetailsElementResults = {
responseTime: string | null; responseTime: string | null;
responseValue: number | null; responseValue: number | null;
responseValueIsNegative: boolean | null; responseValueIsNegative: boolean | null;
responseValueIsWithinNorm: boolean | null;
normLowerIncluded: boolean; normLowerIncluded: boolean;
normUpperIncluded: boolean; normUpperIncluded: boolean;
status: string; status: string;

View File

@@ -688,6 +688,7 @@ export type Database = {
response_time: string response_time: string
response_value: number | null response_value: number | null
response_value_is_negative?: boolean | null response_value_is_negative?: boolean | null
response_value_is_within_norm?: boolean | null
status: string status: string
unit: string | null unit: string | null
updated_at: string | null updated_at: string | null
@@ -708,6 +709,7 @@ export type Database = {
response_time: string response_time: string
response_value: number | null response_value: number | null
response_value_is_negative?: boolean | null response_value_is_negative?: boolean | null
response_value_is_within_norm?: boolean | null
status: string status: string
unit?: string | null unit?: string | null
updated_at?: string | null updated_at?: string | null
@@ -728,6 +730,7 @@ export type Database = {
response_time?: string response_time?: string
response_value?: number | null response_value?: number | null
response_value_is_negative?: boolean | null response_value_is_negative?: boolean | null
response_value_is_within_norm?: boolean | null
status: string status: string
unit?: string | null unit?: string | null
updated_at?: string | null updated_at?: string | null

View File

@@ -7,9 +7,16 @@
"noAnalysisElements": "No analysis orders found", "noAnalysisElements": "No analysis orders found",
"noAnalysisOrders": "No analysis orders found", "noAnalysisOrders": "No analysis orders found",
"analysisDate": "Analysis result date", "analysisDate": "Analysis result date",
"cancelled": "Cancelled",
"results": { "results": {
"range": { "range": {
"normal": "Normal range" "normal": "Normal range"
},
"value": {
"negative": "Negative",
"positive": "Positive",
"isWithinNorm": "Within norm",
"isNotWithinNorm": "Not within norm"
} }
}, },
"orderTitle": "Order number {{orderNumber}}", "orderTitle": "Order number {{orderNumber}}",

View File

@@ -7,9 +7,16 @@
"noAnalysisElements": "Veel ei ole tellitud analüüse", "noAnalysisElements": "Veel ei ole tellitud analüüse",
"noAnalysisOrders": "Veel ei ole analüüside tellimusi", "noAnalysisOrders": "Veel ei ole analüüside tellimusi",
"analysisDate": "Analüüsi vastuse kuupäev", "analysisDate": "Analüüsi vastuse kuupäev",
"cancelled": "Tühistatud",
"results": { "results": {
"range": { "range": {
"normal": "Normaalne vahemik" "normal": "Normaalne vahemik"
},
"value": {
"negative": "Negatiivne",
"positive": "Positiivne",
"isWithinNorm": "Normi piires",
"isNotWithinNorm": "Normi piirest väljas"
} }
}, },
"orderTitle": "Tellimus {{orderNumber}}", "orderTitle": "Tellimus {{orderNumber}}",

View File

@@ -7,9 +7,16 @@
"noAnalysisElements": "Анализы еще не заказаны", "noAnalysisElements": "Анализы еще не заказаны",
"noAnalysisOrders": "Пока нет заказов на анализы", "noAnalysisOrders": "Пока нет заказов на анализы",
"analysisDate": "Дата результата анализа", "analysisDate": "Дата результата анализа",
"cancelled": "Отменен",
"results": { "results": {
"range": { "range": {
"normal": "Нормальный диапазон" "normal": "Нормальный диапазон"
},
"value": {
"negative": "Отрицательный",
"positive": "Положительный",
"isWithinNorm": "В норме",
"isNotWithinNorm": "Не в норме"
} }
}, },
"orderTitle": "Заказ {{orderNumber}}" "orderTitle": "Заказ {{orderNumber}}"

View File

@@ -0,0 +1,2 @@
ALTER TABLE medreport.analysis_response_elements
ADD COLUMN response_value_is_within_norm BOOLEAN;