diff --git a/frontend/public/locales/de/common.json b/frontend/public/locales/de/common.json index 6896c23e..cfbe4093 100644 --- a/frontend/public/locales/de/common.json +++ b/frontend/public/locales/de/common.json @@ -176,7 +176,8 @@ "viewToggle": { "cards": "Karten", "table": "Tabelle", - "components": "Komponenten" + "components": "Komponenten", + "matrix": "Matrix" }, "filters": { "title": "Filter & Suche", diff --git a/frontend/public/locales/de/medical.json b/frontend/public/locales/de/medical.json index 0dca2aae..65fd8022 100644 --- a/frontend/public/locales/de/medical.json +++ b/frontend/public/locales/de/medical.json @@ -1984,5 +1984,24 @@ "borderline": "Grenzwertig", "unknown": "Unbekannt" } + }, + "labMatrix": { + "filterAll": "Alle ({{count}})", + "filterAbnormalOnly": "Nur Auffällige", + "filterByCategory": "Nach Kategorie filtern...", + "resultsCount": "{{results}} Ergebnisse", + "parametersCount": "{{parameters}} Parameter", + "columnParameter": "Parameter", + "columnUnit": "Einheit", + "loading": "Matrixdaten werden geladen...", + "noData": "Keine Testkomponenten gefunden. Öffne ein Laborergebnis und füge Testkomponenten hinzu, oder nutze den Schnell-PDF-Import.", + "errorTitle": "Fehler beim Laden der Matrix", + "legend": "Legende", + "statusNormal": "Normal", + "statusHigh": "Hoch", + "statusLow": "Niedrig", + "statusCritical": "Kritisch", + "statusAbnormal": "Auffällig", + "statusBorderline": "Grenzwertig" } -} \ No newline at end of file +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index f432eff5..a403139c 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -248,7 +248,8 @@ "viewToggle": { "cards": "Cards", "table": "Table", - "components": "Components" + "components": "Components", + "matrix": "Matrix" }, "filters": { "title": "Filters & Search", diff --git a/frontend/public/locales/en/medical.json b/frontend/public/locales/en/medical.json index da978f82..9efedd24 100644 --- a/frontend/public/locales/en/medical.json +++ b/frontend/public/locales/en/medical.json @@ -1984,5 +1984,24 @@ "borderline": "Borderline", "unknown": "Unknown" } + }, + "labMatrix": { + "filterAll": "All ({{count}})", + "filterAbnormalOnly": "Abnormal Only", + "filterByCategory": "Filter by category...", + "resultsCount": "{{results}} results", + "parametersCount": "{{parameters}} parameters", + "columnParameter": "Parameter", + "columnUnit": "Unit", + "loading": "Loading matrix data...", + "noData": "No test components found. Open a lab result and add test components, or use Quick PDF Import.", + "errorTitle": "Error loading matrix", + "legend": "Legend", + "statusNormal": "Normal", + "statusHigh": "High", + "statusLow": "Low", + "statusCritical": "Critical", + "statusAbnormal": "Abnormal", + "statusBorderline": "Borderline" } -} \ No newline at end of file +} diff --git a/frontend/public/locales/es/common.json b/frontend/public/locales/es/common.json index 320a1fc0..4cc94818 100644 --- a/frontend/public/locales/es/common.json +++ b/frontend/public/locales/es/common.json @@ -209,7 +209,8 @@ "viewToggle": { "cards": "Tarjetas", "table": "Tabla", - "components": "Componentes" + "components": "Componentes", + "matrix": "Matriz" }, "filters": { "title": "Filtros y búsqueda", diff --git a/frontend/public/locales/es/medical.json b/frontend/public/locales/es/medical.json index c8fd26a1..8c614bc2 100644 --- a/frontend/public/locales/es/medical.json +++ b/frontend/public/locales/es/medical.json @@ -1984,5 +1984,24 @@ "borderline": "Límite", "unknown": "Desconocido" } + }, + "labMatrix": { + "filterAll": "Todos ({{count}})", + "filterAbnormalOnly": "Solo anormales", + "filterByCategory": "Filtrar por categoría...", + "resultsCount": "{{results}} resultados", + "parametersCount": "{{parameters}} parámetros", + "columnParameter": "Parámetro", + "columnUnit": "Unidad", + "loading": "Cargando datos de la matriz...", + "noData": "No se encontraron componentes de prueba. Abre un resultado de laboratorio y añade componentes, o usa la importación rápida de PDF.", + "errorTitle": "Error al cargar la matriz", + "legend": "Leyenda", + "statusNormal": "Normal", + "statusHigh": "Alto", + "statusLow": "Bajo", + "statusCritical": "Crítico", + "statusAbnormal": "Anormal", + "statusBorderline": "Límite" } -} \ No newline at end of file +} diff --git a/frontend/public/locales/fr/common.json b/frontend/public/locales/fr/common.json index aa1c7ecc..ea0942ec 100644 --- a/frontend/public/locales/fr/common.json +++ b/frontend/public/locales/fr/common.json @@ -209,7 +209,8 @@ "viewToggle": { "cards": "Cartes", "table": "Tableau", - "components": "Composants" + "components": "Composants", + "matrix": "Matrice" }, "filters": { "title": "Filtres et recherche", diff --git a/frontend/public/locales/fr/medical.json b/frontend/public/locales/fr/medical.json index 297b88d1..9cb3a33c 100644 --- a/frontend/public/locales/fr/medical.json +++ b/frontend/public/locales/fr/medical.json @@ -202,12 +202,12 @@ "chemistry": "Chimie", "hematology": "Hématologie", "hepatology": "Hépatologie", - "immunology": "Immunologie", + "immunology": "Immunologie", "genetics": "Génétique", "cardiology": "Cardiologie", "pulmonology": "Pulmonologie", - "hearing": "Ouïe", - "stomatology": "Stomatologie", + "hearing": "Ouïe", + "stomatology": "Stomatologie", "other": "Autre" }, "testType": { @@ -236,7 +236,7 @@ "relatedConditions": "Conditions concernées", "linkConditionsTitle": "Connecter des conditions médicales", "linkConditionsDescription": "Associez ces résultats d'analyse aux conditions médicales concernées pour améliorer le suivi et l'organisation.", - "testComponents": "Composants d'analyse (optionnel)", + "testComponents": "Composants d'analyse (optionnel)", "testComponentsDescription": "Ajoutez optionnellement des valeurs d'analyse individuelles. Vous pouvez également en ajouter plus ultérieurement.", "addTestComponent": "Ajouter des composants d'analyse", "hideTestComponents": "Cacher les composants d'analyse", @@ -1232,19 +1232,19 @@ } }, "name": { - "label": "Nom complet", - "placeholder": "Dr. Jeanne Dupont", - "description": "Nom complet du docteur, dont son titre" + "label": "Nom complet", + "placeholder": "Dr. Jeanne Dupont", + "description": "Nom complet du docteur, dont son titre" }, "specialty": { - "label": "Spécialité médicale", - "placeholder": "Cherchez parmi les spécialités ou créez-en une...", - "description": "Sélectionnez dans la liste ou saisissez une spécialité" + "label": "Spécialité médicale", + "placeholder": "Cherchez parmi les spécialités ou créez-en une...", + "description": "Sélectionnez dans la liste ou saisissez une spécialité" }, "practice": { - "label": "Etablissement/Clinique", - "placeholder": "Cherchez ou créez un établissement...", - "description": "Sélectionnez un établissement existant ou commencez à saisir pour en créer un" + "label": "Etablissement/Clinique", + "placeholder": "Cherchez ou créez un établissement...", + "description": "Sélectionnez un établissement existant ou commencez à saisir pour en créer un" }, "rating": { "label": "Avis", @@ -1381,9 +1381,9 @@ "description": "Nom spécifique ou identification de l'emplacement" }, "streetAddress": { - "label": "Adresse physique", - "placeholder": "123 Grand-rue", - "description": "Adresse physique" + "label": "Adresse physique", + "placeholder": "123 Grand-rue", + "description": "Adresse physique" }, "city": { "label": "Cité", @@ -1951,7 +1951,7 @@ "form": { "invalidPhoneDigits": "Un numéro de téléphone ne peut contenir que des chiffres, des espaces, des tirets, des parenthèses, des points, et des +", "invalidWebsiteUrl": "Veuillez saisir une URL de site web valide" - }, + }, "componentCatalog": { "searchPlaceholder": "Rechercher dans les résultats d'analyse...", "filterCategory": "Catégorie", @@ -1984,5 +1984,24 @@ "borderline": "Inconcluant", "unknown": "Inconnu" } + }, + "labMatrix": { + "filterAll": "Tous ({{count}})", + "filterAbnormalOnly": "Anormaux uniquement", + "filterByCategory": "Filtrer par catégorie...", + "resultsCount": "{{results}} résultats", + "parametersCount": "{{parameters}} paramètres", + "columnParameter": "Paramètre", + "columnUnit": "Unité", + "loading": "Chargement des données de la matrice...", + "noData": "Aucun composant de test trouvé. Ouvrez un résultat de laboratoire et ajoutez des composants, ou utilisez l'importation rapide PDF.", + "errorTitle": "Erreur lors du chargement de la matrice", + "legend": "Légende", + "statusNormal": "Normal", + "statusHigh": "Élevé", + "statusLow": "Bas", + "statusCritical": "Critique", + "statusAbnormal": "Anormal", + "statusBorderline": "Limite" } } diff --git a/frontend/public/locales/it/common.json b/frontend/public/locales/it/common.json index ccbe35e4..175b26db 100644 --- a/frontend/public/locales/it/common.json +++ b/frontend/public/locales/it/common.json @@ -209,7 +209,8 @@ "viewToggle": { "cards": "Schede", "table": "Tabella", - "components": "Componenti" + "components": "Componenti", + "matrix": "Matrice" }, "filters": { "title": "Filtri e ricerca", diff --git a/frontend/public/locales/it/medical.json b/frontend/public/locales/it/medical.json index 96c6e88f..9074fc32 100644 --- a/frontend/public/locales/it/medical.json +++ b/frontend/public/locales/it/medical.json @@ -1984,5 +1984,24 @@ "borderline": "Limite", "unknown": "Sconosciuto" } + }, + "labMatrix": { + "filterAll": "Tutti ({{count}})", + "filterAbnormalOnly": "Solo anomali", + "filterByCategory": "Filtra per categoria...", + "resultsCount": "{{results}} risultati", + "parametersCount": "{{parameters}} parametri", + "columnParameter": "Parametro", + "columnUnit": "Unità", + "loading": "Caricamento dati matrice...", + "noData": "Nessun componente di test trovato. Apri un risultato di laboratorio e aggiungi componenti, oppure usa l'importazione rapida PDF.", + "errorTitle": "Errore nel caricamento della matrice", + "legend": "Legenda", + "statusNormal": "Normale", + "statusHigh": "Alto", + "statusLow": "Basso", + "statusCritical": "Critico", + "statusAbnormal": "Anomalo", + "statusBorderline": "Borderline" } -} \ No newline at end of file +} diff --git a/frontend/public/locales/pt/common.json b/frontend/public/locales/pt/common.json index 6d3d1d09..14b6f541 100644 --- a/frontend/public/locales/pt/common.json +++ b/frontend/public/locales/pt/common.json @@ -209,7 +209,8 @@ "viewToggle": { "cards": "Cards", "table": "Tabela", - "components": "Componentes" + "components": "Componentes", + "matrix": "Matriz" }, "filters": { "title": "Filtros e Pesquisa", diff --git a/frontend/public/locales/pt/medical.json b/frontend/public/locales/pt/medical.json index 5c98fc0e..d975656e 100644 --- a/frontend/public/locales/pt/medical.json +++ b/frontend/public/locales/pt/medical.json @@ -1984,5 +1984,24 @@ "borderline": "Limítrofe", "unknown": "Desconhecido" } + }, + "labMatrix": { + "filterAll": "Todos ({{count}})", + "filterAbnormalOnly": "Apenas anormais", + "filterByCategory": "Filtrar por categoria...", + "resultsCount": "{{results}} resultados", + "parametersCount": "{{parameters}} parâmetros", + "columnParameter": "Parâmetro", + "columnUnit": "Unidade", + "loading": "Carregando dados da matriz...", + "noData": "Nenhum componente de teste encontrado. Abra um resultado de laboratório e adicione componentes, ou use a importação rápida de PDF.", + "errorTitle": "Erro ao carregar a matriz", + "legend": "Legenda", + "statusNormal": "Normal", + "statusHigh": "Alto", + "statusLow": "Baixo", + "statusCritical": "Crítico", + "statusAbnormal": "Anormal", + "statusBorderline": "Limítrofe" } -} \ No newline at end of file +} diff --git a/frontend/src/components/medical/labresults/LabResultMatrix.tsx b/frontend/src/components/medical/labresults/LabResultMatrix.tsx new file mode 100644 index 00000000..278e0d71 --- /dev/null +++ b/frontend/src/components/medical/labresults/LabResultMatrix.tsx @@ -0,0 +1,615 @@ +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { + Text, + Paper, + Group, + Stack, + Loader, + Center, + Alert, + Badge, + SegmentedControl, + Tooltip, + ScrollArea, + MultiSelect, +} from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +import { labTestComponentApi } from '../../../services/api/labTestComponentApi'; +import { useDateFormat } from '../../../hooks/useDateFormat'; +import { getCategoryColor, getCategoryDisplayName, CATEGORY_SELECT_OPTIONS } from '../../../constants/labCategories'; +import type { ComponentStatus } from '../../../constants/labCategories'; +import logger from '../../../services/logger'; + +// --- Types --- + +interface LabResult { + id: number | string; + test_name?: string; + completed_date?: string; + [key: string]: unknown; +} + +interface TestComponent { + test_name: string; + category?: string; + unit?: string; + display_order?: number; + result_type?: string; + value?: number | string | null; + qualitative_value?: string | null; + status?: ComponentStatus | string; + ref_range_min?: number | null; + ref_range_max?: number | null; + ref_range_text?: string | null; +} + +interface LabResultMatrixProps { + labResults: LabResult[]; +} + +interface TestMeta { + test_name: string; + category: string; + unit?: string; + display_order: number; + result_type: string; +} + +interface MatrixData { + tests: TestMeta[]; + valueLookup: Record>; + categories: string[]; +} + +type GroupedRow = + | { type: 'header'; category: string } + | ({ type: 'test' } & TestMeta); + +// --- Status styling --- + +const STATUS_COLORS: Record = { + normal: { bg: 'transparent', text: 'inherit' }, + high: { bg: 'var(--mantine-color-red-0)', text: 'var(--mantine-color-red-8)' }, + low: { bg: 'var(--mantine-color-blue-0)', text: 'var(--mantine-color-blue-8)' }, + critical: { bg: 'var(--mantine-color-red-1)', text: 'var(--mantine-color-red-9)' }, + abnormal: { bg: 'var(--mantine-color-orange-0)', text: 'var(--mantine-color-orange-8)' }, + borderline: { bg: 'var(--mantine-color-yellow-0)', text: 'var(--mantine-color-yellow-9)' }, +}; + +const STATUS_INDICATORS: Record = { + high: '\u2191', // up arrow + low: '\u2193', // down arrow + critical: '\u2191\u2191', // double up arrow + abnormal: '\u26A0', // warning sign + borderline: '\u2248', // approximately equal +}; + +const CATEGORY_ORDER = CATEGORY_SELECT_OPTIONS.map(o => o.value); + +// --- Component --- + +const LabResultMatrix: React.FC = React.memo(({ labResults }) => { + const { t } = useTranslation('medical'); + const { formatDate: formatDateHook, locale } = useDateFormat(); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [componentsMap, setComponentsMap] = useState>({}); + const [filterMode, setFilterMode] = useState('all'); + const [selectedCategories, setSelectedCategories] = useState([]); + const abortRef = useRef(null); + + // Sort lab results by date ascending + const sortedResults = useMemo(() => { + return (labResults || []) + .filter((r) => r.completed_date) + .sort((a, b) => new Date(a.completed_date!).getTime() - new Date(b.completed_date!).getTime()); + }, [labResults]); + + // Fetch components for all lab results with AbortController + const fetchComponents = useCallback(async () => { + if (!sortedResults.length) { + setLoading(false); + return; + } + + // Cancel previous requests + if (abortRef.current) { + abortRef.current.abort(); + } + const controller = new AbortController(); + abortRef.current = controller; + + try { + setLoading(true); + setError(null); + + const promises = sortedResults.map(async (lr) => { + try { + const response = await labTestComponentApi.getByLabResult(lr.id, undefined, undefined, controller.signal); + return { labResultId: lr.id, components: (response.data || []) as TestComponent[], failed: false }; + } catch (err: any) { + if (err.name === 'AbortError' || err.name === 'CanceledError') throw err; + logger.warn('lab_matrix_component_fetch_error', { + labResultId: lr.id, + error: err.message, + }); + return { labResultId: lr.id, components: [] as TestComponent[], failed: true, errorMsg: err.message }; + } + }); + + const allResults = await Promise.all(promises); + const failedCount = allResults.filter((r) => r.failed).length; + + if (failedCount === allResults.length && failedCount > 0) { + const firstError = allResults.find((r) => r.failed)?.errorMsg; + setError(firstError || t('labMatrix.errorTitle', 'Error loading matrix')); + return; + } + + const map: Record = {}; + allResults.forEach(({ labResultId, components }) => { + map[labResultId] = components; + }); + setComponentsMap(map); + } catch (err: any) { + if (err.name === 'AbortError' || err.name === 'CanceledError') return; + logger.error('lab_matrix_fetch_error', { error: err.message }); + setError(err.message || t('labMatrix.errorTitle', 'Error loading matrix')); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + }, [sortedResults, t]); + + useEffect(() => { + fetchComponents(); + }, [fetchComponents]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (abortRef.current) abortRef.current.abort(); + }; + }, []); + + // Build matrix data + const matrixData = useMemo((): MatrixData | null => { + if (!sortedResults.length) return null; + + const testMap = new Map(); + sortedResults.forEach((lr) => { + const components = componentsMap[lr.id] || []; + components.forEach((comp) => { + const key = comp.test_name; + if (!testMap.has(key)) { + testMap.set(key, { + test_name: comp.test_name, + category: comp.category || 'other', + unit: comp.unit, + display_order: comp.display_order || 999, + result_type: comp.result_type || 'quantitative', + }); + } + const existing = testMap.get(key)!; + if (comp.unit) existing.unit = comp.unit; + if (comp.category) existing.category = comp.category; + }); + }); + + const tests = Array.from(testMap.values()).sort((a, b) => { + const catA = CATEGORY_ORDER.indexOf(a.category); + const catB = CATEGORY_ORDER.indexOf(b.category); + if (catA !== catB) return (catA === -1 ? 99 : catA) - (catB === -1 ? 99 : catB); + if (a.display_order !== b.display_order) return a.display_order - b.display_order; + return a.test_name.localeCompare(b.test_name); + }); + + const valueLookup: Record> = {}; + sortedResults.forEach((lr) => { + const components = componentsMap[lr.id] || []; + components.forEach((comp) => { + if (!valueLookup[comp.test_name]) valueLookup[comp.test_name] = {}; + valueLookup[comp.test_name][lr.id] = comp; + }); + }); + + const categories = [...new Set(tests.map((t) => t.category))]; + return { tests, valueLookup, categories }; + }, [sortedResults, componentsMap]); + + // Filters + const filteredTests = useMemo(() => { + if (!matrixData) return []; + let tests = matrixData.tests; + + if (selectedCategories.length > 0) { + tests = tests.filter((t) => selectedCategories.includes(t.category)); + } + + if (filterMode === 'abnormal') { + tests = tests.filter((t) => + sortedResults.some((lr) => { + const comp = matrixData.valueLookup[t.test_name]?.[lr.id]; + return comp && comp.status && comp.status !== 'normal'; + }), + ); + } + return tests; + }, [matrixData, filterMode, selectedCategories, sortedResults]); + + // Grouped by category + const groupedTests = useMemo((): GroupedRow[] => { + const groups: GroupedRow[] = []; + let currentCategory: string | null = null; + filteredTests.forEach((test) => { + if (test.category !== currentCategory) { + currentCategory = test.category; + groups.push({ type: 'header', category: currentCategory }); + } + groups.push({ type: 'test', ...test }); + }); + return groups; + }, [filteredTests]); + + const formatColumnDate = useCallback( + (dateStr: string | undefined) => { + if (!dateStr) return ''; + return formatDateHook(dateStr); + }, + [formatDateHook], + ); + + const formatValue = useCallback( + (comp: TestComponent | undefined) => { + if (!comp) return null; + if (comp.result_type === 'qualitative') return comp.qualitative_value || null; + if (comp.value != null) { + const val = Number(comp.value); + if (Number.isInteger(val)) return val.toString(); + return val.toLocaleString(locale, { maximumFractionDigits: 2 }); + } + return null; + }, + [locale], + ); + + const getRefText = (comp: TestComponent | undefined): string => { + if (!comp) return ''; + const parts: string[] = []; + if (comp.ref_range_min != null && comp.ref_range_max != null) { + parts.push(`Ref: ${comp.ref_range_min}\u2013${comp.ref_range_max}`); + } else if (comp.ref_range_max != null) { + parts.push(`Ref: < ${comp.ref_range_max}`); + } else if (comp.ref_range_min != null) { + parts.push(`Ref: > ${comp.ref_range_min}`); + } else if (comp.ref_range_text) { + parts.push(`Ref: ${comp.ref_range_text}`); + } + if (comp.unit) parts.push(comp.unit); + return parts.join(' '); + }; + + // --- Render --- + + if (loading) { + return ( +
+ + + + {t('labMatrix.loading', 'Loading matrix data...')} + + +
+ ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!sortedResults.length || !matrixData || !matrixData.tests.length) { + return ( + + {t( + 'labMatrix.noData', + 'No test components found. Open a lab result and add test components, or use Quick PDF Import.', + )} + + ); + } + + const categoryOptions = (matrixData.categories || []).map((c) => ({ + value: c, + label: getCategoryDisplayName(c), + })); + + return ( + + {/* Filters */} + + + + + + {t('labMatrix.resultsCount', '{{results}} results', { + results: sortedResults.length, + })}{' '} + ·{' '} + {t('labMatrix.parametersCount', '{{parameters}} parameters', { + parameters: filteredTests.length, + })}{' '} + · {formatColumnDate(sortedResults[0]?.completed_date)} –{' '} + {formatColumnDate(sortedResults[sortedResults.length - 1]?.completed_date)} + + + + + {/* Matrix */} + + + + + + + + + {sortedResults.map((lr) => ( + + ))} + + + + {groupedTests.map((row) => { + if (row.type === 'header') { + const catColor = getCategoryColor(row.category); + return ( + + + + ); + } + + return ( + + + + {sortedResults.map((lr) => { + const comp = matrixData.valueLookup[row.test_name]?.[lr.id]; + const value = formatValue(comp); + const status = comp?.status || null; + const colors = STATUS_COLORS[status || ''] || STATUS_COLORS.normal; + const indicator = status ? STATUS_INDICATORS[status] : undefined; + const refText = getRefText(comp); + + return ( + + ); + })} + + ); + })} + +
+ {t('labMatrix.columnParameter', 'Parameter')} +
+ {t('labMatrix.columnParameter', 'Parameter')} + + {t('labMatrix.columnUnit', 'Unit')} + + + + {formatColumnDate(lr.completed_date)} + + +
+ {getCategoryDisplayName(row.category)} +
+ + {row.test_name} + + + {row.unit || ''} + + {value ? ( + + + {value} + {indicator && ( + + {indicator} + + )} + + + ) : ( + + )} +
+
+
+ + {/* Legend */} + + + {t('labMatrix.legend', 'Legend')}: + + + {t('labMatrix.statusNormal', 'Normal')} + + + {t('labMatrix.statusHigh', 'High')} {STATUS_INDICATORS.high} + + + {t('labMatrix.statusLow', 'Low')} {STATUS_INDICATORS.low} + + + {t('labMatrix.statusCritical', 'Critical')} {STATUS_INDICATORS.critical} + + + {t('labMatrix.statusAbnormal', 'Abnormal')} {STATUS_INDICATORS.abnormal} + + + {t('labMatrix.statusBorderline', 'Borderline')} {STATUS_INDICATORS.borderline} + + +
+ ); +}); + +LabResultMatrix.displayName = 'LabResultMatrix'; + +export default LabResultMatrix; diff --git a/frontend/src/components/medical/labresults/__tests__/LabResultMatrix.test.tsx b/frontend/src/components/medical/labresults/__tests__/LabResultMatrix.test.tsx new file mode 100644 index 00000000..a20c11b1 --- /dev/null +++ b/frontend/src/components/medical/labresults/__tests__/LabResultMatrix.test.tsx @@ -0,0 +1,336 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import LabResultMatrix from '../LabResultMatrix'; + +// Mock Mantine components +vi.mock('@mantine/core', () => ({ + Stack: ({ children, ...props }: any) =>
{children}
, + Group: ({ children, ...props }: any) =>
{children}
, + Paper: ({ children, ...props }: any) =>
{children}
, + Text: ({ children, ...props }: any) => {children}, + Loader: ({ ...props }: any) =>
, + Center: ({ children, ...props }: any) =>
{children}
, + Alert: ({ children, title, ...props }: any) => ( +
+ {title && {title}} + {children} +
+ ), + Badge: ({ children, ...props }: any) => {children}, + SegmentedControl: ({ data, value, onChange, ...props }: any) => ( +
+ {data.map((d: any) => ( + + ))} +
+ ), + Tooltip: ({ children }: any) => <>{children}, + ScrollArea: ({ children, ...props }: any) =>
{children}
, + MultiSelect: ({ ...props }: any) =>
, +})); + +// Stable t function reference (avoid infinite useEffect loop) +const stableT = (key: string, fallback: string, opts?: Record) => { + if (typeof fallback === 'string') { + if (opts) { + return fallback.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts[k] ?? '')); + } + return fallback; + } + return key; +}; + +// Mock react-i18next with stable t reference +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: stableT, + i18n: { language: 'en', changeLanguage: () => Promise.resolve() }, + }), + Trans: ({ children }: any) => children, + I18nextProvider: ({ children }: any) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +// Stable formatDate function +const stableFormatDate = (dateStr: string) => { + const d = new Date(dateStr); + return d.toLocaleDateString('en-US', { day: '2-digit', month: '2-digit', year: '2-digit' }); +}; + +const stableDateFormatResult = { + formatDate: stableFormatDate, + locale: 'en-US', +}; + +// Mock useDateFormat with stable reference +vi.mock('../../../../hooks/useDateFormat', () => ({ + useDateFormat: () => stableDateFormatResult, +})); + +// Mock labCategories +vi.mock('../../../../constants/labCategories', () => ({ + CATEGORY_SELECT_OPTIONS: [ + { value: 'hematology', label: 'Hematology' }, + { value: 'chemistry', label: 'Chemistry' }, + { value: 'other', label: 'Other' }, + ], + getCategoryDisplayName: (cat: string) => cat.charAt(0).toUpperCase() + cat.slice(1), + getCategoryColor: (cat: string) => { + const map: Record = { hematology: 'red', chemistry: 'blue', other: 'gray' }; + return map[cat] || 'gray'; + }, +})); + +// Mock labTestComponentApi +const mockGetByLabResult = vi.fn(); +vi.mock('../../../../services/api/labTestComponentApi', () => ({ + labTestComponentApi: { + getByLabResult: (...args: any[]) => mockGetByLabResult(...args), + }, +})); + +// Mock logger +vi.mock('../../../../services/logger', () => ({ + default: { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})); + +// --- Test data --- + +const sampleLabResults = [ + { id: 1, test_name: 'Blood Panel', completed_date: '2024-01-10' }, + { id: 2, test_name: 'Blood Panel', completed_date: '2024-03-15' }, +]; + +const sampleComponents1 = [ + { + test_name: 'Hemoglobin', + category: 'hematology', + unit: 'g/dL', + display_order: 1, + result_type: 'quantitative', + value: 14.2, + status: 'normal', + ref_range_min: 12.0, + ref_range_max: 17.5, + }, + { + test_name: 'Glucose', + category: 'chemistry', + unit: 'mg/dL', + display_order: 1, + result_type: 'quantitative', + value: 110, + status: 'high', + ref_range_min: 70, + ref_range_max: 100, + }, +]; + +const sampleComponents2 = [ + { + test_name: 'Hemoglobin', + category: 'hematology', + unit: 'g/dL', + display_order: 1, + result_type: 'quantitative', + value: 13.8, + status: 'normal', + ref_range_min: 12.0, + ref_range_max: 17.5, + }, + { + test_name: 'Glucose', + category: 'chemistry', + unit: 'mg/dL', + display_order: 1, + result_type: 'quantitative', + value: 95, + status: 'normal', + ref_range_min: 70, + ref_range_max: 100, + }, +]; + +describe('LabResultMatrix', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('shows loading state initially', () => { + mockGetByLabResult.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + expect(screen.getByText('Loading matrix data...')).toBeInTheDocument(); + }); + + it('renders empty state with no lab results', async () => { + render(); + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + expect(screen.getByText(/No test components found/)).toBeInTheDocument(); + }); + + it('renders empty state when lab results have no completed_date', async () => { + render(); + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + }); + + it('renders matrix table with valid data', async () => { + mockGetByLabResult + .mockResolvedValueOnce({ data: sampleComponents1 }) + .mockResolvedValueOnce({ data: sampleComponents2 }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Hemoglobin')).toBeInTheDocument(); + }); + + expect(screen.getByText('Glucose')).toBeInTheDocument(); + expect(screen.getByText('14.2')).toBeInTheDocument(); + expect(screen.getByText('13.8')).toBeInTheDocument(); + }); + + it('shows error state when all API calls fail', async () => { + mockGetByLabResult.mockRejectedValue(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + expect(screen.getByText('Network error')).toBeInTheDocument(); + }); + + it('renders category headers', async () => { + mockGetByLabResult + .mockResolvedValueOnce({ data: sampleComponents1 }) + .mockResolvedValueOnce({ data: sampleComponents2 }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Chemistry')).toBeInTheDocument(); + }); + + expect(screen.getByText('Hematology')).toBeInTheDocument(); + }); + + it('renders the segmented control for filter modes', async () => { + mockGetByLabResult + .mockResolvedValueOnce({ data: sampleComponents1 }) + .mockResolvedValueOnce({ data: sampleComponents2 }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('segmented-control')).toBeInTheDocument(); + }); + + expect(screen.getByText('All (2)')).toBeInTheDocument(); + expect(screen.getByText('Abnormal Only')).toBeInTheDocument(); + }); + + it('filters to abnormal only when clicked', async () => { + mockGetByLabResult + .mockResolvedValueOnce({ data: sampleComponents1 }) + .mockResolvedValueOnce({ data: sampleComponents2 }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Hemoglobin')).toBeInTheDocument(); + }); + + // Click "Abnormal Only" + const user = userEvent.setup(); + await user.click(screen.getByText('Abnormal Only')); + + // Glucose is high in the first result, should still show + await waitFor(() => { + expect(screen.getByText('Glucose')).toBeInTheDocument(); + }); + }); + + it('renders legend with all status types including borderline', async () => { + mockGetByLabResult + .mockResolvedValueOnce({ data: sampleComponents1 }) + .mockResolvedValueOnce({ data: sampleComponents2 }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Legend:')).toBeInTheDocument(); + }); + + const badges = screen.getAllByTestId('badge'); + expect(badges.length).toBe(6); // Normal, High, Low, Critical, Abnormal, Borderline + }); + + it('renders status indicators (arrows) for abnormal values', async () => { + mockGetByLabResult + .mockResolvedValueOnce({ data: sampleComponents1 }) + .mockResolvedValueOnce({ data: sampleComponents2 }); + + render(); + + await waitFor(() => { + expect(screen.getByText('110')).toBeInTheDocument(); + }); + + // Check for the up arrow indicator (Glucose has status 'high') + const arrowSpan = screen.getByLabelText('high'); + expect(arrowSpan).toBeInTheDocument(); + expect(arrowSpan.textContent).toBe('\u2191'); + }); + + it('uses proper table semantics with scope attributes', async () => { + mockGetByLabResult + .mockResolvedValueOnce({ data: sampleComponents1 }) + .mockResolvedValueOnce({ data: sampleComponents2 }); + + const { container } = render(); + + await waitFor(() => { + expect(screen.getByText('Hemoglobin')).toBeInTheDocument(); + }); + + // Check for scope="col" on header ths + const colHeaders = container.querySelectorAll('th[scope="col"]'); + expect(colHeaders.length).toBeGreaterThan(0); + + // Check for scope="row" on row headers (test names) + const rowHeaders = container.querySelectorAll('th[scope="row"]'); + expect(rowHeaders.length).toBeGreaterThan(0); + + // Check for table aria-label + const table = container.querySelector('table'); + expect(table).toHaveAttribute('aria-label'); + }); + + it('renders summary text with correct counts', async () => { + mockGetByLabResult + .mockResolvedValueOnce({ data: sampleComponents1 }) + .mockResolvedValueOnce({ data: sampleComponents2 }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/2 results/)).toBeInTheDocument(); + }); + + expect(screen.getByText(/2 parameters/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/shared/ViewToggle.jsx b/frontend/src/components/shared/ViewToggle.jsx index e0758e58..0a17efc2 100644 --- a/frontend/src/components/shared/ViewToggle.jsx +++ b/frontend/src/components/shared/ViewToggle.jsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; const MODE_CONFIG = { cards: { icon: '\uD83D\uDCCB', labelKey: 'viewToggle.cards', fallback: 'Cards' }, table: { icon: '\uD83D\uDCCA', labelKey: 'viewToggle.table', fallback: 'Table' }, + matrix: { icon: '\uD83D\uDCC0', labelKey: 'viewToggle.matrix', fallback: 'Matrix' }, components: { icon: '\uD83E\uDDEA', labelKey: 'viewToggle.components', fallback: 'Components' }, }; diff --git a/frontend/src/hooks/usePersistedViewMode.js b/frontend/src/hooks/usePersistedViewMode.js index 8dc2bd0c..8fbe8d06 100644 --- a/frontend/src/hooks/usePersistedViewMode.js +++ b/frontend/src/hooks/usePersistedViewMode.js @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; const LEGACY_KEY = 'medikeep_viewmode'; -const VALID_MODES = new Set(['cards', 'table', 'components']); +const VALID_MODES = new Set(['cards', 'table', 'components', 'matrix']); function readValidMode(key) { try { @@ -16,15 +16,15 @@ function readValidMode(key) { } /** - * Persists a page-specific view mode ('cards', 'table', or 'components') in localStorage. + * Persists a page-specific view mode in localStorage. * * Storage key: `medikeep_viewmode_${pageKey}`. Falls back to the legacy global * key `medikeep_viewmode` on first load for migration, then persists to the * page-specific key via useEffect. * * @param {string} pageKey - Page identifier (e.g. 'medications', 'lab-results'). - * @param {'cards' | 'table' | 'components'} [defaultMode='cards'] - Fallback when nothing is stored. - * @returns {['cards' | 'table' | 'components', (mode: 'cards' | 'table' | 'components') => void]} + * @param {'cards' | 'table' | 'components' | 'matrix'} [defaultMode='cards'] - Fallback when nothing is stored. + * @returns {['cards' | 'table' | 'components' | 'matrix', (mode: string) => void]} */ export function usePersistedViewMode(pageKey, defaultMode = 'cards') { if (typeof pageKey !== 'string' || pageKey.trim().length === 0) { diff --git a/frontend/src/pages/medical/LabResults.jsx b/frontend/src/pages/medical/LabResults.jsx index 175bdbf4..ca78d1c7 100644 --- a/frontend/src/pages/medical/LabResults.jsx +++ b/frontend/src/pages/medical/LabResults.jsx @@ -34,7 +34,7 @@ import LabResultCard from '../../components/medical/labresults/LabResultCard'; import LabResultViewModal from '../../components/medical/labresults/LabResultViewModal'; import LabResultFormWrapper from '../../components/medical/labresults/LabResultFormWrapper'; import LabResultQuickImportModal from '../../components/medical/labresults/LabResultQuickImportModal'; -import TestComponentCatalog from '../../components/medical/labresults/TestComponentCatalog'; +import LabResultMatrix from '../../components/medical/labresults/LabResultMatrix'; import { notifications } from '@mantine/notifications'; import { labTestComponentApi } from '../../services/api/labTestComponentApi'; import { sanitizeComponentForApi } from '../../utils/labTestComponentUtils'; @@ -545,8 +545,8 @@ const LabResults = () => { }, [isBlocking, resetSubmission]); const renderViewContent = () => { - if (viewMode === 'components') { - return currentPatient?.id ? : null; + if (viewMode === 'matrix') { + return ; } if (filteredLabResults.length === 0) { @@ -669,13 +669,13 @@ const LabResults = () => { ]} viewMode={viewMode} onViewModeChange={setViewMode} - viewModes={['cards', 'table', 'components']} + viewModes={['cards', 'table', 'matrix']} viewToggleSize="sm" mb={0} /> - {/* Mantine Filter Controls - hidden in components view (it has its own) */} - {viewMode !== 'components' && ( + {/* Mantine Filter Controls - hidden in matrix view (it has its own) */} + {viewMode !== 'matrix' && ( )}