feat(MED-161): update analysis results view
This commit is contained in:
@@ -48,7 +48,7 @@ const Level = ({
|
||||
|
||||
export const AnalysisLevelBarSkeleton = () => {
|
||||
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" />
|
||||
</div>
|
||||
);
|
||||
@@ -95,7 +95,7 @@ const AnalysisLevelBar = ({
|
||||
const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh;
|
||||
|
||||
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
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from '@kit/ui/collapsible';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import Analysis from '~/home/(user)/(dashboard)/analysis-results/_components/analysis';
|
||||
import AnalysisDoctor from './analysis-doctor';
|
||||
|
||||
export default function DoctorAnalysisWrapper({
|
||||
@@ -30,7 +29,7 @@ export default function DoctorAnalysisWrapper({
|
||||
asChild
|
||||
>
|
||||
<div className="[&[data-state=open]_.caret-icon]:rotate-180">
|
||||
<Analysis
|
||||
<AnalysisDoctor
|
||||
startIcon={
|
||||
analysisData.latestPreviousAnalysis && (
|
||||
<CaretDownIcon className="caret-icon transition-transform duration-200" />
|
||||
|
||||
@@ -52,6 +52,8 @@ export default async function AnalysisResultsPage({
|
||||
);
|
||||
}
|
||||
|
||||
const orderedAnalysisElements = analysisResponse.orderedAnalysisElements;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader />
|
||||
@@ -80,7 +82,7 @@ export default async function AnalysisResultsPage({
|
||||
<h4>
|
||||
<Trans
|
||||
i18nKey="analysis-results:orderTitle"
|
||||
values={{ orderNumber: analysisResponse.order.medusa_order_id }}
|
||||
values={{ orderNumber: analysisResponse.order.medusaOrderId }}
|
||||
/>
|
||||
</h4>
|
||||
<h5>
|
||||
@@ -88,7 +90,7 @@ export default async function AnalysisResultsPage({
|
||||
i18nKey={`orders:status.${analysisResponse.order.status}`}
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</h5>
|
||||
@@ -102,13 +104,9 @@ export default async function AnalysisResultsPage({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{analysisResponse.elements ? (
|
||||
analysisResponse.elements.map((element, index) => (
|
||||
<Analysis
|
||||
key={index}
|
||||
analysisElement={{ analysis_name_lab: element.analysis_name }}
|
||||
results={element}
|
||||
/>
|
||||
{orderedAnalysisElements ? (
|
||||
orderedAnalysisElements.map((element, index) => (
|
||||
<Analysis key={index} element={element} />
|
||||
))
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -3,14 +3,12 @@ import { useMemo } from 'react';
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@kit/ui/utils';
|
||||
import { AnalysisResultForDisplay } from './analysis';
|
||||
import { AnalysisResultDetailsElementResults } from '@/packages/features/accounts/src/types/analysis-results';
|
||||
|
||||
export enum AnalysisResultLevel {
|
||||
VERY_LOW = 0,
|
||||
LOW = 1,
|
||||
NORMAL = 2,
|
||||
HIGH = 3,
|
||||
VERY_HIGH = 4,
|
||||
NORMAL = 0,
|
||||
WARNING = 1,
|
||||
CRITICAL = 2,
|
||||
}
|
||||
|
||||
const Level = ({
|
||||
@@ -19,17 +17,19 @@ const Level = ({
|
||||
isFirst = false,
|
||||
isLast = false,
|
||||
arrowLocation,
|
||||
normRangeText,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
color: 'destructive' | 'success' | 'warning' | 'gray-200';
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
arrowLocation?: number;
|
||||
normRangeText?: string | null;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(`bg-${color} relative h-3 flex-1`, {
|
||||
'opacity-20': !isActive,
|
||||
'opacity-60': !isActive,
|
||||
'rounded-l-lg': isFirst,
|
||||
'rounded-r-lg': isLast,
|
||||
})}
|
||||
@@ -37,96 +37,176 @@ const Level = ({
|
||||
{isActive && (
|
||||
<div
|
||||
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} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export const AnalysisLevelBarSkeleton = () => {
|
||||
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" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AnalysisLevelBar = ({
|
||||
normLowerIncluded = true,
|
||||
normUpperIncluded = true,
|
||||
level,
|
||||
results,
|
||||
normRangeText,
|
||||
}: {
|
||||
normLowerIncluded?: boolean;
|
||||
normUpperIncluded?: boolean;
|
||||
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(() => {
|
||||
if (value < lower!) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (normLowerIncluded || normUpperIncluded) {
|
||||
// If no response value, center the arrow
|
||||
if (value === null || value === undefined) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
const calculated = ((value - lower!) / (upper! - lower!)) * 100;
|
||||
|
||||
if (calculated > 100) {
|
||||
return 100;
|
||||
// If no normal ranges defined, center the arrow
|
||||
if (lower === null && upper === null) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
const [isVeryLow, isLow, isHigh, isVeryHigh] = useMemo(() => [
|
||||
level === AnalysisResultLevel.VERY_LOW,
|
||||
level === AnalysisResultLevel.LOW,
|
||||
level === AnalysisResultLevel.HIGH,
|
||||
level === AnalysisResultLevel.VERY_HIGH,
|
||||
], [level, value, upper, lower]);
|
||||
// Determine level states based on normStatus
|
||||
const isNormal = level === AnalysisResultLevel.NORMAL;
|
||||
const isWarning = level === AnalysisResultLevel.WARNING;
|
||||
const isCritical = level === AnalysisResultLevel.CRITICAL;
|
||||
const isPending = level === null;
|
||||
|
||||
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 (
|
||||
<div className="mt-4 flex h-3 w-[35%] max-w-[360px] gap-1 sm:mt-0">
|
||||
{normLowerIncluded && (
|
||||
<>
|
||||
<Level
|
||||
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 className="mt-4 flex h-3 w-[60%] sm:w-[35%] max-w-[360px] gap-1 sm:mt-0">
|
||||
<Level {...first} />
|
||||
<Level {...second} />
|
||||
<Level {...third} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
'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 { Info } from 'lucide-react';
|
||||
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { AnalysisElement } from '~/lib/services/analysis-element.service';
|
||||
|
||||
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 enum AnalysisStatus {
|
||||
NORMAL = 0,
|
||||
MEDIUM = 1,
|
||||
@@ -35,26 +21,45 @@ export enum AnalysisStatus {
|
||||
}
|
||||
|
||||
const Analysis = ({
|
||||
analysisElement,
|
||||
results,
|
||||
startIcon,
|
||||
endIcon,
|
||||
isCancelled,
|
||||
element,
|
||||
}: {
|
||||
analysisElement: Pick<AnalysisElement, 'analysis_name_lab'>;
|
||||
results?: AnalysisResultForDisplay;
|
||||
isCancelled?: boolean;
|
||||
startIcon?: ReactElement | null;
|
||||
endIcon?: ReactNode | null;
|
||||
element: AnalysisResultDetailsElement;
|
||||
}) => {
|
||||
const name = analysisElement.analysis_name_lab || '';
|
||||
const status = results?.norm_status || AnalysisStatus.NORMAL;
|
||||
const value = results?.response_value || 0;
|
||||
const { t } = useTranslation();
|
||||
|
||||
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 normLowerIncluded = results?.norm_lower_included || false;
|
||||
const normUpperIncluded = results?.norm_upper_included || false;
|
||||
const normLower = results?.norm_lower || 0;
|
||||
const normUpper = results?.norm_upper || 0;
|
||||
const normLower = results?.normLower;
|
||||
const normUpper = results?.normUpper;
|
||||
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const analysisResultLevel = useMemo(() => {
|
||||
@@ -62,32 +67,34 @@ const Analysis = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const isUnderNorm = value < normLower;
|
||||
if (isUnderNorm) {
|
||||
switch (status) {
|
||||
case AnalysisStatus.MEDIUM:
|
||||
return AnalysisResultLevel.LOW;
|
||||
default:
|
||||
return AnalysisResultLevel.VERY_LOW;
|
||||
}
|
||||
if (results.responseValue === null || results.responseValue === undefined) {
|
||||
return null;
|
||||
}
|
||||
switch (status) {
|
||||
case AnalysisStatus.MEDIUM:
|
||||
return AnalysisResultLevel.HIGH;
|
||||
case AnalysisStatus.HIGH:
|
||||
return AnalysisResultLevel.VERY_HIGH;
|
||||
|
||||
const normStatus = results.normStatus;
|
||||
|
||||
switch (normStatus) {
|
||||
case 1:
|
||||
return AnalysisResultLevel.WARNING;
|
||||
case 2:
|
||||
return AnalysisResultLevel.CRITICAL;
|
||||
case 0:
|
||||
default:
|
||||
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 (
|
||||
<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">
|
||||
{startIcon || <div className="w-4" />}
|
||||
{name}
|
||||
{results?.response_time && (
|
||||
{results?.responseTime && (
|
||||
<div
|
||||
className="group/tooltip relative"
|
||||
onClick={(e) => {
|
||||
@@ -105,42 +112,41 @@ const Analysis = ({
|
||||
>
|
||||
<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>
|
||||
{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="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" />}
|
||||
{!(hasIsNegative || hasIsWithinNorm) && (
|
||||
<>
|
||||
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
|
||||
{normRangeText}
|
||||
<div>
|
||||
<Trans i18nKey="analysis-results:results.range.normal" />
|
||||
</div>
|
||||
</div>
|
||||
<AnalysisLevelBar
|
||||
results={results}
|
||||
level={analysisResultLevel!}
|
||||
normRangeText={normRangeText}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (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>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { AnalysisResultDetails } from '@kit/accounts/types/accounts';
|
||||
import { AnalysisResultDetailsMapped } from '@kit/accounts/types/analysis-results';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api';
|
||||
|
||||
export type UserAnalyses = Awaited<ReturnType<typeof loadUserAnalysis>>;
|
||||
|
||||
@@ -15,9 +15,9 @@ export const loadUserAnalysis = cache(analysisLoader);
|
||||
|
||||
async function analysisLoader(
|
||||
analysisOrderId: number,
|
||||
): Promise<AnalysisResultDetails | null> {
|
||||
): Promise<AnalysisResultDetailsMapped | null> {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createAccountsApi(client);
|
||||
const api = createUserAnalysesApi(client);
|
||||
|
||||
return api.getUserAnalysis(analysisOrderId);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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 type { AnalysisResponseElement } from "../types/analysis-response-element";
|
||||
|
||||
export async function getExistingAnalysisResponseElements({
|
||||
analysisResponseId,
|
||||
}: {
|
||||
analysisResponseId: number;
|
||||
}) {
|
||||
}): Promise<AnalysisResponseElement[]> {
|
||||
const { data } = await getSupabaseServerAdminClient()
|
||||
.schema('medreport')
|
||||
.from('analysis_response_elements')
|
||||
@@ -17,6 +18,18 @@ export async function getExistingAnalysisResponseElements({
|
||||
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({
|
||||
analysisOrderId,
|
||||
orderNumber,
|
||||
|
||||
@@ -68,7 +68,7 @@ export interface IMedipostPublicMessageDataParsed {
|
||||
Koefitsient: number;
|
||||
Hind: number;
|
||||
}[];
|
||||
UuringuElement: IUuringElement;
|
||||
UuringuElement?: IUuringElement[];
|
||||
}[];
|
||||
MaterjalideGrupp: IMaterialGroup[];
|
||||
Kood: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Message } from '@/lib/types/medipost';
|
||||
|
||||
import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client';
|
||||
|
||||
export function getLatestMessage({
|
||||
export async function getLatestMessage({
|
||||
messages,
|
||||
excludedMessageIds,
|
||||
}: {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { AnalysisOrderStatus } from '@/packages/shared/src/types/medipost-analys
|
||||
import type {
|
||||
ResponseUuringuGrupp,
|
||||
MedipostOrderResponse,
|
||||
ResponseUuring,
|
||||
UuringElement,
|
||||
} from '@/packages/shared/src/types/medipost-analysis';
|
||||
import { toArray } from '@/lib/utils';
|
||||
import type { AnalysisOrder } from '~/lib/types/analysis-order';
|
||||
@@ -29,7 +29,7 @@ import { composeOrderXML, OrderedAnalysisElement } from './medipostXML.service';
|
||||
import { getAccountAdmin } from '../account.service';
|
||||
import { logMedipostDispatch } from '../audit.service';
|
||||
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 USER = process.env.MEDIPOST_USER!;
|
||||
@@ -55,7 +55,7 @@ export async function getLatestPrivateMessageListItem({
|
||||
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) => {
|
||||
@@ -67,7 +67,7 @@ const logger = (analysisOrder: AnalysisOrder, externalId: string, analysisRespon
|
||||
}
|
||||
};
|
||||
|
||||
function canCreateAnalysisResponseElement({
|
||||
export async function canCreateAnalysisResponseElement({
|
||||
existingElements,
|
||||
groupUuring: {
|
||||
UuringuElement: {
|
||||
@@ -78,8 +78,8 @@ function canCreateAnalysisResponseElement({
|
||||
responseValue,
|
||||
log,
|
||||
}: {
|
||||
existingElements: AnalysisResponseElement[];
|
||||
groupUuring: ResponseUuring;
|
||||
existingElements: Pick<AnalysisResponseElement, 'analysis_element_original_id' | 'status' | 'response_value'>[];
|
||||
groupUuring: { UuringuElement: Pick<UuringElement, 'UuringOlek' | 'UuringId'> };
|
||||
responseValue: number | null;
|
||||
log: ReturnType<typeof logger>;
|
||||
}) {
|
||||
@@ -102,21 +102,19 @@ function canCreateAnalysisResponseElement({
|
||||
}
|
||||
|
||||
|
||||
async function getAnalysisResponseElementsForGroup({
|
||||
analysisResponseId,
|
||||
export async function getAnalysisResponseElementsForGroup({
|
||||
analysisGroup,
|
||||
existingElements,
|
||||
log,
|
||||
}: {
|
||||
analysisResponseId: number;
|
||||
analysisGroup: ResponseUuringuGrupp;
|
||||
analysisGroup: Pick<ResponseUuringuGrupp, 'UuringuGruppNimi' | 'Uuring'>;
|
||||
existingElements: Pick<AnalysisResponseElement, 'analysis_element_original_id' | 'status' | 'response_value'>[];
|
||||
log: ReturnType<typeof logger>;
|
||||
}) {
|
||||
const groupUuringItems = toArray(analysisGroup.Uuring as ResponseUuringuGrupp['Uuring']);
|
||||
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'>[] = [];
|
||||
const results: Omit<AnalysisResponseElement, 'created_at' | 'updated_at' | 'id' | 'analysis_response_id'>[] = [];
|
||||
|
||||
for (const groupUuring of groupUuringItems) {
|
||||
const groupUuringElement = groupUuring.UuringuElement;
|
||||
@@ -127,21 +125,25 @@ async function getAnalysisResponseElementsForGroup({
|
||||
|
||||
for (const response of elementAnalysisResponses) {
|
||||
const analysisElementOriginalId = groupUuringElement.UuringId;
|
||||
const vastuseVaartus = response.VastuseVaartus;
|
||||
const responseValue = (() => {
|
||||
const valueAsNumber = Number(response.VastuseVaartus);
|
||||
const valueAsNumber = Number(vastuseVaartus);
|
||||
if (isNaN(valueAsNumber)) {
|
||||
return null;
|
||||
}
|
||||
return valueAsNumber;
|
||||
})();
|
||||
|
||||
if (!canCreateAnalysisResponseElement({ existingElements, groupUuring, responseValue, log })) {
|
||||
if (!await canCreateAnalysisResponseElement({ existingElements, groupUuring, responseValue, log })) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const responseValueIsNumeric = responseValue !== null;
|
||||
const responseValueIsNegative = vastuseVaartus === 'Negatiivne';
|
||||
const responseValueIsWithinNorm = vastuseVaartus === 'Normi piires';
|
||||
|
||||
results.push({
|
||||
analysis_element_original_id: analysisElementOriginalId,
|
||||
analysis_response_id: analysisResponseId,
|
||||
norm_lower: response.NormAlum?.['#text'] ?? null,
|
||||
norm_lower_included:
|
||||
response.NormAlum?.['@_kaasaarvatud'].toLowerCase() === 'jah',
|
||||
@@ -156,6 +158,8 @@ async function getAnalysisResponseElementsForGroup({
|
||||
analysis_name: groupUuringElement.UuringNimi || groupUuringElement.KNimetus,
|
||||
comment: groupUuringElement.UuringuKommentaar ?? null,
|
||||
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;
|
||||
}
|
||||
|
||||
export async function syncPrivateMessage({
|
||||
messageResponse,
|
||||
async function getNewAnalysisResponseElements({
|
||||
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,
|
||||
}: {
|
||||
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'>;
|
||||
}) {
|
||||
const supabase = getSupabaseServerAdminClient();
|
||||
|
||||
const externalId = messageResponse.ValisTellimuseId;
|
||||
const orderNumber = messageResponse.TellimuseNumber;
|
||||
const orderStatus = AnalysisOrderStatus[messageResponse.TellimuseOlek];
|
||||
const orderStatus = AnalysisOrderStatus[TellimuseOlek];
|
||||
|
||||
const log = logger(order, externalId, orderNumber);
|
||||
|
||||
@@ -193,37 +234,28 @@ export async function syncPrivateMessage({
|
||||
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`);
|
||||
const newElements = await getNewAnalysisResponseElements({ analysisGroups, existingElements, log });
|
||||
|
||||
for (const analysisGroup of analysisGroups) {
|
||||
log(`[${analysisGroups.indexOf(analysisGroup) + 1}/${analysisGroups.length}] Syncing analysis group '${analysisGroup.UuringuGruppNimi}'`);
|
||||
|
||||
const elements = await getAnalysisResponseElementsForGroup({
|
||||
analysisResponseId,
|
||||
analysisGroup,
|
||||
log,
|
||||
});
|
||||
|
||||
for (const element of elements) {
|
||||
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);
|
||||
}
|
||||
for (const element of newElements) {
|
||||
try {
|
||||
await createAnalysisResponseElement({
|
||||
element: {
|
||||
...element,
|
||||
analysis_response_id: analysisResponseId,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
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 allOrderResponseElements = await getExistingAnalysisResponseElements({ analysisResponseId });
|
||||
const expectedOrderResponseElements = order.analysis_element_ids?.length ?? 0;
|
||||
if (allOrderResponseElements.length !== expectedOrderResponseElements) {
|
||||
return { isPartial: true };
|
||||
}
|
||||
|
||||
return { isCompleted: orderStatus === 'COMPLETED' };
|
||||
return await hasAllAnalysisResponseElements({ analysisResponseId, order })
|
||||
? { isCompleted: orderStatus === 'COMPLETED' }
|
||||
: { isPartial: true };
|
||||
}
|
||||
|
||||
export async function readPrivateMessageResponse({
|
||||
@@ -297,6 +329,9 @@ export async function readPrivateMessageResponse({
|
||||
analysisOrder = await getAnalysisOrder({ analysisOrderId })
|
||||
medusaOrderId = analysisOrder.medusa_order_id;
|
||||
} catch (e) {
|
||||
if (IS_ENABLED_DELETE_PRIVATE_MESSAGE) {
|
||||
await deletePrivateMessage(privateMessageId);
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
|
||||
let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>;
|
||||
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 });
|
||||
const status = await syncPrivateMessage({ messageResponse, order: analysisOrder });
|
||||
|
||||
await createMedipostActionLog({
|
||||
action: 'sync_analysis_results_from_medipost',
|
||||
@@ -394,7 +419,7 @@ export async function getPrivateMessage(messageId: string) {
|
||||
await validateMedipostResponse(data, { canHaveEmptyCode: true });
|
||||
|
||||
return {
|
||||
message: parseXML(data) as MedipostOrderResponse,
|
||||
message: (await parseXML(data)) as MedipostOrderResponse,
|
||||
xml: data as string,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,5 +29,5 @@ export async function getLatestPublicMessageListItem() {
|
||||
throw new Error('Failed to get public message list');
|
||||
}
|
||||
|
||||
return getLatestMessage({ messages: data?.messages });
|
||||
return await getLatestMessage({ messages: data?.messages });
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { MedipostValidationError } from './MedipostValidationError';
|
||||
import { parseXML } from '../util/xml.service';
|
||||
|
||||
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;
|
||||
if (canHaveEmptyCode) {
|
||||
if (code && code !== 0) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
export function parseXML(xml: string) {
|
||||
export async function parseXML(xml: string) {
|
||||
const parser = new XMLParser({ ignoreAttributes: false });
|
||||
return parser.parse(xml);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ export type AnalysisResultDetailsElementResults = {
|
||||
responseTime: string | null;
|
||||
responseValue: number | null;
|
||||
responseValueIsNegative: boolean | null;
|
||||
responseValueIsWithinNorm: boolean | null;
|
||||
normLowerIncluded: boolean;
|
||||
normUpperIncluded: boolean;
|
||||
status: string;
|
||||
|
||||
@@ -80,7 +80,7 @@ class UserAnalysesApi {
|
||||
.from('analysis_responses')
|
||||
.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(*))`,
|
||||
)
|
||||
.eq('user_id', user.id)
|
||||
@@ -192,6 +192,10 @@ class UserAnalysesApi {
|
||||
}
|
||||
return nestedElements.map((element) => {
|
||||
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 {
|
||||
status: element.UuringOlek,
|
||||
unit: element.Mootyhik,
|
||||
@@ -199,8 +203,9 @@ class UserAnalysesApi {
|
||||
normUpper: elementVastus?.NormYlem?.['#text'],
|
||||
normStatus: elementVastus?.NormiStaatus,
|
||||
responseTime: elementVastus?.VastuseAeg,
|
||||
responseValue: elementVastus?.VastuseVaartus,
|
||||
responseValueIsNegative: elementVastus?.VastuseVaartus === 'Negatiivne',
|
||||
response_value: responseValueIsNegative || !responseValueIsNumeric ? null : (responseValue ?? null),
|
||||
response_value_is_negative: responseValueIsNumeric ? null : responseValueIsNegative,
|
||||
response_value_is_within_norm: responseValueIsNumeric ? null : responseValueIsWithinNorm,
|
||||
normLowerIncluded: elementVastus?.NormAlum?.['@_kaasaarvatud'] === 'JAH',
|
||||
normUpperIncluded: elementVastus?.NormYlem?.['@_kaasaarvatud'] === 'JAH',
|
||||
analysisElementOriginalId: element.UuringId,
|
||||
@@ -216,6 +221,7 @@ class UserAnalysesApi {
|
||||
responseTime: elementResponse.response_time,
|
||||
responseValue: elementResponse.response_value,
|
||||
responseValueIsNegative: elementResponse.response_value_is_negative === true,
|
||||
responseValueIsWithinNorm: elementResponse.response_value_is_within_norm === true,
|
||||
normLowerIncluded: elementResponse.norm_lower_included,
|
||||
normUpperIncluded: elementResponse.norm_upper_included,
|
||||
status: elementResponse.status,
|
||||
|
||||
@@ -19,6 +19,7 @@ const ElementSchema = z.object({
|
||||
response_time: z.string(),
|
||||
response_value: z.number(),
|
||||
response_value_is_negative: z.boolean(),
|
||||
response_value_is_within_norm: z.boolean(),
|
||||
norm_lower_included: z.boolean(),
|
||||
norm_upper_included: z.boolean(),
|
||||
status: z.string(),
|
||||
@@ -78,6 +79,7 @@ export type AnalysisResultDetailsElementResults = {
|
||||
responseTime: string | null;
|
||||
responseValue: number | null;
|
||||
responseValueIsNegative: boolean | null;
|
||||
responseValueIsWithinNorm: boolean | null;
|
||||
normLowerIncluded: boolean;
|
||||
normUpperIncluded: boolean;
|
||||
status: string;
|
||||
|
||||
@@ -688,6 +688,7 @@ export type Database = {
|
||||
response_time: string
|
||||
response_value: number | null
|
||||
response_value_is_negative?: boolean | null
|
||||
response_value_is_within_norm?: boolean | null
|
||||
status: string
|
||||
unit: string | null
|
||||
updated_at: string | null
|
||||
@@ -708,6 +709,7 @@ export type Database = {
|
||||
response_time: string
|
||||
response_value: number | null
|
||||
response_value_is_negative?: boolean | null
|
||||
response_value_is_within_norm?: boolean | null
|
||||
status: string
|
||||
unit?: string | null
|
||||
updated_at?: string | null
|
||||
@@ -728,6 +730,7 @@ export type Database = {
|
||||
response_time?: string
|
||||
response_value?: number | null
|
||||
response_value_is_negative?: boolean | null
|
||||
response_value_is_within_norm?: boolean | null
|
||||
status: string
|
||||
unit?: string | null
|
||||
updated_at?: string | null
|
||||
|
||||
@@ -7,9 +7,16 @@
|
||||
"noAnalysisElements": "No analysis orders found",
|
||||
"noAnalysisOrders": "No analysis orders found",
|
||||
"analysisDate": "Analysis result date",
|
||||
"cancelled": "Cancelled",
|
||||
"results": {
|
||||
"range": {
|
||||
"normal": "Normal range"
|
||||
},
|
||||
"value": {
|
||||
"negative": "Negative",
|
||||
"positive": "Positive",
|
||||
"isWithinNorm": "Within norm",
|
||||
"isNotWithinNorm": "Not within norm"
|
||||
}
|
||||
},
|
||||
"orderTitle": "Order number {{orderNumber}}",
|
||||
|
||||
@@ -7,9 +7,16 @@
|
||||
"noAnalysisElements": "Veel ei ole tellitud analüüse",
|
||||
"noAnalysisOrders": "Veel ei ole analüüside tellimusi",
|
||||
"analysisDate": "Analüüsi vastuse kuupäev",
|
||||
"cancelled": "Tühistatud",
|
||||
"results": {
|
||||
"range": {
|
||||
"normal": "Normaalne vahemik"
|
||||
},
|
||||
"value": {
|
||||
"negative": "Negatiivne",
|
||||
"positive": "Positiivne",
|
||||
"isWithinNorm": "Normi piires",
|
||||
"isNotWithinNorm": "Normi piirest väljas"
|
||||
}
|
||||
},
|
||||
"orderTitle": "Tellimus {{orderNumber}}",
|
||||
|
||||
@@ -7,9 +7,16 @@
|
||||
"noAnalysisElements": "Анализы еще не заказаны",
|
||||
"noAnalysisOrders": "Пока нет заказов на анализы",
|
||||
"analysisDate": "Дата результата анализа",
|
||||
"cancelled": "Отменен",
|
||||
"results": {
|
||||
"range": {
|
||||
"normal": "Нормальный диапазон"
|
||||
},
|
||||
"value": {
|
||||
"negative": "Отрицательный",
|
||||
"positive": "Положительный",
|
||||
"isWithinNorm": "В норме",
|
||||
"isNotWithinNorm": "Не в норме"
|
||||
}
|
||||
},
|
||||
"orderTitle": "Заказ {{orderNumber}}"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE medreport.analysis_response_elements
|
||||
ADD COLUMN response_value_is_within_norm BOOLEAN;
|
||||
Reference in New Issue
Block a user