From e65ada2961f141b9a4cfc2ea3fd16f791c118afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:55:08 +0200 Subject: [PATCH] feat(labs): merge markers, edit their aliases and pick a marker on review The marker list offers a merge with the server's preview: what moves, what is converted, which names become aliases, and a refusal when a unit cannot be converted; it says a merge cannot be undone. The marker page shows and edits the other spellings and follows a merged marker to its target. Scan and document review name the matched marker or offer its suggestions and a new marker; the choice is remembered. The biomarkerMerge feature flag is on. --- messages/de.json | 51 +++- messages/en.json | 51 +++- messages/es.json | 51 +++- messages/fr.json | 51 +++- messages/it.json | 51 +++- messages/ko.json | 51 +++- messages/pl.json | 51 +++- src/app/api/auth/me/route.ts | 2 +- .../documents/document-facts-review.tsx | 52 ++++ .../documents/use-document-facts.ts | 7 +- .../labs/__tests__/biomarker-aliases.test.tsx | 116 +++++++ .../__tests__/lab-biomarker-detail.test.tsx | 4 +- .../labs/__tests__/ocr-marker-match.test.tsx | 139 +++++++++ .../labs/__tests__/ocr-review-dialog.test.tsx | 24 +- .../__tests__/ocr-review-validation.test.ts | 4 + .../labs/__tests__/ocr-row-editor.test.tsx | 4 + src/components/labs/biomarker-aliases.tsx | 154 ++++++++++ src/components/labs/biomarker-manager.tsx | 39 ++- src/components/labs/biomarker-merge-sheet.tsx | 282 ++++++++++++++++++ src/components/labs/lab-biomarker-detail.tsx | 28 +- src/components/labs/marker-match-field.tsx | 124 ++++++++ src/components/labs/ocr-review-dialog.tsx | 25 +- src/components/labs/ocr-review-types.ts | 43 +++ src/components/labs/ocr-review-validation.ts | 13 +- src/components/labs/ocr-row-editor.tsx | 61 ++-- src/components/labs/types.ts | 23 ++ src/components/labs/use-ocr-extract.ts | 5 + 27 files changed, 1454 insertions(+), 52 deletions(-) create mode 100644 src/components/labs/__tests__/biomarker-aliases.test.tsx create mode 100644 src/components/labs/__tests__/ocr-marker-match.test.tsx create mode 100644 src/components/labs/biomarker-aliases.tsx create mode 100644 src/components/labs/biomarker-merge-sheet.tsx create mode 100644 src/components/labs/marker-match-field.tsx diff --git a/messages/de.json b/messages/de.json index fc3592090..ed7059ac9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9687,6 +9687,54 @@ "fromReport": "Befundbereich {range}", "catalogDiffers": "dein Bereich {range}", "printed": "auf dem Befund in {unit}: {range}" + }, + "match": { + "viaAlias": "über einen Namen, den du früher zugeordnet hast", + "viaCatalog": "als übliche Abkürzung erkannt", + "chooseLabel": "Gehört zu", + "suggestionOption": "{name} ({unit})", + "newMarkerOption": "Ein neuer Biomarker", + "rememberHint": "Der Name wird für den nächsten Befund gemerkt.", + "chooseHint": "Wähle einen Biomarker, wenn du ihn schon erfasst." + }, + "aliases": { + "title": "Auch geschrieben als", + "taken": "{alias} gehört schon zu einem anderen Biomarker.", + "saveError": "Die Namen konnten nicht gespeichert werden. Versuche es erneut.", + "remove": "{alias} entfernen", + "empty": "Ergänze die Namen, die andere Labore für {name} drucken, damit ihre Befunde hier landen.", + "placeholder": "z. B. Alk.P", + "addLabel": "Weiterer Name für diesen Biomarker", + "add": "Hinzufügen", + "alsoWritten": "Auch geschrieben als {aliases}" + }, + "merge": { + "open": "Zusammenführen", + "title": "Biomarker zusammenführen", + "description": "Fasse Biomarker zusammen, die derselbe Test unter anderem Namen sind.", + "pickHint": "Wähle mindestens zwei Biomarker aus.", + "keepLabel": "Behalten", + "keepHint": "Die anderen gehen in diesen auf, und ihre Namen werden seine weiteren Namen.", + "showPreview": "Vorschau zeigen", + "back": "Zurück", + "confirm": "Jetzt zusammenführen", + "previewSummaryOne": "{count} Messung wechselt zu {name} ({unit}).", + "previewSummaryFew": "{count} Messungen wechseln zu {name} ({unit}).", + "previewSummaryOther": "{count} Messungen wechseln zu {name} ({unit}).", + "movesOne": "{count} Messung, schon in derselben Einheit", + "movesFew": "{count} Messungen, schon in derselben Einheit", + "movesOther": "{count} Messungen, schon in derselben Einheit", + "convertedOne": "{count} Messung, umgerechnet von {unit} in {targetUnit}", + "convertedFew": "{count} Messungen, umgerechnet von {unit} in {targetUnit}", + "convertedOther": "{count} Messungen, umgerechnet von {unit} in {targetUnit}", + "refused": "{unit} lässt sich nicht in {targetUnit} umrechnen.", + "aliasesAdded": "Diese Namen führen danach zum behaltenen Biomarker:", + "blocked": "Solange sich eine Einheit nicht umrechnen lässt, wird nichts zusammengeführt. Lass diesen Biomarker weg oder behalte einen anderen.", + "noUndo": "Das Zusammenführen lässt sich nicht rückgängig machen.", + "doneToastOne": "In {name} zusammengeführt, {count} Messung verschoben", + "doneToastFew": "In {name} zusammengeführt, {count} Messungen verschoben", + "doneToastOther": "In {name} zusammengeführt, {count} Messungen verschoben", + "error": "Die Biomarker konnten nicht zusammengeführt werden. Versuche es erneut." } }, "modules": { @@ -11126,7 +11174,8 @@ "extractEmpty": "Aus diesem Dokument konnten keine Werte ausgelesen werden.", "failUnitRequired": "Ein Zahlenwert braucht eine Einheit, bevor er gespeichert werden kann.", "failUnitMismatch": "Die angegebene Einheit passt nicht zur gespeicherten Einheit dieses Markers. Passe sie an und schließe die Prüfung erneut ab.", - "failGeneric": "Dieser Wert konnte nicht gespeichert werden." + "failGeneric": "Dieser Wert konnte nicht gespeichert werden.", + "failUnknownBiomarker": "Den gewählten Biomarker gibt es nicht mehr. Wähle einen anderen." }, "contentIndex": { "indexPrompt": "Dokumentinhalte durchsuchbar machen.", diff --git a/messages/en.json b/messages/en.json index 98a57afc3..317fe14df 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9687,6 +9687,54 @@ "fromReport": "Report range {range}", "catalogDiffers": "your range {range}", "printed": "as printed in {unit}: {range}" + }, + "match": { + "viaAlias": "by a name you chose before", + "viaCatalog": "recognised as a common abbreviation", + "chooseLabel": "Belongs to", + "suggestionOption": "{name} ({unit})", + "newMarkerOption": "A new biomarker", + "rememberHint": "This name will be remembered for the next report.", + "chooseHint": "Pick a biomarker if this is one you already track." + }, + "aliases": { + "title": "Also written as", + "taken": "{alias} already belongs to another biomarker.", + "saveError": "The names could not be saved. Try again.", + "remove": "Remove {alias}", + "empty": "Add the names other labs print for {name}, so their reports land here.", + "placeholder": "e.g. Alk.P", + "addLabel": "Another name for this biomarker", + "add": "Add", + "alsoWritten": "Also written as {aliases}" + }, + "merge": { + "open": "Merge", + "title": "Merge biomarkers", + "description": "Combine markers that are the same test written differently.", + "pickHint": "Select at least two biomarkers.", + "keepLabel": "Keep", + "keepHint": "The others move into this one and their names become its other names.", + "showPreview": "Show preview", + "back": "Back", + "confirm": "Merge now", + "previewSummaryOne": "{count} reading moves to {name} ({unit}).", + "previewSummaryFew": "{count} readings move to {name} ({unit}).", + "previewSummaryOther": "{count} readings move to {name} ({unit}).", + "movesOne": "{count} reading, already in the same unit", + "movesFew": "{count} readings, already in the same unit", + "movesOther": "{count} readings, already in the same unit", + "convertedOne": "{count} reading, converted from {unit} to {targetUnit}", + "convertedFew": "{count} readings, converted from {unit} to {targetUnit}", + "convertedOther": "{count} readings, converted from {unit} to {targetUnit}", + "refused": "{unit} cannot be converted to {targetUnit}.", + "aliasesAdded": "These names will lead to the kept biomarker:", + "blocked": "Nothing can be merged while a unit cannot be converted. Leave that biomarker out or keep another one.", + "noUndo": "A merge cannot be undone.", + "doneToastOne": "Merged into {name}, {count} reading moved", + "doneToastFew": "Merged into {name}, {count} readings moved", + "doneToastOther": "Merged into {name}, {count} readings moved", + "error": "The biomarkers could not be merged. Try again." } }, "modules": { @@ -11126,7 +11174,8 @@ "extractEmpty": "No values could be extracted from this document.", "failUnitRequired": "A numeric value needs a unit before it can be saved.", "failUnitMismatch": "The stated unit does not match the saved unit for this marker. Adjust it, then finish the review again.", - "failGeneric": "This value could not be saved." + "failGeneric": "This value could not be saved.", + "failUnknownBiomarker": "The chosen biomarker no longer exists. Pick another one." }, "contentIndex": { "indexPrompt": "Make document contents searchable.", diff --git a/messages/es.json b/messages/es.json index 4f8622fc0..c14403377 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9687,6 +9687,54 @@ "fromReport": "Rango del informe {range}", "catalogDiffers": "tu rango {range}", "printed": "impreso en {unit}: {range}" + }, + "match": { + "viaAlias": "por un nombre que elegiste antes", + "viaCatalog": "reconocido como una abreviatura habitual", + "chooseLabel": "Pertenece a", + "suggestionOption": "{name} ({unit})", + "newMarkerOption": "Un biomarcador nuevo", + "rememberHint": "Este nombre se recordará para el próximo informe.", + "chooseHint": "Elige un biomarcador si ya lo registras." + }, + "aliases": { + "title": "También escrito como", + "taken": "{alias} ya pertenece a otro biomarcador.", + "saveError": "No se pudieron guardar los nombres. Inténtalo de nuevo.", + "remove": "Quitar {alias}", + "empty": "Añade los nombres que otros laboratorios imprimen para {name}, para que sus informes lleguen aquí.", + "placeholder": "p. ej. FA", + "addLabel": "Otro nombre para este biomarcador", + "add": "Añadir", + "alsoWritten": "También escrito como {aliases}" + }, + "merge": { + "open": "Combinar", + "title": "Combinar biomarcadores", + "description": "Une los biomarcadores que son la misma prueba escrita de otra forma.", + "pickHint": "Selecciona al menos dos biomarcadores.", + "keepLabel": "Conservar", + "keepHint": "Los demás pasan a este y sus nombres se convierten en sus otros nombres.", + "showPreview": "Ver vista previa", + "back": "Atrás", + "confirm": "Combinar ahora", + "previewSummaryOne": "{count} medición pasa a {name} ({unit}).", + "previewSummaryFew": "{count} mediciones pasan a {name} ({unit}).", + "previewSummaryOther": "{count} mediciones pasan a {name} ({unit}).", + "movesOne": "{count} medición, ya en la misma unidad", + "movesFew": "{count} mediciones, ya en la misma unidad", + "movesOther": "{count} mediciones, ya en la misma unidad", + "convertedOne": "{count} medición, convertida de {unit} a {targetUnit}", + "convertedFew": "{count} mediciones, convertidas de {unit} a {targetUnit}", + "convertedOther": "{count} mediciones, convertidas de {unit} a {targetUnit}", + "refused": "{unit} no se puede convertir a {targetUnit}.", + "aliasesAdded": "Estos nombres llevarán al biomarcador conservado:", + "blocked": "No se combina nada mientras una unidad no se pueda convertir. Deja fuera ese biomarcador o conserva otro.", + "noUndo": "Una combinación no se puede deshacer.", + "doneToastOne": "Combinado en {name}, {count} medición movida", + "doneToastFew": "Combinado en {name}, {count} mediciones movidas", + "doneToastOther": "Combinado en {name}, {count} mediciones movidas", + "error": "No se pudieron combinar los biomarcadores. Inténtalo de nuevo." } }, "modules": { @@ -11126,7 +11174,8 @@ "extractEmpty": "No se pudieron extraer valores de este documento.", "failUnitRequired": "Un valor numérico necesita una unidad antes de poder guardarse.", "failUnitMismatch": "La unidad indicada no coincide con la unidad guardada de este marcador. Ajústala y termina la revisión de nuevo.", - "failGeneric": "Este valor no se pudo guardar." + "failGeneric": "Este valor no se pudo guardar.", + "failUnknownBiomarker": "El biomarcador elegido ya no existe. Elige otro." }, "contentIndex": { "indexPrompt": "Haz que el contenido de los documentos sea buscable.", diff --git a/messages/fr.json b/messages/fr.json index 48b16f632..172d45319 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9687,6 +9687,54 @@ "fromReport": "Plage du compte rendu {range}", "catalogDiffers": "votre plage {range}", "printed": "imprimé en {unit} : {range}" + }, + "match": { + "viaAlias": "par un nom que tu as choisi auparavant", + "viaCatalog": "reconnu comme une abréviation courante", + "chooseLabel": "Correspond à", + "suggestionOption": "{name} ({unit})", + "newMarkerOption": "Un nouveau biomarqueur", + "rememberHint": "Ce nom sera retenu pour le prochain compte rendu.", + "chooseHint": "Choisis un biomarqueur si tu le suis déjà." + }, + "aliases": { + "title": "Aussi écrit", + "taken": "{alias} appartient déjà à un autre biomarqueur.", + "saveError": "Les noms n'ont pas pu être enregistrés. Réessaie.", + "remove": "Retirer {alias}", + "empty": "Ajoute les noms que d'autres laboratoires impriment pour {name}, pour que leurs comptes rendus arrivent ici.", + "placeholder": "p. ex. PAL", + "addLabel": "Autre nom pour ce biomarqueur", + "add": "Ajouter", + "alsoWritten": "Aussi écrit {aliases}" + }, + "merge": { + "open": "Fusionner", + "title": "Fusionner des biomarqueurs", + "description": "Réunis les biomarqueurs qui sont le même examen écrit autrement.", + "pickHint": "Sélectionne au moins deux biomarqueurs.", + "keepLabel": "Conserver", + "keepHint": "Les autres sont fondus dans celui-ci et leurs noms deviennent ses autres noms.", + "showPreview": "Voir l'aperçu", + "back": "Retour", + "confirm": "Fusionner maintenant", + "previewSummaryOne": "{count} mesure passe à {name} ({unit}).", + "previewSummaryFew": "{count} mesures passent à {name} ({unit}).", + "previewSummaryOther": "{count} mesures passent à {name} ({unit}).", + "movesOne": "{count} mesure, déjà dans la même unité", + "movesFew": "{count} mesures, déjà dans la même unité", + "movesOther": "{count} mesures, déjà dans la même unité", + "convertedOne": "{count} mesure, convertie de {unit} en {targetUnit}", + "convertedFew": "{count} mesures, converties de {unit} en {targetUnit}", + "convertedOther": "{count} mesures, converties de {unit} en {targetUnit}", + "refused": "{unit} ne peut pas être converti en {targetUnit}.", + "aliasesAdded": "Ces noms mèneront au biomarqueur conservé :", + "blocked": "Rien n'est fusionné tant qu'une unité ne peut pas être convertie. Laisse ce biomarqueur de côté ou conserve-en un autre.", + "noUndo": "Une fusion ne peut pas être annulée.", + "doneToastOne": "Fusionné dans {name}, {count} mesure déplacée", + "doneToastFew": "Fusionné dans {name}, {count} mesures déplacées", + "doneToastOther": "Fusionné dans {name}, {count} mesures déplacées", + "error": "Les biomarqueurs n'ont pas pu être fusionnés. Réessaie." } }, "modules": { @@ -11126,7 +11174,8 @@ "extractEmpty": "Aucune valeur n’a pu être extraite de ce document.", "failUnitRequired": "Une valeur numérique a besoin d’une unité avant de pouvoir être enregistrée.", "failUnitMismatch": "L’unité indiquée ne correspond pas à l’unité enregistrée pour ce marqueur. Ajustez-la, puis terminez à nouveau la vérification.", - "failGeneric": "Cette valeur n’a pas pu être enregistrée." + "failGeneric": "Cette valeur n’a pas pu être enregistrée.", + "failUnknownBiomarker": "Le biomarqueur choisi n'existe plus. Choisis-en un autre." }, "contentIndex": { "indexPrompt": "Rendre le contenu des documents consultable.", diff --git a/messages/it.json b/messages/it.json index a716cd7d7..725cabf55 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9687,6 +9687,54 @@ "fromReport": "Intervallo del referto {range}", "catalogDiffers": "il tuo intervallo {range}", "printed": "stampato in {unit}: {range}" + }, + "match": { + "viaAlias": "tramite un nome che hai scelto in precedenza", + "viaCatalog": "riconosciuto come abbreviazione comune", + "chooseLabel": "Appartiene a", + "suggestionOption": "{name} ({unit})", + "newMarkerOption": "Un nuovo biomarcatore", + "rememberHint": "Questo nome verrà ricordato per il prossimo referto.", + "chooseHint": "Scegli un biomarcatore se lo registri già." + }, + "aliases": { + "title": "Scritto anche come", + "taken": "{alias} appartiene già a un altro biomarcatore.", + "saveError": "Non è stato possibile salvare i nomi. Riprova.", + "remove": "Rimuovi {alias}", + "empty": "Aggiungi i nomi che altri laboratori stampano per {name}, così i loro referti arrivano qui.", + "placeholder": "es. ALP", + "addLabel": "Altro nome per questo biomarcatore", + "add": "Aggiungi", + "alsoWritten": "Scritto anche come {aliases}" + }, + "merge": { + "open": "Unisci", + "title": "Unisci biomarcatori", + "description": "Riunisci i biomarcatori che sono lo stesso esame scritto in modo diverso.", + "pickHint": "Seleziona almeno due biomarcatori.", + "keepLabel": "Mantieni", + "keepHint": "Gli altri confluiscono in questo e i loro nomi diventano i suoi altri nomi.", + "showPreview": "Mostra anteprima", + "back": "Indietro", + "confirm": "Unisci ora", + "previewSummaryOne": "{count} misurazione passa a {name} ({unit}).", + "previewSummaryFew": "{count} misurazioni passano a {name} ({unit}).", + "previewSummaryOther": "{count} misurazioni passano a {name} ({unit}).", + "movesOne": "{count} misurazione, già nella stessa unità", + "movesFew": "{count} misurazioni, già nella stessa unità", + "movesOther": "{count} misurazioni, già nella stessa unità", + "convertedOne": "{count} misurazione, convertita da {unit} a {targetUnit}", + "convertedFew": "{count} misurazioni, convertite da {unit} a {targetUnit}", + "convertedOther": "{count} misurazioni, convertite da {unit} a {targetUnit}", + "refused": "{unit} non può essere convertito in {targetUnit}.", + "aliasesAdded": "Questi nomi porteranno al biomarcatore mantenuto:", + "blocked": "Non si unisce nulla finché un'unità non può essere convertita. Escludi quel biomarcatore o mantienine un altro.", + "noUndo": "Un'unione non può essere annullata.", + "doneToastOne": "Unito in {name}, {count} misurazione spostata", + "doneToastFew": "Unito in {name}, {count} misurazioni spostate", + "doneToastOther": "Unito in {name}, {count} misurazioni spostate", + "error": "Non è stato possibile unire i biomarcatori. Riprova." } }, "modules": { @@ -11126,7 +11174,8 @@ "extractEmpty": "Non è stato possibile estrarre valori da questo documento.", "failUnitRequired": "Un valore numerico ha bisogno di un’unità prima di poter essere salvato.", "failUnitMismatch": "L’unità indicata non corrisponde all’unità salvata per questo marcatore. Correggila e concludi di nuovo la revisione.", - "failGeneric": "Questo valore non è stato salvato." + "failGeneric": "Questo valore non è stato salvato.", + "failUnknownBiomarker": "Il biomarcatore scelto non esiste più. Scegline un altro." }, "contentIndex": { "indexPrompt": "Rendi ricercabile il contenuto dei documenti.", diff --git a/messages/ko.json b/messages/ko.json index ebf33b659..93077aa28 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9687,6 +9687,54 @@ "fromReport": "결과지 범위 {range}", "catalogDiffers": "내 범위 {range}", "printed": "결과지 원문({unit}): {range}" + }, + "match": { + "viaAlias": "전에 고른 이름으로 연결됨", + "viaCatalog": "흔히 쓰는 약어로 인식됨", + "chooseLabel": "연결할 항목", + "suggestionOption": "{name} ({unit})", + "newMarkerOption": "새 바이오마커", + "rememberHint": "다음 결과지를 위해 이 이름을 기억해요.", + "chooseHint": "이미 기록 중인 바이오마커라면 골라 주세요." + }, + "aliases": { + "title": "다른 표기", + "taken": "{alias}은(는) 이미 다른 바이오마커에 연결돼 있어요.", + "saveError": "이름을 저장하지 못했어요. 다시 시도해 주세요.", + "remove": "{alias} 삭제", + "empty": "다른 검사기관이 {name}에 쓰는 이름을 추가하면 그 결과지도 여기로 모여요.", + "placeholder": "예: ALP", + "addLabel": "이 바이오마커의 다른 이름", + "add": "추가", + "alsoWritten": "다른 표기: {aliases}" + }, + "merge": { + "open": "합치기", + "title": "바이오마커 합치기", + "description": "이름만 다르게 적힌 같은 검사 항목을 하나로 모아요.", + "pickHint": "바이오마커를 두 개 이상 골라 주세요.", + "keepLabel": "남길 항목", + "keepHint": "나머지는 이 항목으로 옮겨지고, 그 이름은 이 항목의 다른 표기가 돼요.", + "showPreview": "미리 보기", + "back": "뒤로", + "confirm": "지금 합치기", + "previewSummaryOne": "측정값 {count}개가 {name} ({unit})(으)로 옮겨져요.", + "previewSummaryFew": "측정값 {count}개가 {name} ({unit})(으)로 옮겨져요.", + "previewSummaryOther": "측정값 {count}개가 {name} ({unit})(으)로 옮겨져요.", + "movesOne": "측정값 {count}개, 이미 같은 단위", + "movesFew": "측정값 {count}개, 이미 같은 단위", + "movesOther": "측정값 {count}개, 이미 같은 단위", + "convertedOne": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산", + "convertedFew": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산", + "convertedOther": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산", + "refused": "{unit}은(는) {targetUnit}(으)로 환산할 수 없어요.", + "aliasesAdded": "이 이름들은 남긴 바이오마커로 연결돼요:", + "blocked": "환산할 수 없는 단위가 있으면 아무것도 합치지 않아요. 그 바이오마커를 빼거나 다른 항목을 남겨 주세요.", + "noUndo": "합친 뒤에는 되돌릴 수 없어요.", + "doneToastOne": "{name}(으)로 합쳤어요, 측정값 {count}개 이동", + "doneToastFew": "{name}(으)로 합쳤어요, 측정값 {count}개 이동", + "doneToastOther": "{name}(으)로 합쳤어요, 측정값 {count}개 이동", + "error": "바이오마커를 합치지 못했어요. 다시 시도해 주세요." } }, "modules": { @@ -11126,7 +11174,8 @@ "extractEmpty": "이 문서에서 추출할 수 있는 값이 없었어요.", "failUnitRequired": "숫자 값을 저장하려면 단위가 필요해요.", "failUnitMismatch": "기재된 단위가 이 지표에 저장된 단위와 달라요. 수정한 뒤 검토를 다시 완료해 주세요.", - "failGeneric": "이 값을 저장하지 못했어요." + "failGeneric": "이 값을 저장하지 못했어요.", + "failUnknownBiomarker": "고른 바이오마커가 더 이상 없어요. 다른 항목을 골라 주세요." }, "contentIndex": { "indexPrompt": "문서 내용을 검색할 수 있게 만들어요.", diff --git a/messages/pl.json b/messages/pl.json index 7a2f70e17..3b7ea5962 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9687,6 +9687,54 @@ "fromReport": "Zakres z wyniku {range}", "catalogDiffers": "twój zakres {range}", "printed": "wydrukowano w {unit}: {range}" + }, + "match": { + "viaAlias": "według nazwy, którą wybrałeś wcześniej", + "viaCatalog": "rozpoznano jako popularny skrót", + "chooseLabel": "Należy do", + "suggestionOption": "{name} ({unit})", + "newMarkerOption": "Nowy biomarker", + "rememberHint": "Ta nazwa zostanie zapamiętana na następny wynik.", + "chooseHint": "Wybierz biomarker, jeśli już go śledzisz." + }, + "aliases": { + "title": "Zapisywany też jako", + "taken": "{alias} należy już do innego biomarkera.", + "saveError": "Nie udało się zapisać nazw. Spróbuj ponownie.", + "remove": "Usuń {alias}", + "empty": "Dodaj nazwy, które inne laboratoria drukują dla {name}, aby ich wyniki trafiały tutaj.", + "placeholder": "np. ALP", + "addLabel": "Inna nazwa tego biomarkera", + "add": "Dodaj", + "alsoWritten": "Zapisywany też jako {aliases}" + }, + "merge": { + "open": "Scal", + "title": "Scal biomarkery", + "description": "Połącz biomarkery, które są tym samym badaniem zapisanym inaczej.", + "pickHint": "Zaznacz co najmniej dwa biomarkery.", + "keepLabel": "Zachowaj", + "keepHint": "Pozostałe zostaną włączone do tego, a ich nazwy staną się jego innymi nazwami.", + "showPreview": "Pokaż podgląd", + "back": "Wstecz", + "confirm": "Scal teraz", + "previewSummaryOne": "{count} pomiar przechodzi do {name} ({unit}).", + "previewSummaryFew": "{count} pomiary przechodzą do {name} ({unit}).", + "previewSummaryOther": "{count} pomiarów przechodzi do {name} ({unit}).", + "movesOne": "{count} pomiar, już w tej samej jednostce", + "movesFew": "{count} pomiary, już w tej samej jednostce", + "movesOther": "{count} pomiarów, już w tej samej jednostce", + "convertedOne": "{count} pomiar, przeliczony z {unit} na {targetUnit}", + "convertedFew": "{count} pomiary, przeliczone z {unit} na {targetUnit}", + "convertedOther": "{count} pomiarów, przeliczonych z {unit} na {targetUnit}", + "refused": "Nie można przeliczyć {unit} na {targetUnit}.", + "aliasesAdded": "Te nazwy będą prowadzić do zachowanego biomarkera:", + "blocked": "Nic nie zostanie scalone, dopóki jednej jednostki nie da się przeliczyć. Pomiń ten biomarker albo zachowaj inny.", + "noUndo": "Scalenia nie można cofnąć.", + "doneToastOne": "Scalono w {name}, przeniesiono {count} pomiar", + "doneToastFew": "Scalono w {name}, przeniesiono {count} pomiary", + "doneToastOther": "Scalono w {name}, przeniesiono {count} pomiarów", + "error": "Nie udało się scalić biomarkerów. Spróbuj ponownie." } }, "modules": { @@ -11126,7 +11174,8 @@ "extractEmpty": "Z tego dokumentu nie udało się odczytać żadnych wartości.", "failUnitRequired": "Wartość liczbowa potrzebuje jednostki, zanim będzie można ją zapisać.", "failUnitMismatch": "Podana jednostka nie zgadza się z zapisaną jednostką tego wskaźnika. Popraw ją i zakończ przegląd ponownie.", - "failGeneric": "Nie udało się zapisać tej wartości." + "failGeneric": "Nie udało się zapisać tej wartości.", + "failUnknownBiomarker": "Wybrany biomarker już nie istnieje. Wybierz inny." }, "contentIndex": { "indexPrompt": "Udostępnij treść dokumentów do wyszukiwania.", diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 601873978..590730f6c 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -89,7 +89,7 @@ const SERVER_FEATURES = { // form work; until then a client must not send it (contract §0.6). cumulativeHourBuckets: false, syncTriggerBackfill: false, - biomarkerMerge: false, + biomarkerMerge: true, lifeCircumstances: false, } as const; diff --git a/src/components/documents/document-facts-review.tsx b/src/components/documents/document-facts-review.tsx index c17019dad..3904846cc 100644 --- a/src/components/documents/document-facts-review.tsx +++ b/src/components/documents/document-facts-review.tsx @@ -26,6 +26,7 @@ */ import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { FlaskConical, Loader2, ScanSearch } from "lucide-react"; import { toast } from "sonner"; @@ -35,7 +36,11 @@ import { Checkbox } from "@/components/ui/checkbox"; import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { MarkerMatchField } from "@/components/labs/marker-match-field"; +import type { BiomarkerListResponse } from "@/components/labs/types"; +import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import type { ConditionFactData, @@ -61,6 +66,8 @@ function commitFailureKey(reason: string): string { return "documents.review.failUnitRequired"; case "observation.unitMismatch": return "documents.review.failUnitMismatch"; + case "observation.unknownBiomarker": + return "documents.review.failUnknownBiomarker"; default: return "documents.review.failGeneric"; } @@ -202,6 +209,9 @@ function FactRow({ checked, onCheckedChange, failure, + chosenBiomarkerId, + onChooseBiomarker, + markerName, }: { documentId: string; fact: ExtractedFactDto; @@ -209,6 +219,11 @@ function FactRow({ onCheckedChange: (checked: boolean) => void; /** A per-fact commit refusal from the last confirm attempt, or null. */ failure: string | null; + /** v1.43 — the suggestion the person picked for a lab value, or null. */ + chosenBiomarkerId: string | null; + onChooseBiomarker: (biomarkerId: string | null) => void; + /** The account's marker name by id, for the matched marker. */ + markerName: (biomarkerId: string) => string | null; }) { const { t } = useTranslations(); const calendarDate = useCalendarDate(); @@ -264,6 +279,25 @@ function FactRow({ {typeLabel} {date ? `, ${calendarDate(date)}` : ""}

+ {isObservation && + (fact.biomarkerId !== undefined || + (fact.suggestions?.length ?? 0) > 0) ? ( + + onChooseBiomarker(suggestion?.biomarkerId ?? null) + } + slot="document-fact-marker" + /> + ) : null} {isObservation && (fact.data as ObservationFactData).referenceText ? (

{t("documents.review.statedRange", { @@ -460,6 +494,18 @@ export function DocumentFactsSection({ // until it is verified (which checks it). const [included, setIncluded] = useState>({}); const [failures, setFailures] = useState>({}); + // v1.43 — the marker the person picked per lab value, keyed by fact. + const [chosen, setChosen] = useState>({}); + const hasObservations = doc.facts.some( + (fact) => fact.factType === "OBSERVATION" && fact.status === "PENDING", + ); + const markers = useQuery({ + queryKey: queryKeys.biomarkers(), + queryFn: () => apiGet("/api/biomarkers"), + enabled: canManage && hasObservations && open, + }); + const markerName = (id: string) => + markers.data?.biomarkers.find((m) => m.id === id)?.name ?? null; const pending = doc.facts.filter((fact) => fact.status === "PENDING"); const approvedCount = doc.facts.filter( @@ -482,6 +528,7 @@ export function DocumentFactsSection({ const decisions = decidable.map((fact) => ({ factId: fact.id, action: isChecked(fact) ? ("approve" as const) : ("reject" as const), + ...(chosen[fact.id] ? { biomarkerId: chosen[fact.id] as string } : {}), })); confirmFacts.mutate( { documentId: doc.id, decisions }, @@ -566,6 +613,11 @@ export function DocumentFactsSection({ checked={isChecked(fact)} onCheckedChange={(checked) => setChecked(fact.id, checked)} failure={failures[fact.id] ?? null} + chosenBiomarkerId={chosen[fact.id] ?? null} + onChooseBiomarker={(id) => + setChosen((prev) => ({ ...prev, [fact.id]: id })) + } + markerName={markerName} /> ))} diff --git a/src/components/documents/use-document-facts.ts b/src/components/documents/use-document-facts.ts index 469155b9b..2ab18b787 100644 --- a/src/components/documents/use-document-facts.ts +++ b/src/components/documents/use-document-facts.ts @@ -114,7 +114,12 @@ export function useConfirmFacts() { Error, { documentId: string; - decisions: { factId: string; action: "approve" | "reject" }[]; + decisions: { + factId: string; + action: "approve" | "reject"; + /** v1.43 — the marker the person chose for a lab value. */ + biomarkerId?: string; + }[]; } >({ mutationFn: ({ documentId, decisions }) => diff --git a/src/components/labs/__tests__/biomarker-aliases.test.tsx b/src/components/labs/__tests__/biomarker-aliases.test.tsx new file mode 100644 index 000000000..faf6b5608 --- /dev/null +++ b/src/components/labs/__tests__/biomarker-aliases.test.tsx @@ -0,0 +1,116 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canManageDomain: () => true }), +})); + +import { BiomarkerAliases } from "../biomarker-aliases"; +import { BiomarkerMergeSheet } from "../biomarker-merge-sheet"; +import type { BiomarkerDto } from "../types"; + +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: ({ + children, + title, + footer, + }: { + children: React.ReactNode; + title: string; + footer?: React.ReactNode; + }) => ( +

+

{title}

+ {children} + {footer} +
+ ), +})); + +function wrap(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + {node} + , + ); +} + +const marker = (overrides: Partial): BiomarkerDto => ({ + id: "bm-1", + name: "Alkaline Phosphatase", + unit: "U/L", + lowerBound: null, + upperBound: null, + panel: null, + analyteKey: null, + hasContext: false, + context: null, + hidden: false, + aliases: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, +}); + +describe("", () => { + it("shows the other spellings as chips, removable for a manager", () => { + const html = wrap( + , + ); + expect(html).toContain("Also written as"); + expect(html).toContain("Alk.P"); + expect(html).toContain('aria-label="Remove AP"'); + expect(html).toContain("Another name for this biomarker"); + }); + + it("is read-only without the manage right, and absent when there is nothing to show", () => { + const readOnly = wrap( + , + ); + expect(readOnly).toContain("Alk.P"); + expect(readOnly).not.toContain("Remove Alk.P"); + expect(readOnly).not.toContain(")).toBe( + "", + ); + }); + + it("speaks German too", () => { + expect( + wrap(, "de"), + ).toContain("Auch geschrieben als"); + }); +}); + +describe("", () => { + it("opens on the pick step with every marker and no preview yet", () => { + const html = wrap( + {}} + markers={[ + marker({ id: "a", name: "Alkaline Phosphatase" }), + marker({ id: "b", name: "Alk.P" }), + ]} + />, + ); + expect(html).toContain("Merge biomarkers"); + expect(html).toContain('data-slot="biomarker-merge-select"'); + expect(html).toContain("Alk.P"); + expect(html).toContain("Select at least two biomarkers."); + // The preview button waits for a selection. + expect(html).toMatch( + /]*data-slot="biomarker-merge-preview"[^>]*disabled=""/, + ); + expect(html).not.toContain('data-slot="biomarker-merge-confirm"'); + }); +}); diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx index f2f966b9b..86092e3cb 100644 --- a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -26,7 +26,9 @@ vi.mock("@/hooks/use-ai-capability", () => ({ onDeviceAllowed: true, }), })); -vi.mock("@/lib/api/api-fetch", () => ({ +vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({ + ApiError: (await importOriginal()) + .ApiError, apiGet: () => new Promise(() => {}), apiDelete: vi.fn(), apiPut: vi.fn(), diff --git a/src/components/labs/__tests__/ocr-marker-match.test.tsx b/src/components/labs/__tests__/ocr-marker-match.test.tsx new file mode 100644 index 000000000..6e89bba6b --- /dev/null +++ b/src/components/labs/__tests__/ocr-marker-match.test.tsx @@ -0,0 +1,139 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { OcrExtractedRowDto } from "@/lib/validations/labs-ocr"; + +import { + chooseMarker, + seedReviewRows, + type OcrReviewRow, +} from "../ocr-review-types"; +import { toCommitRow } from "../ocr-review-validation"; +import { OcrRowEditor } from "../ocr-row-editor"; + +const dto: OcrExtractedRowDto = { + analyte: "Bilirubin ges.", + value: 0.8, + valueText: null, + unit: "mg/dL", + referenceLow: null, + referenceHigh: null, + referenceText: null, + takenAt: "2026-06-01", + confidence: { analyte: 1, value: 1, unit: 1, range: 1 }, + biomarkerMatch: "new", + markerUnit: null, + markerAnalyteKey: null, + duplicateOf: null, + biomarkerId: null, + matchKind: null, + suggestions: [ + { + biomarkerId: "bm-bili", + name: "Bilirubin (Total)", + unit: "µmol/L", + matchKind: "similar", + }, + ], +}; + +const lookup = (id: string) => + id === "bm-bili" + ? { name: "Bilirubin (Total)", analyteKey: "bilirubin-total" } + : id === "bm-alp" + ? { name: "Alkaline Phosphatase", analyteKey: null } + : null; + +function render(row: OcrReviewRow, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + {}} markerLookup={lookup} /> + , + ); +} + +describe("the scan review's marker choice (D1223)", () => { + it("seeds the match and the suggestions, with nothing chosen", () => { + const [row] = seedReviewRows([dto], null); + expect(row).toMatchObject({ + biomarkerId: null, + matchKind: null, + chosenBiomarkerId: null, + suggestions: dto.suggestions, + }); + }); + + it("reads a scan from before the fields existed as no match", () => { + const legacy = { ...dto } as Partial; + delete legacy.biomarkerId; + delete legacy.matchKind; + delete legacy.suggestions; + const [row] = seedReviewRows([legacy as OcrExtractedRowDto], null); + expect(row.suggestions).toEqual([]); + expect(row.biomarkerId).toBeNull(); + }); + + it("offers the suggestions and a new marker, new by default", () => { + const [row] = seedReviewRows([dto], null); + const html = render(row); + expect(html).toContain('data-slot="ocr-row-marker-choice"'); + expect(html).toContain("Bilirubin (Total) (µmol/L)"); + expect(html).toContain("A new biomarker"); + expect(html).toMatch(/