mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
e5544913f6
commit
e65ada2961
27 files changed
+1454
-52
No files matched your search
+50
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user