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)}` : ""}
+ )}
+
+ );
+}
diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx
index 9518eb0b8..d0affd93b 100644
--- a/src/components/labs/lab-biomarker-detail.tsx
+++ b/src/components/labs/lab-biomarker-detail.tsx
@@ -33,7 +33,7 @@ import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { useInsightBiomarkerAssessment } from "@/hooks/use-insight-status";
import { useMounted } from "@/hooks/use-mounted";
-import { apiDelete, apiGet } from "@/lib/api/api-fetch";
+import { ApiError, apiDelete, apiGet } from "@/lib/api/api-fetch";
import { summarize, type DataSummary } from "@/lib/analytics/trends";
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
import { useTranslations } from "@/lib/i18n/context";
@@ -51,6 +51,7 @@ import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { importWithRetry } from "@/lib/retry-import";
+import { BiomarkerAliases } from "./biomarker-aliases";
import { BiomarkerForm } from "./biomarker-form";
import { LabForm } from "./lab-form";
import type {
@@ -157,12 +158,29 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const {
data: marker,
isError: markerError,
+ error: markerFailure,
refetch: refetchMarker,
} = useQuery({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
queryFn: () => apiGet(`/api/biomarkers/${biomarkerId}`),
});
+ // v1.43 (D1223) — a marker merged into another answers 404 with the
+ // marker it went into; a bookmark or a stale screen follows it there.
+ const mergedInto =
+ markerFailure instanceof ApiError &&
+ markerFailure.status === 404 &&
+ typeof markerFailure.meta?.mergedInto === "string"
+ ? markerFailure.meta.mergedInto
+ : null;
+ useEffect(() => {
+ if (!mergedInto) return;
+ queryClient.removeQueries({
+ queryKey: queryKeys.biomarkerDetail(biomarkerId),
+ });
+ router.replace(`/labs/${mergedInto}`);
+ }, [mergedInto, biomarkerId, queryClient, router]);
+
// v1.25 — offset-paginated reading feed. The page no longer fetches a single
// `limit=500` window (which silently truncated a marker once it crossed 500
// readings); it loads a page at a time and a "Load more" control reveals the
@@ -303,7 +321,8 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
// v1.25.1 (H2-M2) — the error state keeps the heading + description and adds a
// retry, mirroring the insights `SubPageShell` recovery affordance rather than
// collapsing to a bare line with no context and no way back.
- if (markerError || listError) {
+ // A merged marker is on its way to its target (see above), not failed.
+ if ((markerError && !mergedInto) || listError) {
return (
) : null}
+ {/* v1.43 (D1223) — the other spellings this marker answers to. */}
+ {marker ? (
+
+ ) : null}
+
{/* Bridge to the source report PDFs. There is no per-value document
relation in the data model yet, so this links to the vault filtered
to lab-result documents rather than the exact scan behind one value.
diff --git a/src/components/labs/marker-match-field.tsx b/src/components/labs/marker-match-field.tsx
new file mode 100644
index 000000000..e81a3f3c6
--- /dev/null
+++ b/src/components/labs/marker-match-field.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+/**
+ * v1.43 (D1223) — which marker a reviewed lab value goes to.
+ *
+ * Shared by the scan review and the document review. The server resolves a
+ * printed name to one of the account's markers (its name, an alias, or a
+ * catalog abbreviation such as "T.Bili") and, when nothing resolves, offers up
+ * to three markers to choose from. This renders that resolution: the marker a
+ * value was matched to, or a choice between the suggestions and a new marker.
+ * A choice is remembered by the server as another spelling of the marker, so
+ * the next report from the same lab matches on its own.
+ *
+ * Nothing is computed here: the match, its kind and the suggestions all come
+ * from the review DTO.
+ */
+import { useId } from "react";
+import { FilePlus2, Link2 } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import { useTranslations } from "@/lib/i18n/context";
+
+export interface MarkerSuggestionDto {
+ biomarkerId: string;
+ name: string;
+ unit: string;
+ matchKind: "catalog" | "similar";
+}
+
+/** The value for "a new marker" in the choice. */
+const NEW_MARKER = "";
+
+export function MarkerMatchField({
+ matchedName,
+ matchKind,
+ suggestions,
+ chosenId,
+ onChoose,
+ slot,
+}: {
+ /** The marker the server matched, by name; null when nothing matched. */
+ matchedName: string | null;
+ matchKind: "name" | "alias" | "catalog" | null;
+ suggestions: readonly MarkerSuggestionDto[];
+ /** The suggestion the person picked, or null for a new marker. */
+ chosenId: string | null;
+ onChoose: (suggestion: MarkerSuggestionDto | null) => void;
+ /** `data-slot` prefix for tests. */
+ slot: string;
+}) {
+ const { t } = useTranslations();
+ const selectId = useId();
+
+ if (matchedName !== null) {
+ return (
+