feat(frontend): let the clinician pick the tooth from the candidates

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 04:54:31 +08:00
parent 82fad4ac02
commit 754efdee09
7 changed files with 130 additions and 29 deletions

View File

@@ -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 clinics list",

View File

@@ -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": "در فهرست این مطب نیست",

View File

@@ -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",

View File

@@ -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<keyof VoiceApplySelection, boolean>,
): 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. */

View File

@@ -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}

View File

@@ -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<VoiceApplySelection>(() =>
initialVoiceSelection(result),
);
const [chosen, setChosen] = useState<FdiToothId[]>([]);
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({
</h2>
<p className="mt-2 rounded-[var(--radius-md)] bg-background-card/60 px-3 py-2 text-sm text-text-secondary">
{result.transcript}
{effective.transcript}
</p>
{nothingToApply ? (
@@ -94,7 +110,7 @@ export function VoiceReviewSheet({
onChange={toggle('treatmentType')}
>
<span className="text-sm text-text-primary">
{labelFor(result.treatmentType, treatmentCatalog)}
{labelFor(effective.treatmentType, treatmentCatalog)}
</span>
</Row>
) : null}
@@ -120,26 +136,26 @@ export function VoiceReviewSheet({
onChange={toggle('comment')}
>
<span className="text-sm whitespace-pre-wrap text-text-primary">
{result.comment}
{effective.comment}
</span>
</Row>
) : null}
{available.prosthesis && result.prosthesis ? (
{available.prosthesis && effective.prosthesis ? (
<Row
label={t('prosthesisColType')}
checked={selection.prosthesis}
onChange={toggle('prosthesis')}
warning={
result.prosthesis.complete
effective.prosthesis.complete
? undefined
: t('voiceProsthesisIncomplete', {
teeth: formatToothList(result.prosthesis.missingTeeth, locale),
teeth: formatToothList(effective.prosthesis.missingTeeth, locale),
})
}
>
<span className="text-sm text-text-primary">
{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')}
>
<span className="text-sm text-text-primary">
{labs.find((lab) => lab.id === result.labId)?.name ?? result.labId}
{labs.find((lab) => lab.id === effective.labId)?.name ?? effective.labId}
</span>
</Row>
) : null}
{available.dueDate && result.dueDate ? (
{available.dueDate && effective.dueDate ? (
<Row
label={t('dueDateLabel')}
checked={selection.dueDate}
onChange={toggle('dueDate')}
>
<span className="text-sm text-text-primary">
{formatDate(civilDateToLocalDate(result.dueDate))}
{formatDate(civilDateToLocalDate(effective.dueDate))}
</span>
</Row>
) : null}
</div>
)}
{result.unresolved.length > 0 ? (
{effective.unresolved.length > 0 ? (
<div className="mt-4 rounded-[var(--radius-md)] border border-amber-500/40 bg-amber-500/10 px-3 py-2">
<p className="text-xs font-medium text-amber-700 dark:text-amber-400">
{t('voiceNotUnderstood')}
</p>
<ul className="mt-1 space-y-0.5">
{result.unresolved.map((item, index) => (
{effective.unresolved.map((item, index) => (
<li key={`${item.spoken}-${index}`} className="text-xs text-text-secondary">
{item.spoken ? `${item.spoken}” — ` : ''}
{t(`voiceUnresolved.${item.reason}`)}
{item.candidates && item.candidates.length > 0 ? (
<span className="mt-1 flex flex-wrap items-center gap-1">
<span className="text-text-muted">{t('voicePickTooth')}</span>
{item.candidates.map((tooth) => {
const picked = chosen.includes(tooth as FdiToothId);
return (
<button
key={tooth}
type="button"
aria-pressed={picked}
aria-label={t('toothAria', { fdi: tooth })}
onClick={() => pickCandidate(tooth as FdiToothId)}
className={`rounded-full border px-2 py-0.5 text-xs transition-colors ${
picked
? 'border-transparent bg-primary text-white'
: 'border-border text-text-primary hover:border-border-strong'
}`}
>
{tooth}
</button>
);
})}
</span>
) : null}
</li>
))}
</ul>
@@ -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"
>

View File

@@ -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 {