feat(treatment): add voice detail entry #65
@@ -909,12 +909,13 @@
|
|||||||
"voiceProsthesisIncomplete": "No prosthesis type for {teeth} — the case cannot be sent until every tooth has one.",
|
"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.",
|
"voiceLabInexact": "The spoken name only partly matched this lab. Confirm before sending.",
|
||||||
"voiceNotUnderstood": "Not understood",
|
"voiceNotUnderstood": "Not understood",
|
||||||
|
"voicePickTooth": "Which tooth?",
|
||||||
"voiceDiscard": "Discard",
|
"voiceDiscard": "Discard",
|
||||||
"voiceApply": "{count, plural, one {Apply # field} other {Apply # fields}}",
|
"voiceApply": "{count, plural, one {Apply # field} other {Apply # fields}}",
|
||||||
"voiceUnresolved": {
|
"voiceUnresolved": {
|
||||||
"not_permanent_tooth": "not a permanent tooth",
|
"not_permanent_tooth": "not a permanent tooth",
|
||||||
"position_out_of_range": "not a valid tooth position",
|
"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",
|
"malformed": "could not be read",
|
||||||
"span_not_same_arch": "a bridge cannot span both jaws",
|
"span_not_same_arch": "a bridge cannot span both jaws",
|
||||||
"unknown_catalog_code": "not in this clinic’s list",
|
"unknown_catalog_code": "not in this clinic’s list",
|
||||||
|
|||||||
@@ -910,12 +910,13 @@
|
|||||||
"voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندانها نوع داشته باشند، کیس ارسال نمیشود.",
|
"voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندانها نوع داشته باشند، کیس ارسال نمیشود.",
|
||||||
"voiceLabInexact": "نام گفتهشده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.",
|
"voiceLabInexact": "نام گفتهشده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.",
|
||||||
"voiceNotUnderstood": "شناسایی نشد",
|
"voiceNotUnderstood": "شناسایی نشد",
|
||||||
|
"voicePickTooth": "کدام دندان؟",
|
||||||
"voiceDiscard": "انصراف",
|
"voiceDiscard": "انصراف",
|
||||||
"voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}",
|
"voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}",
|
||||||
"voiceUnresolved": {
|
"voiceUnresolved": {
|
||||||
"not_permanent_tooth": "دندان دائمی نیست",
|
"not_permanent_tooth": "دندان دائمی نیست",
|
||||||
"position_out_of_range": "شماره دندان معتبر نیست",
|
"position_out_of_range": "شماره دندان معتبر نیست",
|
||||||
"tooth_missing_quadrant": "بالا/پایین و چپ/راست گفته نشد — مثلاً «دو بالا راست»",
|
"tooth_missing_quadrant": "شماره کامل دندان نیست — مثلاً «بیست و شش»",
|
||||||
"malformed": "قابل خواندن نبود",
|
"malformed": "قابل خواندن نبود",
|
||||||
"span_not_same_arch": "بریج نمیتواند بین دو فک باشد",
|
"span_not_same_arch": "بریج نمیتواند بین دو فک باشد",
|
||||||
"unknown_catalog_code": "در فهرست این مطب نیست",
|
"unknown_catalog_code": "در فهرست این مطب نیست",
|
||||||
|
|||||||
@@ -909,12 +909,13 @@
|
|||||||
"voiceProsthesisIncomplete": "Geen prothesetype voor {teeth} — de casus kan pas worden verstuurd als elk element er een heeft.",
|
"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.",
|
"voiceLabInexact": "De uitgesproken naam kwam slechts deels overeen met dit lab. Bevestig voor verzending.",
|
||||||
"voiceNotUnderstood": "Niet begrepen",
|
"voiceNotUnderstood": "Niet begrepen",
|
||||||
|
"voicePickTooth": "Welk element?",
|
||||||
"voiceDiscard": "Verwerpen",
|
"voiceDiscard": "Verwerpen",
|
||||||
"voiceApply": "{count, plural, one {# veld toepassen} other {# velden toepassen}}",
|
"voiceApply": "{count, plural, one {# veld toepassen} other {# velden toepassen}}",
|
||||||
"voiceUnresolved": {
|
"voiceUnresolved": {
|
||||||
"not_permanent_tooth": "geen blijvend element",
|
"not_permanent_tooth": "geen blijvend element",
|
||||||
"position_out_of_range": "geen geldige elementpositie",
|
"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",
|
"malformed": "kon niet worden gelezen",
|
||||||
"span_not_same_arch": "een brug kan niet over beide kaken lopen",
|
"span_not_same_arch": "een brug kan niet over beide kaken lopen",
|
||||||
"unknown_catalog_code": "staat niet in de lijst van deze praktijk",
|
"unknown_catalog_code": "staat niet in de lijst van deze praktijk",
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
|
||||||
import type { FdiToothId } from '@/types/treatment';
|
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. */
|
/** Which rows the review sheet renders at all — a row with nothing extracted is noise. */
|
||||||
export function voiceRowAvailability(result: VoiceExtractionResult) {
|
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 {
|
* How many rows will actually be applied — drives the confirm button's label.
|
||||||
return Object.values(selection).filter(Boolean).length;
|
*
|
||||||
|
* 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. */
|
/** Teeth that are part of a bridge, for the read-only chart's connection marks. */
|
||||||
|
|||||||
@@ -2842,7 +2842,7 @@ export function TreatmentWorkspace({
|
|||||||
treatmentCatalog={treatmentCatalog}
|
treatmentCatalog={treatmentCatalog}
|
||||||
prosthesisCatalog={prosthesisCatalog}
|
prosthesisCatalog={prosthesisCatalog}
|
||||||
labs={orgs}
|
labs={orgs}
|
||||||
onApply={(selection) => applyVoiceResult(voiceResult, selection)}
|
onApply={(selection, applied) => applyVoiceResult(applied, selection)}
|
||||||
onDiscard={() => setVoiceResult(null)}
|
onDiscard={() => setVoiceResult(null)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ import {
|
|||||||
hasAnythingToApply,
|
hasAnythingToApply,
|
||||||
initialVoiceSelection,
|
initialVoiceSelection,
|
||||||
voiceRowAvailability,
|
voiceRowAvailability,
|
||||||
|
withChosenTeeth,
|
||||||
} from '@/components/treatment/voiceReviewRows';
|
} from '@/components/treatment/voiceReviewRows';
|
||||||
import { useLocale } from 'next-intl';
|
import { useLocale } from 'next-intl';
|
||||||
import { useAppFormatters } from '@/lib/hooks/useAppFormatters';
|
import { useAppFormatters } from '@/lib/hooks/useAppFormatters';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type { ProsthesisCatalogEntry } 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';
|
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
|
||||||
|
|
||||||
interface VoiceReviewSheetProps {
|
interface VoiceReviewSheetProps {
|
||||||
@@ -29,7 +30,8 @@ interface VoiceReviewSheetProps {
|
|||||||
treatmentCatalog: TreatmentCatalogEntry[];
|
treatmentCatalog: TreatmentCatalogEntry[];
|
||||||
prosthesisCatalog: ProsthesisCatalogEntry[];
|
prosthesisCatalog: ProsthesisCatalogEntry[];
|
||||||
labs: LinkedOrganizationOption[];
|
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;
|
onDiscard: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,12 +56,26 @@ export function VoiceReviewSheet({
|
|||||||
const [selection, setSelection] = useState<VoiceApplySelection>(() =>
|
const [selection, setSelection] = useState<VoiceApplySelection>(() =>
|
||||||
initialVoiceSelection(result),
|
initialVoiceSelection(result),
|
||||||
);
|
);
|
||||||
|
const [chosen, setChosen] = useState<FdiToothId[]>([]);
|
||||||
|
|
||||||
const available = useMemo(() => voiceRowAvailability(result), [result]);
|
// Everything below renders from `effective`, never from `result` — a tooth picked from
|
||||||
const connectedTeeth = useMemo(() => connectedTeethFromResult(result), [result]);
|
// the candidate chips has to reach the rows, the chart and the apply count alike.
|
||||||
const selectedTeeth = useMemo(() => new Set(result.teeth), [result.teeth]);
|
const effective = useMemo(() => withChosenTeeth(result, chosen), [result, chosen]);
|
||||||
const nothingToApply = !hasAnythingToApply(result);
|
|
||||||
const selectedCount = countSelected(selection);
|
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 }[]) =>
|
const labelFor = (code: string | null, catalog: { code: string; label: string }[]) =>
|
||||||
catalog.find((entry) => entry.code === code)?.label ?? code ?? '';
|
catalog.find((entry) => entry.code === code)?.label ?? code ?? '';
|
||||||
@@ -80,7 +96,7 @@ export function VoiceReviewSheet({
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="mt-2 rounded-[var(--radius-md)] bg-background-card/60 px-3 py-2 text-sm text-text-secondary">
|
<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>
|
</p>
|
||||||
|
|
||||||
{nothingToApply ? (
|
{nothingToApply ? (
|
||||||
@@ -94,7 +110,7 @@ export function VoiceReviewSheet({
|
|||||||
onChange={toggle('treatmentType')}
|
onChange={toggle('treatmentType')}
|
||||||
>
|
>
|
||||||
<span className="text-sm text-text-primary">
|
<span className="text-sm text-text-primary">
|
||||||
{labelFor(result.treatmentType, treatmentCatalog)}
|
{labelFor(effective.treatmentType, treatmentCatalog)}
|
||||||
</span>
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -120,26 +136,26 @@ export function VoiceReviewSheet({
|
|||||||
onChange={toggle('comment')}
|
onChange={toggle('comment')}
|
||||||
>
|
>
|
||||||
<span className="text-sm whitespace-pre-wrap text-text-primary">
|
<span className="text-sm whitespace-pre-wrap text-text-primary">
|
||||||
{result.comment}
|
{effective.comment}
|
||||||
</span>
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{available.prosthesis && result.prosthesis ? (
|
{available.prosthesis && effective.prosthesis ? (
|
||||||
<Row
|
<Row
|
||||||
label={t('prosthesisColType')}
|
label={t('prosthesisColType')}
|
||||||
checked={selection.prosthesis}
|
checked={selection.prosthesis}
|
||||||
onChange={toggle('prosthesis')}
|
onChange={toggle('prosthesis')}
|
||||||
warning={
|
warning={
|
||||||
result.prosthesis.complete
|
effective.prosthesis.complete
|
||||||
? undefined
|
? undefined
|
||||||
: t('voiceProsthesisIncomplete', {
|
: t('voiceProsthesisIncomplete', {
|
||||||
teeth: formatToothList(result.prosthesis.missingTeeth, locale),
|
teeth: formatToothList(effective.prosthesis.missingTeeth, locale),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="text-sm text-text-primary">
|
<span className="text-sm text-text-primary">
|
||||||
{Object.entries(result.prosthesis.byTooth)
|
{Object.entries(effective.prosthesis.byTooth)
|
||||||
.map(
|
.map(
|
||||||
([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`,
|
([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`,
|
||||||
)
|
)
|
||||||
@@ -153,38 +169,62 @@ export function VoiceReviewSheet({
|
|||||||
label={t('entryStepLab')}
|
label={t('entryStepLab')}
|
||||||
checked={selection.lab}
|
checked={selection.lab}
|
||||||
onChange={toggle('lab')}
|
onChange={toggle('lab')}
|
||||||
warning={result.labMatchExact ? undefined : t('voiceLabInexact')}
|
warning={effective.labMatchExact ? undefined : t('voiceLabInexact')}
|
||||||
>
|
>
|
||||||
<span className="text-sm text-text-primary">
|
<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>
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{available.dueDate && result.dueDate ? (
|
{available.dueDate && effective.dueDate ? (
|
||||||
<Row
|
<Row
|
||||||
label={t('dueDateLabel')}
|
label={t('dueDateLabel')}
|
||||||
checked={selection.dueDate}
|
checked={selection.dueDate}
|
||||||
onChange={toggle('dueDate')}
|
onChange={toggle('dueDate')}
|
||||||
>
|
>
|
||||||
<span className="text-sm text-text-primary">
|
<span className="text-sm text-text-primary">
|
||||||
{formatDate(civilDateToLocalDate(result.dueDate))}
|
{formatDate(civilDateToLocalDate(effective.dueDate))}
|
||||||
</span>
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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">
|
<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">
|
<p className="text-xs font-medium text-amber-700 dark:text-amber-400">
|
||||||
{t('voiceNotUnderstood')}
|
{t('voiceNotUnderstood')}
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-1 space-y-0.5">
|
<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">
|
<li key={`${item.spoken}-${index}`} className="text-xs text-text-secondary">
|
||||||
{item.spoken ? `“${item.spoken}” — ` : ''}
|
{item.spoken ? `“${item.spoken}” — ` : ''}
|
||||||
{t(`voiceUnresolved.${item.reason}`)}
|
{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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -199,7 +239,7 @@ export function VoiceReviewSheet({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={selectedCount === 0}
|
disabled={selectedCount === 0}
|
||||||
onClick={() => onApply(selection)}
|
onClick={() => onApply(selection, effective)}
|
||||||
fullWidth
|
fullWidth
|
||||||
className="sm:w-auto"
|
className="sm:w-auto"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export interface VoiceUnresolvedItem {
|
|||||||
/** The transcript span that could not be resolved, so the clinician sees what was heard. */
|
/** The transcript span that could not be resolved, so the clinician sees what was heard. */
|
||||||
spoken: string;
|
spoken: string;
|
||||||
reason: VoiceUnresolvedReason;
|
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 {
|
export interface VoiceProsthesisResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user