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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 22:55:08 +02:00
1 parent e5544913f6
commit e65ada2961
27 files changed
+1454 -52

No files matched your search

+50 -1
View File
@@ -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.",
+50 -1
View File
@@ -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.",
+50 -1
View File
@@ -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.",
+50 -1
View File
@@ -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.",
+50 -1
View File
@@ -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.",
+50 -1
View File
@@ -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": "문서 내용을 검색할 수 있게 만들어요.",
+50 -1
View File
@@ -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.",
+1 -1
View File
@@ -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;
@@ -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)}` : ""}
</p>
{isObservation &&
(fact.biomarkerId !== undefined ||
(fact.suggestions?.length ?? 0) > 0) ? (
<MarkerMatchField
matchedName={
fact.biomarkerId
? (markerName(fact.biomarkerId) ??
(fact.data as ObservationFactData).label)
: null
}
matchKind={fact.matchKind ?? null}
suggestions={fact.suggestions ?? []}
chosenId={chosenBiomarkerId}
onChoose={(suggestion) =>
onChooseBiomarker(suggestion?.biomarkerId ?? null)
}
slot="document-fact-marker"
/>
) : null}
{isObservation && (fact.data as ObservationFactData).referenceText ? (
<p className="text-muted-foreground text-xs break-words">
{t("documents.review.statedRange", {
@@ -460,6 +494,18 @@ export function DocumentFactsSection({
// until it is verified (which checks it).
const [included, setIncluded] = useState<Record<string, boolean>>({});
const [failures, setFailures] = useState<Record<string, string>>({});
// v1.43 — the marker the person picked per lab value, keyed by fact.
const [chosen, setChosen] = useState<Record<string, string | null>>({});
const hasObservations = doc.facts.some(
(fact) => fact.factType === "OBSERVATION" && fact.status === "PENDING",
);
const markers = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/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}
/>
))}
</ul>
@@ -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 }) =>
@@ -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;
}) => (
<div>
<h2>{title}</h2>
{children}
{footer}
</div>
),
}));
function wrap(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
const marker = (overrides: Partial<BiomarkerDto>): 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("<BiomarkerAliases>", () => {
it("shows the other spellings as chips, removable for a manager", () => {
const html = wrap(
<BiomarkerAliases
marker={marker({ aliases: ["Alk.P", "AP"] })}
canEdit
/>,
);
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(
<BiomarkerAliases
marker={marker({ aliases: ["Alk.P"] })}
canEdit={false}
/>,
);
expect(readOnly).toContain("Alk.P");
expect(readOnly).not.toContain("Remove Alk.P");
expect(readOnly).not.toContain("<form");
expect(wrap(<BiomarkerAliases marker={marker({})} canEdit={false} />)).toBe(
"",
);
});
it("speaks German too", () => {
expect(
wrap(<BiomarkerAliases marker={marker({})} canEdit />, "de"),
).toContain("Auch geschrieben als");
});
});
describe("<BiomarkerMergeSheet>", () => {
it("opens on the pick step with every marker and no preview yet", () => {
const html = wrap(
<BiomarkerMergeSheet
open
onOpenChange={() => {}}
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(
/<button[^>]*data-slot="biomarker-merge-preview"[^>]*disabled=""/,
);
expect(html).not.toContain('data-slot="biomarker-merge-confirm"');
});
});
@@ -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<typeof import("@/lib/api/api-fetch")>())
.ApiError,
apiGet: () => new Promise(() => {}),
apiDelete: vi.fn(),
apiPut: vi.fn(),
@@ -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(
<I18nProvider initialLocale={locale}>
<OcrRowEditor row={row} onChange={() => {}} markerLookup={lookup} />
</I18nProvider>,
);
}
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<OcrExtractedRowDto>;
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(/<option value="" selected="">/);
});
it("names the matched marker and how it was found", () => {
const [row] = seedReviewRows(
[
{
...dto,
analyte: "Alk.P",
biomarkerId: "bm-alp",
matchKind: "catalog",
suggestions: [],
biomarkerMatch: "existing",
markerUnit: "U/L",
},
],
null,
);
const html = render(row);
expect(html).toContain("Links to existing Alkaline Phosphatase");
expect(html).toContain("recognised as a common abbreviation");
expect(html).not.toContain('data-slot="ocr-row-marker-choice"');
expect(render(row, "de")).toContain("als übliche Abkürzung erkannt");
});
it("files a chosen row under the marker, judged in its unit", () => {
const [row] = seedReviewRows([dto], null);
const chosen = chooseMarker(
row,
dto.suggestions[0],
(id) => lookup(id)?.analyteKey ?? null,
);
expect(chosen).toMatchObject({
chosenBiomarkerId: "bm-bili",
biomarkerMatch: "existing",
markerUnit: "µmol/L",
markerAnalyteKey: "bilirubin-total",
});
expect(toCommitRow(chosen)).toMatchObject({ biomarkerId: "bm-bili" });
const back = chooseMarker(chosen, null, () => null);
expect(back).toMatchObject({
chosenBiomarkerId: null,
biomarkerMatch: "new",
markerUnit: null,
});
expect(toCommitRow(back)).not.toHaveProperty("biomarkerId");
});
it("sends no marker for an automatic match", () => {
const [row] = seedReviewRows(
[{ ...dto, biomarkerId: "bm-alp", matchKind: "alias", suggestions: [] }],
null,
);
expect(toCommitRow(row)).not.toHaveProperty("biomarkerId");
});
});
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -76,17 +77,20 @@ function render(
consentRequired?: boolean;
} = {},
): string {
// The dialog reads the account's markers once rows are on screen (v1.43).
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<OcrReviewDialog
open
onOpenChange={() => {}}
mode={options.mode ?? "vision"}
pdfSupported={options.pdfSupported ?? true}
consentRequired={options.consentRequired}
onCommitted={() => {}}
/>
</I18nProvider>,
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale={locale}>
<OcrReviewDialog
open
onOpenChange={() => {}}
mode={options.mode ?? "vision"}
pdfSupported={options.pdfSupported ?? true}
consentRequired={options.consentRequired}
onCommitted={() => {}}
/>
</I18nProvider>
</QueryClientProvider>,
);
}
@@ -28,6 +28,10 @@ function row(overrides: Partial<OcrReviewRow> = {}): OcrReviewRow {
markerUnit: "mmol/L",
markerAnalyteKey: null,
duplicateOf: null,
biomarkerId: "bm-ldl",
matchKind: "name",
suggestions: [],
chosenBiomarkerId: null,
confirmed: true,
...overrides,
};
@@ -21,6 +21,10 @@ const baseRow: OcrReviewRow = {
markerUnit: "mmol/L",
markerAnalyteKey: null,
duplicateOf: null,
biomarkerId: "bm-ldl",
matchKind: "name",
suggestions: [],
chosenBiomarkerId: null,
confirmed: true,
};
+154
View File
@@ -0,0 +1,154 @@
"use client";
/**
* v1.43 (D1223) — the other spellings a marker answers to.
*
* Shown as chips on the marker; with the labs manage right they can be added
* and removed. Every change sends the whole set (`PUT /api/biomarkers/{id}`
* with `aliases`), which is how the server reads it. A spelling another marker
* already answers to is refused with `labs.biomarker.alias.taken`; the
* sentence names the spelling so the person knows which one to drop.
*/
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { TagChip } from "@/components/ui/tag-chip";
import { ApiError, apiPut } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BiomarkerDto } from "./types";
/** Longest alias the server stores. */
const ALIAS_MAX = 120;
export function BiomarkerAliases({
marker,
canEdit,
}: {
marker: Pick<BiomarkerDto, "id" | "name" | "aliases">;
canEdit: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const aliases = marker.aliases ?? [];
const save = useMutation({
mutationFn: (next: string[]) =>
apiPut<BiomarkerDto>(`/api/biomarkers/${marker.id}`, { aliases: next }),
onSuccess: () => {
setError(null);
setDraft("");
queryClient.invalidateQueries({
queryKey: queryKeys.biomarkerDetail(marker.id),
});
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
},
onError: (err) => {
const alias =
err instanceof ApiError &&
err.meta?.errorCode === "labs.biomarker.alias.taken" &&
typeof err.meta.alias === "string"
? err.meta.alias
: null;
setError(
alias
? t("labs.aliases.taken", { alias })
: t("labs.aliases.saveError"),
);
},
});
if (aliases.length === 0 && !canEdit) return null;
const add = () => {
const name = draft.trim();
if (!name) return;
if (aliases.some((a) => a.toLowerCase() === name.toLowerCase())) {
setDraft("");
return;
}
save.mutate([...aliases, name]);
};
return (
<div className="space-y-2" data-slot="biomarker-aliases">
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("labs.aliases.title")}
</p>
{aliases.length > 0 ? (
<ul className="flex flex-wrap gap-1.5">
{aliases.map((alias) => (
<li key={alias}>
<TagChip className="inline-flex items-center gap-1">
{alias}
{canEdit ? (
<button
type="button"
className="hover:text-foreground text-muted-foreground focus-visible:ring-ring/50 -my-1 -me-1 inline-flex size-6 items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
onClick={() =>
save.mutate(aliases.filter((a) => a !== alias))
}
disabled={save.isPending}
aria-label={t("labs.aliases.remove", { alias })}
>
<X className="size-3" aria-hidden />
</button>
) : null}
</TagChip>
</li>
))}
</ul>
) : (
<p className="text-muted-foreground text-xs">
{t("labs.aliases.empty", { name: marker.name })}
</p>
)}
{canEdit ? (
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
add();
}}
>
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
maxLength={ALIAS_MAX}
placeholder={t("labs.aliases.placeholder")}
aria-label={t("labs.aliases.addLabel")}
className="min-w-0 flex-1"
/>
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
disabled={save.isPending || draft.trim() === ""}
>
{save.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Plus className="size-4" aria-hidden />
)}
{t("labs.aliases.add")}
</Button>
</form>
) : null}
{error ? (
<p role="alert" className="text-destructive text-xs">
{error}
</p>
) : null}
</div>
);
}
+38 -1
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, EyeOff, FlaskConical, Pencil, Plus } from "lucide-react";
import { Eye, EyeOff, FlaskConical, Merge, Pencil, Plus } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
@@ -17,7 +17,10 @@ import { formatReferenceRange } from "@/lib/labs/reference-range";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { BiomarkerForm } from "./biomarker-form";
import { BiomarkerMergeSheet } from "./biomarker-merge-sheet";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
import { useLabNumber } from "./use-lab-format";
@@ -48,6 +51,10 @@ export function BiomarkerManager() {
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<BiomarkerDto | null>(null);
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
// v1.43 (D1223) — merging is a manage-level act, like edit and delete.
const { canManageDomain } = useRecordCapabilities();
const canMerge = canManageDomain("labs");
const [mergeOpen, setMergeOpen] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.biomarkers(),
@@ -122,6 +129,16 @@ export function BiomarkerManager() {
: t("labs.biomarker.noRange")}
{marker.panel ? ` · ${marker.panel}` : ""}
</p>
{marker.aliases && marker.aliases.length > 0 ? (
<p
className="text-muted-foreground truncate text-xs"
data-slot="biomarker-row-aliases"
>
{t("labs.aliases.alsoWritten", {
aliases: marker.aliases.join(", "),
})}
</p>
) : null}
</div>
<Button
size="icon"
@@ -213,6 +230,18 @@ export function BiomarkerManager() {
state's own button, so the card never offers the same action twice. */}
{!isLoading && !isError && markers.length > 0 ? (
<SettingsCardActions>
{canMerge && markers.length >= 2 ? (
<Button
size="sm"
variant="outline"
onClick={() => setMergeOpen(true)}
className="min-h-11 sm:min-h-9"
data-slot="biomarker-merge-open"
>
<Merge className="h-4 w-4" />
{t("labs.merge.open")}
</Button>
) : null}
<Button size="sm" onClick={openNew} className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("labs.biomarker.define")}
@@ -220,6 +249,14 @@ export function BiomarkerManager() {
</SettingsCardActions>
) : null}
{canMerge ? (
<BiomarkerMergeSheet
open={mergeOpen}
onOpenChange={setMergeOpen}
markers={markers}
/>
) : null}
<ResponsiveSheet
open={formOpen}
onOpenChange={setFormOpen}
@@ -0,0 +1,282 @@
"use client";
/**
* v1.43 (D1223) — merge markers that are one analyte written two ways.
*
* Three steps in one sheet: pick the markers, pick the one that stays, read
* the preview, then confirm. The preview is the server's own dry run
* (`POST /api/biomarkers/{id}/merge` with `dryRun: true`): how many readings
* move, which are converted into the kept marker's unit, which names become
* its aliases, and any marker whose unit cannot be converted, which blocks the
* merge. A merge cannot be undone, and the sheet says so before the button.
* Nothing is computed here; every number is the server's.
*/
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { TagChip } from "@/components/ui/tag-chip";
import { apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BiomarkerDto, BiomarkerMergeResultDto } from "./types";
/** The most sources one merge takes, matching the server. */
const MAX_SOURCES = 10;
export function BiomarkerMergeSheet({
open,
onOpenChange,
markers,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
markers: readonly BiomarkerDto[];
}) {
const { t, tCount } = useTranslations();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
const [targetId, setTargetId] = useState<string | null>(null);
const [preview, setPreview] = useState<BiomarkerMergeResultDto | null>(null);
const target = targetId && selected.includes(targetId) ? targetId : null;
const keepId = target ?? selected[0] ?? null;
const sourceIds = useMemo(
() => selected.filter((id) => id !== keepId),
[selected, keepId],
);
const byId = useMemo(() => new Map(markers.map((m) => [m.id, m])), [markers]);
const reset = () => {
setSelected([]);
setTargetId(null);
setPreview(null);
};
const run = useMutation({
mutationFn: (dryRun: boolean) =>
apiPost<BiomarkerMergeResultDto>(`/api/biomarkers/${keepId}/merge`, {
sourceIds,
dryRun,
}),
onSuccess: (result) => {
if (result.dryRun) {
setPreview(result);
return;
}
toast.success(
tCount("labs.merge.doneToast", result.movedReadings, {
name: result.target.name,
}),
);
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
reset();
onOpenChange(false);
},
onError: () => toast.error(t("labs.merge.error")),
});
const toggle = (id: string, checked: boolean) => {
setPreview(null);
setSelected((prev) =>
checked
? prev.length > MAX_SOURCES
? prev
: [...prev, id]
: prev.filter((x) => x !== id),
);
};
const refused = preview?.sources.filter((s) => s.conversion === "refused");
const canPreview = keepId !== null && sourceIds.length > 0;
return (
<ResponsiveSheet
open={open}
onOpenChange={(next) => {
if (!next) reset();
onOpenChange(next);
}}
title={t("labs.merge.title")}
description={t("labs.merge.description")}
footer={
<div className="flex w-full flex-wrap justify-end gap-2">
{preview ? (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setPreview(null)}
disabled={run.isPending}
>
{t("labs.merge.back")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="biomarker-merge-confirm"
onClick={() => run.mutate(false)}
disabled={run.isPending || (refused?.length ?? 0) > 0}
>
{run.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("labs.merge.confirm")}
</Button>
</>
) : (
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="biomarker-merge-preview"
onClick={() => run.mutate(true)}
disabled={!canPreview || run.isPending}
>
{run.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("labs.merge.showPreview")}
</Button>
)}
</div>
}
>
{preview ? (
<div className="space-y-4" data-slot="biomarker-merge-preview-body">
<p className="text-sm">
{tCount("labs.merge.previewSummary", preview.movedReadings, {
name: preview.target.name,
unit: preview.target.unit,
})}
</p>
<ul className="space-y-2">
{preview.sources.map((source) => (
<li
key={source.id}
className="border-border space-y-0.5 rounded-lg border p-3"
>
<p className="text-sm font-medium break-words">
{source.name}{" "}
<span className="text-muted-foreground text-xs font-normal">
{source.unit}
</span>
</p>
<p
className={
source.conversion === "refused"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{source.conversion === "refused"
? t("labs.merge.refused", {
unit: source.unit,
targetUnit: preview.target.unit,
})
: source.conversion === "converted"
? tCount("labs.merge.converted", source.readingCount, {
unit: source.unit,
targetUnit: preview.target.unit,
})
: tCount("labs.merge.moves", source.readingCount)}
</p>
</li>
))}
</ul>
{preview.aliasesAdded.length > 0 ? (
<div className="space-y-1.5">
<p className="text-muted-foreground text-xs">
{t("labs.merge.aliasesAdded")}
</p>
<div className="flex flex-wrap gap-1.5">
{preview.aliasesAdded.map((alias) => (
<TagChip key={alias}>{alias}</TagChip>
))}
</div>
</div>
) : null}
{(refused?.length ?? 0) > 0 ? (
<p role="alert" className="text-destructive text-sm">
{t("labs.merge.blocked")}
</p>
) : (
<p className="text-sm font-medium">{t("labs.merge.noUndo")}</p>
)}
</div>
) : (
<div className="space-y-4" data-slot="biomarker-merge-select">
<ul className="space-y-1">
{markers.map((marker) => {
const id = `merge-pick-${marker.id}`;
const checked = selected.includes(marker.id);
return (
<li key={marker.id}>
<label
htmlFor={id}
className="hover:bg-muted/50 flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-2 py-1.5"
>
<Checkbox
id={id}
checked={checked}
onCheckedChange={(state) =>
toggle(marker.id, state === true)
}
disabled={!checked && selected.length > MAX_SOURCES}
/>
<span className="min-w-0 flex-1 truncate text-sm">
{marker.name}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{marker.unit}
</span>
</label>
</li>
);
})}
</ul>
{selected.length >= 2 ? (
<div className="space-y-1.5">
<Label htmlFor="merge-keep">{t("labs.merge.keepLabel")}</Label>
<NativeSelect
id="merge-keep"
value={keepId ?? ""}
onChange={(event) => setTargetId(event.target.value)}
>
{selected.map((id) => (
<option key={id} value={id}>
{byId.get(id)?.name ?? id}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("labs.merge.keepHint")}
</p>
</div>
) : (
<p className="text-muted-foreground text-xs">
{t("labs.merge.pickHint")}
</p>
)}
</div>
)}
</ResponsiveSheet>
);
}
+26 -2
View File
@@ -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<BiomarkerDto>(`/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 (
<div className="space-y-6">
<PageHeader
@@ -407,6 +426,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</p>
) : null}
{/* v1.43 (D1223) — the other spellings this marker answers to. */}
{marker ? (
<BiomarkerAliases marker={marker} canEdit={canManageLabs} />
) : 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.
+124
View File
@@ -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 (
<div className="flex flex-wrap items-center gap-1.5">
<Badge
variant="outline"
className="text-muted-foreground max-w-full"
title={t("labs.ocr.linksExisting", { name: matchedName })}
data-slot={`${slot}-matched`}
>
<Link2 aria-hidden />
<span className="truncate">
{t("labs.ocr.linksExisting", { name: matchedName })}
</span>
</Badge>
{matchKind === "alias" || matchKind === "catalog" ? (
<span className="text-muted-foreground text-xs">
{matchKind === "alias"
? t("labs.match.viaAlias")
: t("labs.match.viaCatalog")}
</span>
) : null}
</div>
);
}
if (suggestions.length === 0) {
return (
<Badge
variant="outline"
className="text-muted-foreground"
data-slot={`${slot}-new`}
>
<FilePlus2 aria-hidden />
{t("labs.ocr.newBiomarker")}
</Badge>
);
}
return (
<div className="space-y-1.5" data-slot={`${slot}-choice`}>
<Label htmlFor={selectId} className="text-xs">
{t("labs.match.chooseLabel")}
</Label>
<NativeSelect
id={selectId}
value={chosenId ?? NEW_MARKER}
onChange={(event) =>
onChoose(
suggestions.find((s) => s.biomarkerId === event.target.value) ??
null,
)
}
>
{suggestions.map((suggestion) => (
<option key={suggestion.biomarkerId} value={suggestion.biomarkerId}>
{t("labs.match.suggestionOption", {
name: suggestion.name,
unit: suggestion.unit,
})}
</option>
))}
<option value={NEW_MARKER}>{t("labs.match.newMarkerOption")}</option>
</NativeSelect>
<p className="text-muted-foreground text-xs">
{chosenId ? t("labs.match.rememberHint") : t("labs.match.chooseHint")}
</p>
</div>
);
}
+24 -1
View File
@@ -18,12 +18,16 @@ import { toast } from "sonner";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { ApiError } from "@/lib/api/api-fetch";
import { useQuery } from "@tanstack/react-query";
import { ApiError, apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { OcrSkippedRowDto } from "@/lib/validations/labs-ocr";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
import { useTranslations } from "@/lib/i18n/context";
import { OcrRowEditor } from "./ocr-row-editor";
import type { BiomarkerListResponse } from "./types";
import { seedReviewRows, type OcrReviewRow } from "./ocr-review-types";
import { collectRowErrors, planSave } from "./ocr-review-validation";
import {
@@ -176,6 +180,24 @@ export function OcrReviewDialog({
const textExtract = useOcrTextExtract();
const extract = mode === "text" ? textExtract : visionExtract;
const commit = useOcrCommit();
// v1.43 — the account's markers, so a matched row names its marker and a
// chosen suggestion brings the analyte it converts with. Only once rows are
// on screen; the review works without it (names fall back to the printed
// analyte).
const markers = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
enabled: rows.length > 0,
});
const markerLookup = useMemo(() => {
const byId = new Map(
(markers.data?.biomarkers ?? []).map((m) => [
m.id,
{ name: m.name, analyteKey: m.analyteKey },
]),
);
return (id: string) => byId.get(id) ?? null;
}, [markers.data]);
// Text mode never accepts PDFs (tesseract.js can't read them).
const allowPdf = mode === "vision" && pdfSupported;
@@ -437,6 +459,7 @@ export function OcrReviewDialog({
<OcrRowEditor
key={row.key}
row={row}
markerLookup={markerLookup}
errors={showErrors ? rowErrors.get(row.key) : undefined}
onChange={(next) =>
setRows((prev) =>
+43
View File
@@ -34,6 +34,16 @@ export interface OcrReviewRow {
markerAnalyteKey: string | null;
/** Non-null when the server flagged a likely duplicate of a live reading. */
duplicateOf: string | null;
/**
* v1.43 (D1223) — the marker the server matched the name to (its name, an
* alias, or a catalog abbreviation), and how; null when nothing matched.
*/
biomarkerId: string | null;
matchKind: "name" | "alias" | "catalog" | null;
/** Up to three markers to choose from when nothing matched. */
suggestions: OcrExtractedRowDto["suggestions"];
/** The suggestion the person picked; null keeps the automatic outcome. */
chosenBiomarkerId: string | null;
/** Whether the row is selected for saving. Defaults false for duplicates. */
confirmed: boolean;
}
@@ -58,6 +68,11 @@ export function seedReviewRows(
markerUnit: row.markerUnit,
markerAnalyteKey: row.markerAnalyteKey,
duplicateOf: row.duplicateOf,
// A scan read before v1.43 carries none of the three.
biomarkerId: row.biomarkerId ?? null,
matchKind: row.matchKind ?? null,
suggestions: row.suggestions ?? [],
chosenBiomarkerId: null,
// A flagged duplicate starts unchecked, and so does a reading in another
// unit than its marker's that cannot be converted (it carries a badge
// saying why, and ticking it marks the unit field until it is fixed); a
@@ -74,3 +89,31 @@ export function seedReviewRows(
}),
}));
}
/**
* Point a row at a suggestion the person picked, or back at a new marker. The
* marker's unit and analyte come with the choice, so the unit check and the
* conversion badge judge the row against the marker it will be filed under.
*/
export function chooseMarker(
row: OcrReviewRow,
choice: { biomarkerId: string; unit: string } | null,
analyteKeyOf: (biomarkerId: string) => string | null,
): OcrReviewRow {
if (choice === null) {
return {
...row,
chosenBiomarkerId: null,
biomarkerMatch: "new",
markerUnit: null,
markerAnalyteKey: null,
};
}
return {
...row,
chosenBiomarkerId: choice.biomarkerId,
biomarkerMatch: "existing",
markerUnit: choice.unit,
markerAnalyteKey: analyteKeyOf(choice.biomarkerId),
};
}
+12 -1
View File
@@ -129,7 +129,12 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
if (isQualitativeRow(row)) {
const valueText = (row.valueText ?? "").trim();
if (!valueText) return null;
return { analyte, valueText, takenAt: takenAt.toISOString() };
return {
analyte,
valueText,
takenAt: takenAt.toISOString(),
...chosen(row),
};
}
if (row.value === null || !Number.isFinite(row.value)) return null;
@@ -144,9 +149,15 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
...(row.referenceLow !== null ? { referenceLow: row.referenceLow } : {}),
...(row.referenceHigh !== null ? { referenceHigh: row.referenceHigh } : {}),
...(row.referenceText ? { referenceText: row.referenceText } : {}),
...chosen(row),
};
}
/** The marker the person picked, which the commit then files the row under. */
function chosen(row: OcrReviewRow): { biomarkerId?: string } {
return row.chosenBiomarkerId ? { biomarkerId: row.chosenBiomarkerId } : {};
}
/** Blocking fields per CONFIRMED row, keyed by the row's key; writable rows are absent. */
export function collectRowErrors(
rows: readonly OcrReviewRow[],
+34 -27
View File
@@ -15,13 +15,7 @@
*/
import { useId } from "react";
import {
AlertCircle,
FilePlus2,
Link2,
Ruler,
TriangleAlert,
} from "lucide-react";
import { AlertCircle, Ruler, TriangleAlert } from "lucide-react";
import { FieldError } from "@/components/forms/field-error";
import { Badge } from "@/components/ui/badge";
@@ -31,7 +25,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import type { OcrReviewRow } from "./ocr-review-types";
import { MarkerMatchField } from "./marker-match-field";
import { chooseMarker, type OcrReviewRow } from "./ocr-review-types";
import {
readingConversion,
readingUnitBlocked,
@@ -55,9 +50,18 @@ export function OcrRowEditor({
row,
onChange,
errors,
markerLookup = () => null,
}: {
row: OcrReviewRow;
onChange: (next: OcrReviewRow) => void;
/**
* The account's marker by id, for the matched marker's name and the
* analyte a chosen suggestion converts with. Absent: names fall back to the
* printed analyte.
*/
markerLookup?: (
biomarkerId: string,
) => { name: string; analyteKey: string | null } | null;
/** The blocking fields of this row, once a save has been attempted. */
errors?: OcrRowErrors;
}) {
@@ -151,26 +155,29 @@ export function OcrRowEditor({
className="font-medium"
/>
{fieldError("analyte", "labs.ocr.errorAnalyte")}
{/* v1.43 (D1223) — the marker this value goes to: the server's
match, or a choice between its suggestions and a new one. */}
<MarkerMatchField
matchedName={
row.biomarkerId !== null
? (markerLookup(row.biomarkerId)?.name ?? row.analyte)
: null
}
matchKind={row.matchKind}
suggestions={row.suggestions}
chosenId={row.chosenBiomarkerId}
onChoose={(suggestion) =>
onChange(
chooseMarker(
row,
suggestion,
(id) => markerLookup(id)?.analyteKey ?? null,
),
)
}
slot="ocr-row-marker"
/>
<div className="flex flex-wrap items-center gap-1.5">
{row.biomarkerMatch === "existing" ? (
// The chip repeats the analyte, so a long panel name ran past
// the row on a phone; it truncates inside the row instead.
<Badge
variant="outline"
className="text-muted-foreground max-w-full"
title={t("labs.ocr.linksExisting", { name: row.analyte })}
>
<Link2 aria-hidden />
<span className="truncate">
{t("labs.ocr.linksExisting", { name: row.analyte })}
</span>
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
<FilePlus2 aria-hidden />
{t("labs.ocr.newBiomarker")}
</Badge>
)}
{row.duplicateOf ? (
<Badge variant="secondary">
<TriangleAlert aria-hidden />
+23
View File
@@ -95,6 +95,11 @@ export interface BiomarkerDto {
analyteKey: string | null;
hasContext: boolean;
context: string | null;
/**
* v1.43 — other spellings that resolve to this marker, sorted, without its
* own name. Absent from an older server.
*/
aliases?: string[];
/** v1.22 — a hidden marker drops from the active list + lab-entry pickers. */
hidden: boolean;
createdAt: string;
@@ -104,3 +109,21 @@ export interface BiomarkerDto {
export interface BiomarkerListResponse {
biomarkers: BiomarkerDto[];
}
/** v1.43 — `POST /api/biomarkers/{id}/merge`, for the preview and the result. */
export interface BiomarkerMergeResultDto {
dryRun: boolean;
target: BiomarkerDto;
sources: Array<{
id: string;
name: string;
unit: string;
readingCount: number;
conversion: "none" | "converted" | "refused";
refusal: "unknown_analyte" | "unknown_unit" | "not_finite" | null;
}>;
movedReadings: number;
convertedReadings: number;
aliasesAdded: string[];
mergeId: string | null;
}
+5
View File
@@ -51,6 +51,11 @@ export interface OcrCommitRowInput {
referenceHigh?: number;
referenceText?: string;
takenAt: string;
/**
* v1.43 — the marker the person chose on the review screen; the server then
* remembers the analyte as its alias. Absent for an automatic match.
*/
biomarkerId?: string;
}
export interface OcrCommitResult extends OcrCommitResponseDto {