From 754efdee09a69efd0772748f29df5e89b7725711 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 04:54:31 +0800 Subject: [PATCH] feat(frontend): let the clinician pick the tooth from the candidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An under-specified tooth was a dead end: the sheet said what was missing and the clinician had to leave and hunt for it on the chart. The readings are enumerable, so the review sheet now renders them as chips — the one interactive part of an otherwise read-only confirmation step. A pick is folded into the result by withChosenTeeth() rather than tracked alongside it, so the rows, the mini chart, the prosthesis warning and applyVoiceResult all keep reading a single VoiceExtractionResult and none of them has to know the chips exist. It unions rather than toggles: a candidate can coincidentally be a tooth the recording already produced, and tapping it must not deselect that one. Two things that would otherwise make the chips look functional while applying nothing: the teeth row is ticked on the first pick (it starts unticked when the recording produced no teeth of its own), and the apply count is now intersected with row availability so it cannot promise to apply a row with nothing in it. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/messages/en.json | 3 +- frontend/messages/fa.json | 3 +- frontend/messages/nl.json | 3 +- .../components/treatment/voiceReviewRows.ts | 61 +++++++++++++- .../ui/treatment/TreatmentWorkspace.tsx | 2 +- .../ui/treatment/VoiceReviewSheet.tsx | 82 ++++++++++++++----- frontend/src/types/voice.ts | 5 ++ 7 files changed, 130 insertions(+), 29 deletions(-) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 80c2403..0c76d3b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -909,12 +909,13 @@ "voiceProsthesisIncomplete": "No prosthesis type for {teeth} — the case cannot be sent until every tooth has one.", "voiceLabInexact": "The spoken name only partly matched this lab. Confirm before sending.", "voiceNotUnderstood": "Not understood", + "voicePickTooth": "Which tooth?", "voiceDiscard": "Discard", "voiceApply": "{count, plural, one {Apply # field} other {Apply # fields}}", "voiceUnresolved": { "not_permanent_tooth": "not a permanent tooth", "position_out_of_range": "not a valid tooth position", - "tooth_missing_quadrant": "quadrant not said — e.g. “upper right two”", + "tooth_missing_quadrant": "not a whole tooth number — say e.g. “twenty-six”", "malformed": "could not be read", "span_not_same_arch": "a bridge cannot span both jaws", "unknown_catalog_code": "not in this clinic’s list", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index d5ff320..f3ec8dd 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -910,12 +910,13 @@ "voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندان‌ها نوع داشته باشند، کیس ارسال نمی‌شود.", "voiceLabInexact": "نام گفته‌شده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.", "voiceNotUnderstood": "شناسایی نشد", + "voicePickTooth": "کدام دندان؟", "voiceDiscard": "انصراف", "voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}", "voiceUnresolved": { "not_permanent_tooth": "دندان دائمی نیست", "position_out_of_range": "شماره دندان معتبر نیست", - "tooth_missing_quadrant": "بالا/پایین و چپ/راست گفته نشد — مثلاً «دو بالا راست»", + "tooth_missing_quadrant": "شماره کامل دندان نیست — مثلاً «بیست و شش»", "malformed": "قابل خواندن نبود", "span_not_same_arch": "بریج نمی‌تواند بین دو فک باشد", "unknown_catalog_code": "در فهرست این مطب نیست", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index a5d055d..bad8d12 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -909,12 +909,13 @@ "voiceProsthesisIncomplete": "Geen prothesetype voor {teeth} — de casus kan pas worden verstuurd als elk element er een heeft.", "voiceLabInexact": "De uitgesproken naam kwam slechts deels overeen met dit lab. Bevestig voor verzending.", "voiceNotUnderstood": "Niet begrepen", + "voicePickTooth": "Welk element?", "voiceDiscard": "Verwerpen", "voiceApply": "{count, plural, one {# veld toepassen} other {# velden toepassen}}", "voiceUnresolved": { "not_permanent_tooth": "geen blijvend element", "position_out_of_range": "geen geldige elementpositie", - "tooth_missing_quadrant": "kwadrant niet genoemd — bijv. “rechtsboven twee”", + "tooth_missing_quadrant": "geen volledig elementnummer — bijv. “zesentwintig”", "malformed": "kon niet worden gelezen", "span_not_same_arch": "een brug kan niet over beide kaken lopen", "unknown_catalog_code": "staat niet in de lijst van deze praktijk", diff --git a/frontend/src/components/treatment/voiceReviewRows.ts b/frontend/src/components/treatment/voiceReviewRows.ts index f67537c..eda78d6 100644 --- a/frontend/src/components/treatment/voiceReviewRows.ts +++ b/frontend/src/components/treatment/voiceReviewRows.ts @@ -1,5 +1,10 @@ +import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups'; import type { FdiToothId } from '@/types/treatment'; -import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice'; +import type { + VoiceApplySelection, + VoiceExtractionResult, + VoiceProsthesisResult, +} from '@/types/voice'; /** Which rows the review sheet renders at all — a row with nothing extracted is noise. */ export function voiceRowAvailability(result: VoiceExtractionResult) { @@ -35,9 +40,57 @@ export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApply }; } -/** How many rows will actually be applied — drives the confirm button's label. */ -export function countSelected(selection: VoiceApplySelection): number { - return Object.values(selection).filter(Boolean).length; +/** + * How many rows will actually be applied — drives the confirm button's label. + * + * Intersected with availability rather than counting ticks: a row can be ticked and then + * lose its content (the last candidate tooth un-picked), and "Apply 1 item" that applies + * nothing is worse than a wrong number. + */ +export function countSelected( + selection: VoiceApplySelection, + available: Record, +): number { + return (Object.keys(selection) as (keyof VoiceApplySelection)[]).filter( + (key) => selection[key] && available[key], + ).length; +} + +/** Mirrors the backend's rule: every selected tooth needs a code, or the case cannot ship. */ +function recheckProsthesis( + prosthesis: VoiceProsthesisResult, + teeth: readonly FdiToothId[], +): VoiceProsthesisResult { + const missingTeeth = teeth.filter((tooth) => !prosthesis.byTooth[tooth]); + return { ...prosthesis, missingTeeth, complete: missingTeeth.length === 0 }; +} + +/** + * Fold the clinician's candidate picks into the extracted result. + * + * Everything downstream reads a `VoiceExtractionResult` — row availability, the mini + * chart, the prosthesis warning, `applyVoiceResult` — so resolving the picks into one here + * means none of them has to know the chips exist. + * + * Union rather than toggle, for two reasons: a candidate can coincidentally be a tooth the + * recording already produced ("۱۲ و دو"), where tapping it must not deselect that tooth; + * and `groupsFromFlatTeeth` keeps the bridges intact while giving every remaining tooth a + * single group, so no tooth can be lost on the way through. + */ +export function withChosenTeeth( + result: VoiceExtractionResult, + chosen: readonly FdiToothId[], +): VoiceExtractionResult { + if (chosen.length === 0) return result; + + const teeth = [...new Set([...result.teeth, ...chosen])].sort() as FdiToothId[]; + + return { + ...result, + teeth, + toothSelectionGroups: groupsFromFlatTeeth(teeth, result.toothSelectionGroups), + prosthesis: result.prosthesis ? recheckProsthesis(result.prosthesis, teeth) : null, + }; } /** Teeth that are part of a bridge, for the read-only chart's connection marks. */ diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 1f3faee..233bc71 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -2842,7 +2842,7 @@ export function TreatmentWorkspace({ treatmentCatalog={treatmentCatalog} prosthesisCatalog={prosthesisCatalog} labs={orgs} - onApply={(selection) => applyVoiceResult(voiceResult, selection)} + onApply={(selection, applied) => applyVoiceResult(applied, selection)} onDiscard={() => setVoiceResult(null)} /> ) : null} diff --git a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx index 62371c6..e55e041 100644 --- a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx +++ b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx @@ -16,12 +16,13 @@ import { hasAnythingToApply, initialVoiceSelection, voiceRowAvailability, + withChosenTeeth, } from '@/components/treatment/voiceReviewRows'; import { useLocale } from 'next-intl'; import { useAppFormatters } from '@/lib/hooks/useAppFormatters'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; -import type { LinkedOrganizationOption } from '@/types/treatment'; +import type { FdiToothId, LinkedOrganizationOption } from '@/types/treatment'; import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice'; interface VoiceReviewSheetProps { @@ -29,7 +30,8 @@ interface VoiceReviewSheetProps { treatmentCatalog: TreatmentCatalogEntry[]; prosthesisCatalog: ProsthesisCatalogEntry[]; labs: LinkedOrganizationOption[]; - onApply: (selection: VoiceApplySelection) => void; + /** The result is handed back because the sheet may have added teeth the model missed. */ + onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => void; onDiscard: () => void; } @@ -54,12 +56,26 @@ export function VoiceReviewSheet({ const [selection, setSelection] = useState(() => initialVoiceSelection(result), ); + const [chosen, setChosen] = useState([]); - const available = useMemo(() => voiceRowAvailability(result), [result]); - const connectedTeeth = useMemo(() => connectedTeethFromResult(result), [result]); - const selectedTeeth = useMemo(() => new Set(result.teeth), [result.teeth]); - const nothingToApply = !hasAnythingToApply(result); - const selectedCount = countSelected(selection); + // Everything below renders from `effective`, never from `result` — a tooth picked from + // the candidate chips has to reach the rows, the chart and the apply count alike. + const effective = useMemo(() => withChosenTeeth(result, chosen), [result, chosen]); + + const available = useMemo(() => voiceRowAvailability(effective), [effective]); + const connectedTeeth = useMemo(() => connectedTeethFromResult(effective), [effective]); + const selectedTeeth = useMemo(() => new Set(effective.teeth), [effective.teeth]); + const nothingToApply = !hasAnythingToApply(effective); + const selectedCount = countSelected(selection, available); + + const pickCandidate = (tooth: FdiToothId) => { + setChosen((prev) => + prev.includes(tooth) ? prev.filter((t) => t !== tooth) : [...prev, tooth], + ); + // The teeth row starts unticked whenever the recording produced no teeth of its own, + // and a picked tooth that is not ticked applies nothing. + setSelection((prev) => (prev.teeth ? prev : { ...prev, teeth: true })); + }; const labelFor = (code: string | null, catalog: { code: string; label: string }[]) => catalog.find((entry) => entry.code === code)?.label ?? code ?? ''; @@ -80,7 +96,7 @@ export function VoiceReviewSheet({

- {result.transcript} + {effective.transcript}

{nothingToApply ? ( @@ -94,7 +110,7 @@ export function VoiceReviewSheet({ onChange={toggle('treatmentType')} > - {labelFor(result.treatmentType, treatmentCatalog)} + {labelFor(effective.treatmentType, treatmentCatalog)} ) : null} @@ -120,26 +136,26 @@ export function VoiceReviewSheet({ onChange={toggle('comment')} > - {result.comment} + {effective.comment} ) : null} - {available.prosthesis && result.prosthesis ? ( + {available.prosthesis && effective.prosthesis ? ( - {Object.entries(result.prosthesis.byTooth) + {Object.entries(effective.prosthesis.byTooth) .map( ([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`, ) @@ -153,38 +169,62 @@ export function VoiceReviewSheet({ label={t('entryStepLab')} checked={selection.lab} onChange={toggle('lab')} - warning={result.labMatchExact ? undefined : t('voiceLabInexact')} + warning={effective.labMatchExact ? undefined : t('voiceLabInexact')} > - {labs.find((lab) => lab.id === result.labId)?.name ?? result.labId} + {labs.find((lab) => lab.id === effective.labId)?.name ?? effective.labId} ) : null} - {available.dueDate && result.dueDate ? ( + {available.dueDate && effective.dueDate ? ( - {formatDate(civilDateToLocalDate(result.dueDate))} + {formatDate(civilDateToLocalDate(effective.dueDate))} ) : null} )} - {result.unresolved.length > 0 ? ( + {effective.unresolved.length > 0 ? (

{t('voiceNotUnderstood')}

    - {result.unresolved.map((item, index) => ( + {effective.unresolved.map((item, index) => (
  • {item.spoken ? `“${item.spoken}” — ` : ''} {t(`voiceUnresolved.${item.reason}`)} + {item.candidates && item.candidates.length > 0 ? ( + + {t('voicePickTooth')} + {item.candidates.map((tooth) => { + const picked = chosen.includes(tooth as FdiToothId); + return ( + + ); + })} + + ) : null}
  • ))}
@@ -199,7 +239,7 @@ export function VoiceReviewSheet({ type="button" variant="primary" disabled={selectedCount === 0} - onClick={() => onApply(selection)} + onClick={() => onApply(selection, effective)} fullWidth className="sm:w-auto" > diff --git a/frontend/src/types/voice.ts b/frontend/src/types/voice.ts index 8488066..b77665d 100644 --- a/frontend/src/types/voice.ts +++ b/frontend/src/types/voice.ts @@ -16,6 +16,11 @@ export interface VoiceUnresolvedItem { /** The transcript span that could not be resolved, so the clinician sees what was heard. */ spoken: string; reason: VoiceUnresolvedReason; + /** + * FDI codes still consistent with what was heard, when a choice would settle it — the + * review sheet offers them as chips. Only `tooth_missing_quadrant` carries these. + */ + candidates?: string[]; } export interface VoiceProsthesisResult {