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 {