feat(voice): adapt voice entry to the stacked-jobs prosthesis model

Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.

Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.

Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().

Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.

Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.

KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
  type-row lock is missing, so unticking it saves prosthesis lab rows on a
  non-prosthesis detail

Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.

Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 12:23:58 +08:00
parent 77e2ed4b42
commit 15ddb9aac2
29 changed files with 3630 additions and 524 deletions

View File

@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import {
ARCH_TOOTH_LOWER,
ARCH_TOOTH_UPPER,
applyLeafToJobs,
canStackLeaf,
catalogByCode,
effectiveChartRegion,
PARTIAL_DENTURE_CODE,
toothRegionColors,
} from './prosthesisTree';
const CATALOG: ProsthesisCatalogEntry[] = [
{
code: 'pfm_crown',
sortOrder: 1,
label: 'PFM Crown',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'monolithic_zirconia',
sortOrder: 2,
label: 'Monolithic Zirconia',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'zirconia_abutment',
sortOrder: 3,
label: 'Zirconia Abutment',
category: 'implant',
subcategory: '',
chartRegion: 'root',
stackGroup: 'implant',
},
{
code: 'screw_retained',
sortOrder: 4,
label: 'Screw Retained',
category: 'implant',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'implant',
},
{
code: 'cast_post_core',
sortOrder: 5,
label: 'Cast Post & Core',
category: 'post_core',
subcategory: '',
chartRegion: 'root',
stackGroup: 'post_core',
},
{
code: PARTIAL_DENTURE_CODE,
sortOrder: 6,
label: 'Partial Denture',
category: 'removable',
subcategory: '',
chartRegion: 'arch',
stackGroup: 'arch',
},
{
code: 'night_guard_soft',
sortOrder: 7,
label: 'Night Guard',
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
stackGroup: 'arch',
},
];
const byCode = catalogByCode(CATALOG);
describe('canStackLeaf', () => {
it('allows an implant plus a crown restoration on the same tooth', () => {
expect(canStackLeaf(['zirconia_abutment'], 'pfm_crown', byCode)).toBe(true);
});
it('refuses a post & core alongside an implant', () => {
expect(canStackLeaf(['zirconia_abutment'], 'cast_post_core', byCode)).toBe(false);
});
it('refuses an implant alongside a post & core', () => {
expect(canStackLeaf(['cast_post_core'], 'zirconia_abutment', byCode)).toBe(false);
});
it('refuses a second restoration once screw-retained already paints the crown', () => {
expect(canStackLeaf(['screw_retained'], 'pfm_crown', byCode)).toBe(false);
});
it('allows two restorations to replace each other (no illegal stack)', () => {
expect(canStackLeaf(['pfm_crown'], 'monolithic_zirconia', byCode)).toBe(true);
});
it('refuses a code the catalog does not have', () => {
expect(canStackLeaf([], 'gold_foil', byCode)).toBe(false);
});
});
describe('applyLeafToJobs', () => {
it('stacks an implant and a crown restoration on one tooth', () => {
const jobs = applyLeafToJobs(['zirconia_abutment'], 'pfm_crown', byCode);
expect(jobs.sort()).toEqual(['pfm_crown', 'zirconia_abutment'].sort());
});
it('a same-stack-group leaf replaces rather than stacking beside the old one', () => {
// PFM previewed on 13, then PFZ heard for the same tooth: the second live test this repo
// ran on real recordings — the first stack rule bug that had to be fixed.
const jobs = applyLeafToJobs(['pfm_crown'], 'monolithic_zirconia', byCode);
expect(jobs).toEqual(['monolithic_zirconia']);
});
it('leaves the jobs untouched when the stack rules refuse the leaf', () => {
const jobs = applyLeafToJobs(['zirconia_abutment'], 'cast_post_core', byCode);
expect(jobs).toEqual(['zirconia_abutment']);
});
});
describe('effectiveChartRegion', () => {
it('overrides partial_denture to crown even though its catalog chartRegion is arch', () => {
expect(effectiveChartRegion({ code: PARTIAL_DENTURE_CODE, chartRegion: 'arch' })).toBe('crown');
});
it('leaves every other code as the catalog says', () => {
expect(effectiveChartRegion({ code: 'zirconia_abutment', chartRegion: 'root' })).toBe('root');
});
});
describe('toothRegionColors', () => {
it('paints a crown-region code into crownColors only', () => {
const { crown, root } = toothRegionColors(
[{ tooth: '12', prosthesisTypeCode: 'pfm_crown' }],
CATALOG,
);
expect(crown['12']).toBeTruthy();
expect(root['12']).toBeUndefined();
});
it('paints a root-region code into rootColors only', () => {
const { crown, root } = toothRegionColors(
[{ tooth: '12', prosthesisTypeCode: 'zirconia_abutment' }],
CATALOG,
);
expect(root['12']).toBeTruthy();
expect(crown['12']).toBeUndefined();
});
it('paints both crown and root for an arch-region code on a real tooth', () => {
const { crown, root } = toothRegionColors(
[{ tooth: '12', prosthesisTypeCode: 'night_guard_soft' }],
CATALOG,
);
expect(crown['12']).toBeTruthy();
expect(root['12']).toBeTruthy();
});
it('skips jaw sentinel rows — they have no crown or root to paint', () => {
const { crown, root } = toothRegionColors(
[
{ tooth: ARCH_TOOTH_UPPER, prosthesisTypeCode: 'night_guard_soft' },
{ tooth: ARCH_TOOTH_LOWER, prosthesisTypeCode: 'night_guard_soft' },
],
CATALOG,
);
expect(Object.keys(crown)).toHaveLength(0);
expect(Object.keys(root)).toHaveLength(0);
});
});

View File

@@ -0,0 +1,274 @@
import { describe, expect, it } from 'vitest';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { VoiceExtractionResult } from '@/types/voice';
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from './prosthesisTree';
import {
countSelected,
hasAnythingToApply,
initialVoiceSelection,
isLabDependentResult,
joblessProsthesisTargets,
prosthesisChartData,
prosthesisTargetLines,
voiceRowAvailability,
withChosenArch,
withChosenProsthesisLeaf,
withChosenTeeth,
} from './voiceReviewRows';
const CATALOG: ProsthesisCatalogEntry[] = [
{
code: 'pfm_crown',
sortOrder: 1,
label: 'PFM Crown',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'zirconia_abutment',
sortOrder: 2,
label: 'Zirconia Abutment',
category: 'implant',
subcategory: '',
chartRegion: 'root',
stackGroup: 'implant',
},
{
code: 'cast_post_core',
sortOrder: 3,
label: 'Cast Post & Core',
category: 'post_core',
subcategory: '',
chartRegion: 'root',
stackGroup: 'post_core',
},
{
code: 'night_guard_soft',
sortOrder: 4,
label: 'Night Guard',
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
stackGroup: 'arch',
},
];
const LAB_DEPENDENT = new Set(['prosthesis']);
function baseResult(overrides: Partial<VoiceExtractionResult> = {}): VoiceExtractionResult {
return {
transcript: '',
treatmentType: 'restoration',
teeth: [],
toothSelectionGroups: [],
comment: null,
prosthesisAssignments: [],
labId: null,
labMatchExact: false,
dueDate: null,
unresolved: [],
...overrides,
};
}
describe('isLabDependentResult', () => {
it('is true only when the resolved type is in the labDependent set', () => {
expect(isLabDependentResult(baseResult({ treatmentType: 'prosthesis' }), LAB_DEPENDENT)).toBe(
true,
);
expect(isLabDependentResult(baseResult({ treatmentType: 'restoration' }), LAB_DEPENDENT)).toBe(
false,
);
});
});
describe('voiceRowAvailability', () => {
it('shows a plain teeth row for a non-lab-dependent type', () => {
const result = baseResult({ teeth: ['14'] });
const available = voiceRowAvailability(result, LAB_DEPENDENT);
expect(available.teeth).toBe(true);
expect(available.prosthesis).toBe(false);
});
it('merges teeth and prosthesis into one row for a lab-dependent type', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: ['12'], types: ['pfm_crown'], spoken: '' }],
});
const available = voiceRowAvailability(result, LAB_DEPENDENT);
expect(available.teeth).toBe(false);
expect(available.prosthesis).toBe(true);
});
it('shows the merged row for a jaw appliance with no teeth at all', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [
{ targets: [ARCH_TOOTH_UPPER], types: ['night_guard_soft'], spoken: '' },
],
});
expect(voiceRowAvailability(result, LAB_DEPENDENT).prosthesis).toBe(true);
});
});
describe('initialVoiceSelection', () => {
it('ticks an available lab-dependent prosthesis row even when the stack is incomplete', () => {
// All-or-nothing is retired (decision 40) — an incomplete map no longer blocks a tick.
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: ['12'], types: [], spoken: '' }],
});
expect(initialVoiceSelection(result, LAB_DEPENDENT).prosthesis).toBe(true);
});
it('never ticks lab when the match was inexact', () => {
const result = baseResult({ labId: 'lab-1', labMatchExact: false });
expect(initialVoiceSelection(result, LAB_DEPENDENT).lab).toBe(false);
});
});
describe('countSelected', () => {
it('intersects the selection with availability rather than counting raw ticks', () => {
const selection = { treatmentType: true, teeth: true, comment: true, prosthesis: true, lab: true, dueDate: true };
const available = { treatmentType: true, teeth: false, comment: true, prosthesis: false, lab: true, dueDate: false };
expect(countSelected(selection, available)).toBe(3);
});
});
describe('withChosenTeeth', () => {
it('folds a plain candidate into the top-level teeth list', () => {
const result = baseResult({ teeth: ['14'] });
const next = withChosenTeeth(result, ['26']);
expect(next.teeth).toEqual(['14', '26']);
});
it('folds an assignment-scoped candidate into that assignment target list, not the plain list', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: [], types: ['pfm_crown'], spoken: 'دندون دو روکش' }],
});
const next = withChosenTeeth(result, ['12'], 0);
expect(next.prosthesisAssignments[0].targets).toEqual(['12']);
expect(next.teeth).toEqual([]);
});
});
describe('withChosenArch', () => {
it('folds a picked jaw into the assignment that named no jaw at all', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: [], types: ['night_guard_soft'], spoken: 'نایت گارد' }],
});
const next = withChosenArch(result, 0, 'upper');
expect(next.prosthesisAssignments[0].targets).toEqual([ARCH_TOOTH_UPPER]);
});
it('picking both jaws is how a both-jaw appliance is expressed', () => {
let next = withChosenArch(
baseResult({ prosthesisAssignments: [{ targets: [], types: [], spoken: '' }] }),
0,
'upper',
);
next = withChosenArch(next, 0, 'lower');
expect(next.prosthesisAssignments[0].targets.sort()).toEqual(
[ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER].sort(),
);
});
});
describe('withChosenProsthesisLeaf', () => {
it('supplies the missing leaf to the assignment that only named a category', () => {
const result = baseResult({
prosthesisAssignments: [{ targets: ['12'], types: [], spoken: 'روکش' }],
});
const next = withChosenProsthesisLeaf(result, 0, 'pfm_crown');
expect(next.prosthesisAssignments[0].types).toEqual(['pfm_crown']);
// A resolved assignment forces the type — the row locks exactly as the backend does.
expect(next.treatmentType).toBe('prosthesis');
});
});
describe('prosthesisTargetLines', () => {
it('previews the stack that will actually land, through applyLeafToJobs', () => {
const lines = prosthesisTargetLines(
[{ targets: ['12'], types: ['zirconia_abutment', 'pfm_crown'], spoken: '' }],
CATALOG,
);
expect(lines).toEqual([{ target: '12', isJaw: false, applied: ['zirconia_abutment', 'pfm_crown'], refused: [] }]);
});
it('names a refused job rather than silently dropping or silently applying it', () => {
const lines = prosthesisTargetLines(
[{ targets: ['12'], types: ['zirconia_abutment', 'cast_post_core'], spoken: '' }],
CATALOG,
);
expect(lines[0].applied).toEqual(['zirconia_abutment']);
expect(lines[0].refused).toEqual(['cast_post_core']);
});
it('marks a jaw target as such', () => {
const lines = prosthesisTargetLines(
[{ targets: [ARCH_TOOTH_UPPER], types: ['night_guard_soft'], spoken: '' }],
CATALOG,
);
expect(lines[0].isJaw).toBe(true);
});
});
describe('joblessProsthesisTargets', () => {
it('names a tooth left in the plain teeth list with no matching assignment', () => {
const result = baseResult({
teeth: ['12', '13'],
prosthesisAssignments: [{ targets: ['12'], types: ['pfm_crown'], spoken: '' }],
});
expect(joblessProsthesisTargets(result)).toEqual(['13']);
});
it('names an assignment target whose types were empty from the start', () => {
const result = baseResult({
prosthesisAssignments: [{ targets: ['13'], types: [], spoken: '' }],
});
expect(joblessProsthesisTargets(result)).toEqual(['13']);
});
it('does not call a target jobless while it is pending a material pick', () => {
const result = baseResult({
prosthesisAssignments: [{ targets: ['13'], types: [], spoken: 'روکش' }],
unresolved: [{ spoken: 'روکش', reason: 'prosthesis_type_ambiguous', assignmentIndex: 0 }],
});
expect(joblessProsthesisTargets(result)).toEqual([]);
});
});
describe('prosthesisChartData', () => {
it('derives the arch highlight from applied jaw jobs, upper and lower alike', () => {
const lines = prosthesisTargetLines(
[{ targets: [ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER], types: ['night_guard_soft'], spoken: '' }],
CATALOG,
);
const data = prosthesisChartData(lines, [], CATALOG);
expect(data.archHighlight).toBe('both');
expect(data.selectedTeeth.size).toBe(0);
});
it('collects every real tooth, applied or jobless, into selectedTeeth', () => {
const lines = prosthesisTargetLines(
[{ targets: ['12'], types: ['pfm_crown'], spoken: '' }],
CATALOG,
);
const data = prosthesisChartData(lines, ['13'], CATALOG);
expect([...data.selectedTeeth].sort()).toEqual(['12', '13']);
});
});
describe('hasAnythingToApply', () => {
it('is false when the recording produced nothing usable', () => {
expect(hasAnythingToApply(baseResult({ treatmentType: null }), LAB_DEPENDENT)).toBe(false);
});
it('is true once any row is available', () => {
expect(hasAnythingToApply(baseResult({ teeth: ['14'] }), LAB_DEPENDENT)).toBe(true);
});
});

View File

@@ -1,35 +1,73 @@
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
import {
ARCH_TOOTH_LOWER,
ARCH_TOOTH_UPPER,
applyLeafToJobs,
archSentinels,
canStackLeaf,
catalogByCode,
isArchSentinel,
toothRegionColors,
type ArchTarget,
} from '@/components/treatment/prosthesisTree';
import type { FdiToothId } from '@/types/treatment';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type {
VoiceApplySelection,
VoiceExtractionResult,
VoiceProsthesisResult,
VoiceProsthesisAssignment,
VoiceUnresolvedItem,
} from '@/types/voice';
/** Which rows the review sheet renders at all — a row with nothing extracted is noise. */
export function voiceRowAvailability(result: VoiceExtractionResult) {
/** `prosthesis` is the app's only labDependent treatment type today, but this stays generic. */
export function isLabDependentResult(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
): boolean {
return Boolean(result.treatmentType && labDependentCodes.has(result.treatmentType));
}
function hasProsthesisWork(result: VoiceExtractionResult): boolean {
return result.prosthesisAssignments.some((a) => a.targets.length > 0 || a.types.length > 0);
}
/**
* Which rows the review sheet renders at all — a row with nothing extracted is noise.
*
* Teeth and prosthesis are never both available: a lab-dependent type merges them into one
* `prosthesis` row (decision 39), because two independent ticks can save an empty detail —
* `persistDraft` prunes a lab-dependent detail to its jobs.
*/
export function voiceRowAvailability(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
) {
const labDependent = isLabDependentResult(result, labDependentCodes);
return {
treatmentType: result.treatmentType != null,
teeth: result.teeth.length > 0,
teeth: !labDependent && result.teeth.length > 0,
prosthesis: labDependent && (result.teeth.length > 0 || hasProsthesisWork(result)),
comment: Boolean(result.comment?.trim()),
prosthesis: result.prosthesis != null,
lab: result.labId != null,
dueDate: result.dueDate != null,
};
}
/**
* Everything available ticks itself, with two exceptions: an inexactly-matched lab, because
* it is the one extracted value whose error leaves the building; and an incomplete
* prosthesis map, which cannot ship at all and would just move the failure to dispatch.
* Everything available ticks itself, with one exception: an inexactly-matched lab, because it
* is the one extracted value whose error leaves the building. All-or-nothing prosthesis maps
* are retired (decision 40) — an incomplete stack no longer blocks a tick.
*/
export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApplySelection {
const available = voiceRowAvailability(result);
export function initialVoiceSelection(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
): VoiceApplySelection {
const available = voiceRowAvailability(result, labDependentCodes);
return {
treatmentType: available.treatmentType,
teeth: available.teeth,
comment: available.comment,
prosthesis: available.prosthesis && result.prosthesis?.complete === true,
prosthesis: available.prosthesis,
lab: available.lab && result.labMatchExact,
dueDate: available.dueDate,
};
@@ -48,37 +86,75 @@ export function countSelected(
).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 };
/** Every resolved assignment target, across every assignment. */
function allAssignmentTargets(result: VoiceExtractionResult): Set<string> {
return new Set(result.prosthesisAssignments.flatMap((a) => a.targets));
}
/** Forces treatmentType to `prosthesis` the moment any assignment carries a real target. */
function withProsthesisForced(result: VoiceExtractionResult): VoiceExtractionResult {
const forced = result.prosthesisAssignments.some((a) => a.targets.length > 0);
return forced ? { ...result, treatmentType: 'prosthesis' } : result;
}
/**
* Fold the candidate picks into the result, so nothing downstream has to know chips exist.
* Fold a picked FDI/quadrant candidate into the result.
*
* `assignmentIndex` set: the chip came from resolving a `prosthesisAssignments` entry, so the
* pick becomes that assignment's target — inheriting its `types`, not a jobless tooth
* (decision 50). `assignmentIndex` absent: the chip came from the plain `teeth` list and folds
* in there, exactly as before.
*
* Union rather than toggle: a candidate can coincidentally be a tooth the recording already
* produced ("۱۲ و دو"), and tapping it must not deselect that one.
* produced, and tapping it must not deselect that one.
*/
export function withChosenTeeth(
result: VoiceExtractionResult,
chosen: readonly FdiToothId[],
assignmentIndex?: number,
): VoiceExtractionResult {
if (chosen.length === 0) return result;
const teeth = [...new Set([...result.teeth, ...chosen])].sort() as FdiToothId[];
if (assignmentIndex != null) {
const assignments = result.prosthesisAssignments.map((a, i) =>
i === assignmentIndex ? { ...a, targets: [...new Set([...a.targets, ...chosen])] } : a,
);
return withProsthesisForced({ ...result, prosthesisAssignments: assignments });
}
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,
};
}
/** Fold a picked jaw ('upper' / 'lower') into the assignment that named no jaw at all. */
export function withChosenArch(
result: VoiceExtractionResult,
assignmentIndex: number,
arch: 'upper' | 'lower',
): VoiceExtractionResult {
const sentinel = arch === 'upper' ? ARCH_TOOTH_UPPER : ARCH_TOOTH_LOWER;
const assignments = result.prosthesisAssignments.map((a, i) =>
i === assignmentIndex ? { ...a, targets: [...new Set([...a.targets, sentinel])] } : a,
);
return withProsthesisForced({ ...result, prosthesisAssignments: assignments });
}
/** Fold a picked leaf into the assignment that only named a category or subcategory. */
export function withChosenProsthesisLeaf(
result: VoiceExtractionResult,
assignmentIndex: number,
leafCode: string,
): VoiceExtractionResult {
const assignments = result.prosthesisAssignments.map((a, i) =>
i === assignmentIndex ? { ...a, types: [...new Set([...a.types, leafCode])] } : a,
);
return withProsthesisForced({ ...result, prosthesisAssignments: assignments });
}
/** Teeth that are part of a bridge, for the read-only chart's connection marks. */
export function connectedTeethFromResult(result: VoiceExtractionResult): Set<FdiToothId> {
const connected = new Set<FdiToothId>();
@@ -89,7 +165,138 @@ export function connectedTeethFromResult(result: VoiceExtractionResult): Set<Fdi
return connected;
}
/** A recording that produced nothing should say so, not show an empty form of checkboxes. */
export function hasAnythingToApply(result: VoiceExtractionResult): boolean {
return Object.values(voiceRowAvailability(result)).some(Boolean);
export type VoiceProsthesisTargetLine = {
target: string;
isJaw: boolean;
/** Leaf codes that will actually land, in landing order. */
applied: string[];
/** Leaf codes the stack rules refused — shown struck through, never silently dropped. */
refused: string[];
};
/**
* The stack that will actually land, built through `applyLeafToJobs` — the same function the
* manual chart writes through (§7). A code the stack rules refuse (an implant plus a post &
* core on one tooth) is named as refused rather than silently dropped or silently applied.
*/
export function prosthesisTargetLines(
assignments: readonly VoiceProsthesisAssignment[],
catalog: readonly ProsthesisCatalogEntry[],
): VoiceProsthesisTargetLine[] {
const byCode = catalogByCode(catalog);
const byTarget = new Map<string, { applied: string[]; refused: string[] }>();
for (const assignment of assignments) {
for (const target of assignment.targets) {
const entry = byTarget.get(target) ?? { applied: [], refused: [] };
for (const code of assignment.types) {
if (canStackLeaf(entry.applied, code, byCode)) {
entry.applied = applyLeafToJobs(entry.applied, code, byCode);
} else {
entry.refused.push(code);
}
}
byTarget.set(target, entry);
}
}
return [...byTarget.entries()].map(([target, { applied, refused }]) => ({
target,
isJaw: isArchSentinel(target),
applied,
refused,
}));
}
/**
* FDI codes / jaw sentinels named somewhere (an assignment target, or the plain `teeth` list)
* but ending up with no job at all — struck through in the sheet reading "no prosthesis heard"
* (decision 40). A target still pending a material pick (a `prosthesis_type_ambiguous` chip
* for its assignment) is not jobless; it is simply not resolved yet.
*/
export function joblessProsthesisTargets(result: VoiceExtractionResult): string[] {
const pendingIndexes = new Set(
result.unresolved
.filter((u) => u.reason === 'prosthesis_type_ambiguous' && u.assignmentIndex != null)
.map((u) => u.assignmentIndex as number),
);
const jobless = new Set<string>();
const covered = new Set<string>();
const pending = new Set<string>();
result.prosthesisAssignments.forEach((assignment, index) => {
if (pendingIndexes.has(index)) {
for (const target of assignment.targets) pending.add(target);
} else if (assignment.types.length === 0) {
for (const target of assignment.targets) jobless.add(target);
} else {
for (const target of assignment.targets) covered.add(target);
}
});
for (const tooth of result.teeth) {
if (!covered.has(tooth) && !pending.has(tooth)) jobless.add(tooth);
}
return [...jobless];
}
export type VoiceProsthesisChartData = {
crownColors: Partial<Record<FdiToothId, string>>;
rootColors: Partial<Record<FdiToothId, string>>;
archHighlight: ArchTarget | null;
/** Every real tooth involved — a target with a job, or a jobless one named alongside it. */
selectedTeeth: Set<FdiToothId>;
};
/** Feeds the merged row's `FdiToothChart` — crown/root tints plus the arch highlight. */
export function prosthesisChartData(
lines: readonly VoiceProsthesisTargetLine[],
jobless: readonly string[],
catalog: readonly ProsthesisCatalogEntry[],
): VoiceProsthesisChartData {
const rows = lines.flatMap((line) =>
line.applied.map((code) => ({ tooth: line.target, prosthesisTypeCode: code })),
);
const { crown, root } = toothRegionColors(rows, catalog);
const hasUpper = lines.some((l) => l.target === ARCH_TOOTH_UPPER && l.applied.length > 0);
const hasLower = lines.some((l) => l.target === ARCH_TOOTH_LOWER && l.applied.length > 0);
const archHighlight: ArchTarget | null =
hasUpper && hasLower ? 'both' : hasUpper ? 'upper' : hasLower ? 'lower' : null;
const selectedTeeth = new Set<FdiToothId>();
for (const line of lines) {
if (!line.isJaw) selectedTeeth.add(line.target as FdiToothId);
}
for (const tooth of jobless) {
if (!isArchSentinel(tooth)) selectedTeeth.add(tooth as FdiToothId);
}
return { crownColors: crown, rootColors: root, archHighlight, selectedTeeth };
}
/** Which unresolved items belong to which chip section — the sheet renders both identically. */
export function unresolvedWithoutAssignment(
result: VoiceExtractionResult,
): VoiceUnresolvedItem[] {
return result.unresolved.filter((u) => u.assignmentIndex == null);
}
export function unresolvedForAssignment(
result: VoiceExtractionResult,
assignmentIndex: number,
): VoiceUnresolvedItem[] {
return result.unresolved.filter((u) => u.assignmentIndex === assignmentIndex);
}
/** A recording that produced nothing should say so, not show an empty form of checkboxes. */
export function hasAnythingToApply(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
): boolean {
return Object.values(voiceRowAvailability(result, labDependentCodes)).some(Boolean);
}
export { archSentinels, allAssignmentTargets };

View File

@@ -31,6 +31,7 @@ import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { voiceApi } from '@/lib/api/voice';
import { useVoiceCapture } from '@/lib/voice/useVoiceCapture';
import { isMediaRecorderSupported } from '@/lib/voice/audioFormat';
import { VoiceReviewSheet } from '@/components/ui/treatment/VoiceReviewSheet';
import type {
VoiceApplySelection,
@@ -61,6 +62,10 @@ import {
unlinkAdjacentTeeth,
} from '@/components/treatment/toothSelectionGroups';
import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree';
import {
isLabDependentResult,
prosthesisTargetLines,
} from '@/components/treatment/voiceReviewRows';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import {
@@ -621,9 +626,18 @@ export function TreatmentWorkspace({
onError: (error) => showError(getUserFacingError(error, tErrors, t('voiceFailed'))),
});
/** Absence is the unavailable state — the Add button then renders unsplit. */
/**
* Absence is the unavailable state — the Add button then renders unsplit. Gated on both the
* server's availability response AND the browser's own recording support: without the
* latter check the control rendered on a browser that cannot record and failed on tap
* (the Safari report that started this revision, §2).
*/
const voiceForEditor =
voiceAvailability?.enabled && voiceAvailability.locales.includes(locale) ? voice : undefined;
voiceAvailability?.enabled &&
voiceAvailability.locales.includes(locale) &&
isMediaRecorderSupported()
? voice
: undefined;
const selectedStandalone = useMemo(
() => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null,
@@ -2192,7 +2206,13 @@ export function TreatmentWorkspace({
if (selection.treatmentType && result.treatmentType) {
detail.treatmentType = result.treatmentType;
}
if (selection.teeth) {
// Teeth and prosthesis are one row for a lab-dependent type (decision 39) — ticking
// "teeth" independently of "prosthesis" could save an empty detail, since
// `persistDraft` prunes a lab-dependent detail down to its jobs. `selection.prosthesis`
// alone drives both below; `selection.teeth` only ever applies to the plain row.
const labDependent = isLabDependentResult(result, labDependentCodes);
if (!labDependent && selection.teeth) {
detail.teeth = [...result.teeth];
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
...group,
@@ -2203,6 +2223,25 @@ export function TreatmentWorkspace({
detail.comment = result.comment;
}
// The stack that will actually land, built through the same `applyLeafToJobs` the
// manual chart writes through (§7) — a code the stack rules refuse is not applied.
const prosthesisLines =
labDependent && selection.prosthesis
? prosthesisTargetLines(result.prosthesisAssignments, prosthesisCatalog)
: [];
// An assignment target is a selection: a tooth exists on a prosthesis detail only by
// carrying a job (decision 40) — never from the plain `teeth` field.
const prosthesisTeeth = prosthesisLines
.filter((line) => !line.isJaw && line.applied.length > 0)
.map((line) => line.target as FdiToothId);
if (labDependent && selection.prosthesis) {
detail.teeth = prosthesisTeeth;
detail.toothSelectionGroups = groupsFromFlatTeeth(
prosthesisTeeth,
result.toothSelectionGroups,
);
}
const nextDetails = [...detailsRef.current, detail];
setDetails(nextDetails);
// persistDraft reads detailsRef, and setDetails has not rendered yet.
@@ -2212,7 +2251,7 @@ export function TreatmentWorkspace({
// Lab-side rows ride on a lab case draft keyed by the detail's *client* id, so a
// brand-new unsaved detail can still carry one; it is persisted after the detail is.
const wantsLabDraft =
(selection.prosthesis && result.prosthesis) ||
prosthesisLines.some((line) => line.applied.length > 0) ||
(selection.lab && result.labId) ||
(selection.dueDate && result.dueDate);
@@ -2225,26 +2264,19 @@ export function TreatmentWorkspace({
if (selection.dueDate && result.dueDate) {
draft.dueDate = result.dueDate;
}
if (selection.prosthesis && result.prosthesis) {
// byTooth keys are plain strings; the group's teeth are FdiToothId.
const groupOf = (tooth: string) =>
result.toothSelectionGroups.find((group) =>
(group.teeth as readonly string[]).includes(tooth),
)?.groupId ?? '';
// Only teeth that actually landed on the detail. Unticking "teeth" while
// leaving "prosthesis" ticked would otherwise attach prosthesis rows for teeth
// the treatment does not contain — nothing downstream filters them, and they
// would reach task generation as work for teeth nobody is treating.
const detailTeeth = new Set<string>(detail.teeth);
draft.toothProsthesis = Object.entries(result.prosthesis.byTooth)
.filter(([tooth]) => detailTeeth.has(tooth))
.map(([tooth, prosthesisTypeCode]) => ({
detailClientId: detail.clientId,
tooth,
prosthesisTypeCode,
selectionGroupId: groupOf(tooth),
}));
}
draft.toothProsthesis = prosthesisLines.flatMap((line) => {
const groupId = line.isJaw
? ''
: (detail.toothSelectionGroups.find((group) =>
(group.teeth as readonly string[]).includes(line.target),
)?.groupId ?? '');
return line.applied.map((prosthesisTypeCode) => ({
detailClientId: detail.clientId,
tooth: line.target,
prosthesisTypeCode,
selectionGroupId: groupId,
}));
});
const updatedLabCases = [...labCaseDrafts, draft];
setLabCaseDrafts(updatedLabCases);
@@ -2269,8 +2301,10 @@ export function TreatmentWorkspace({
},
[
labCaseDrafts,
labDependentCodes,
persistDraft,
persistLabCases,
prosthesisCatalog,
selectedAppointment?.purpose,
showError,
t,
@@ -3202,6 +3236,7 @@ export function TreatmentWorkspace({
result={voiceResult}
treatmentCatalog={treatmentCatalog}
prosthesisCatalog={prosthesisCatalog}
labDependentCodes={labDependentCodes}
labs={orgs}
onApply={(selection, applied) => applyVoiceResult(applied, selection)}
onDiscard={() => setVoiceResult(null)}

View File

@@ -10,31 +10,44 @@ import {
ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '@/components/treatment/prosthesisTree';
import {
connectedTeethFromResult,
countSelected,
hasAnythingToApply,
initialVoiceSelection,
joblessProsthesisTargets,
prosthesisChartData,
prosthesisTargetLines,
voiceRowAvailability,
withChosenArch,
withChosenProsthesisLeaf,
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 { FdiToothId, LinkedOrganizationOption } from '@/types/treatment';
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
import type {
VoiceApplySelection,
VoiceExtractionResult,
VoiceUnresolvedItem,
} from '@/types/voice';
interface VoiceReviewSheetProps {
result: VoiceExtractionResult;
treatmentCatalog: TreatmentCatalogEntry[];
prosthesisCatalog: ProsthesisCatalogEntry[];
/** `prosthesis` today, but kept generic — the same set the workspace already tracks. */
labDependentCodes: ReadonlySet<string>;
labs: LinkedOrganizationOption[];
/** The result is handed back because the sheet may have added teeth the model missed. */
/** The result is handed back because the sheet may have added teeth or jobs the model missed. */
onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => void;
onDiscard: () => void;
}
type ArchPick = 'upper' | 'lower';
/**
* Confirmation step between the model's output and the form.
*
@@ -45,50 +58,131 @@ export function VoiceReviewSheet({
result,
treatmentCatalog,
prosthesisCatalog,
labDependentCodes,
labs,
onApply,
onDiscard,
}: VoiceReviewSheetProps) {
const t = useTranslations('treatment');
const locale = useLocale();
const { formatDate } = useAppFormatters();
const [selection, setSelection] = useState<VoiceApplySelection>(() =>
initialVoiceSelection(result),
initialVoiceSelection(result, labDependentCodes),
);
const [chosen, setChosen] = useState<FdiToothId[]>([]);
// 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]);
// Every kind of candidate chip the sheet can offer, tracked separately because each folds
// into the result a different way (decision 50). Toggling off removes only the clinician's
// own pick — nothing the recording already produced is ever un-added.
const [chosenTeeth, setChosenTeeth] = useState<FdiToothId[]>([]);
const [chosenAssignmentTeeth, setChosenAssignmentTeeth] = useState<Record<number, FdiToothId[]>>(
{},
);
const [chosenArches, setChosenArches] = useState<Record<number, ArchPick[]>>({});
const [chosenLeaves, setChosenLeaves] = useState<Record<number, string[]>>({});
const available = useMemo(() => voiceRowAvailability(effective), [effective]);
// Everything below renders from `effective`, never from `result` — a candidate picked from
// the chips has to reach the rows, the chart and the apply count alike.
const effective = useMemo(() => {
let next = withChosenTeeth(result, chosenTeeth);
for (const [index, teeth] of Object.entries(chosenAssignmentTeeth)) {
next = withChosenTeeth(next, teeth, Number(index));
}
for (const [index, arches] of Object.entries(chosenArches)) {
for (const arch of arches) next = withChosenArch(next, Number(index), arch);
}
for (const [index, leaves] of Object.entries(chosenLeaves)) {
for (const leaf of leaves) next = withChosenProsthesisLeaf(next, Number(index), leaf);
}
return next;
}, [result, chosenTeeth, chosenAssignmentTeeth, chosenArches, chosenLeaves]);
const available = useMemo(
() => voiceRowAvailability(effective, labDependentCodes),
[effective, labDependentCodes],
);
const connectedTeeth = useMemo(() => connectedTeethFromResult(effective), [effective]);
const selectedTeeth = useMemo(() => new Set(effective.teeth), [effective.teeth]);
const nothingToApply = !hasAnythingToApply(effective);
const prosthesisLines = useMemo(
() => prosthesisTargetLines(effective.prosthesisAssignments, prosthesisCatalog),
[effective, prosthesisCatalog],
);
const joblessTargets = useMemo(() => joblessProsthesisTargets(effective), [effective]);
const chartData = useMemo(
() => prosthesisChartData(prosthesisLines, joblessTargets, prosthesisCatalog),
[prosthesisLines, joblessTargets, prosthesisCatalog],
);
const nothingToApply = !hasAnythingToApply(effective, labDependentCodes);
const selectedCount = countSelected(selection, available);
const pickCandidate = (tooth: FdiToothId) => {
const nextChosen = chosen.includes(tooth)
? chosen.filter((t) => t !== tooth)
: [...chosen, tooth];
setChosen(nextChosen);
setSelection((prev) => ({
...prev,
// The teeth row starts unticked whenever the recording produced no teeth of its own,
// and a picked tooth that is not ticked applies nothing.
teeth: true,
// A picked tooth has no prosthesis type, so the map is no longer shippable — leaving the
// row ticked would apply a map dispatch rejects. Only ever unticks; re-ticking is the
// clinician's call.
prosthesis:
prev.prosthesis &&
withChosenTeeth(result, nextChosen).prosthesis?.complete !== false,
}));
const targetLabel = (target: string): string => {
if (target === ARCH_TOOTH_UPPER) return t('upperArch');
if (target === ARCH_TOOTH_LOWER) return t('lowerArch');
return target;
};
const labelFor = (code: string | null, catalog: { code: string; label: string }[]) =>
catalog.find((entry) => entry.code === code)?.label ?? code ?? '';
const isPicked = (item: VoiceUnresolvedItem, code: string): boolean => {
const index = item.assignmentIndex;
if (item.reason === 'arch_not_spoken') {
return index != null && (chosenArches[index] ?? []).includes(code as ArchPick);
}
if (item.reason === 'prosthesis_type_ambiguous') {
return index != null && (chosenLeaves[index] ?? []).includes(code);
}
if (index != null) return (chosenAssignmentTeeth[index] ?? []).includes(code as FdiToothId);
return chosenTeeth.includes(code as FdiToothId);
};
const pickCandidate = (item: VoiceUnresolvedItem, code: string) => {
const index = item.assignmentIndex;
if (item.reason === 'arch_not_spoken' && index != null) {
setChosenArches((prev) => {
const cur = prev[index] ?? [];
const arch = code as ArchPick;
return {
...prev,
[index]: cur.includes(arch) ? cur.filter((a) => a !== arch) : [...cur, arch],
};
});
} else if (item.reason === 'prosthesis_type_ambiguous' && index != null) {
setChosenLeaves((prev) => {
const cur = prev[index] ?? [];
return { ...prev, [index]: cur.includes(code) ? cur.filter((l) => l !== code) : [...cur, code] };
});
} else if (index != null) {
setChosenAssignmentTeeth((prev) => {
const cur = prev[index] ?? [];
const tooth = code as FdiToothId;
return {
...prev,
[index]: cur.includes(tooth) ? cur.filter((t2) => t2 !== tooth) : [...cur, tooth],
};
});
} else {
const tooth = code as FdiToothId;
setChosenTeeth((prev) => (prev.includes(tooth) ? prev.filter((t2) => t2 !== tooth) : [...prev, tooth]));
}
// A picked candidate has no meaning unless its row is ticked.
setSelection((prev) => ({
...prev,
teeth: prev.teeth || available.teeth,
prosthesis: true,
}));
};
const candidateLabel = (item: VoiceUnresolvedItem, code: string): string => {
if (item.reason === 'arch_not_spoken') return code === 'upper' ? t('upperArch') : t('lowerArch');
if (item.reason === 'prosthesis_type_ambiguous') return labelFor(code, prosthesisCatalog);
return code;
};
const promptFor = (item: VoiceUnresolvedItem): string | null => {
if (item.reason === 'arch_not_spoken') return t('voicePickJaw');
if (item.reason === 'tooth_missing_quadrant') return t('voicePickTooth');
return null;
};
const toggle = (key: keyof VoiceApplySelection) => (checked: boolean) =>
setSelection((prev) => ({ ...prev, [key]: checked }));
@@ -138,6 +232,59 @@ export function VoiceReviewSheet({
</Row>
) : null}
{available.prosthesis ? (
<Row
label={t('voiceTeethAndProsthesis')}
checked={selection.prosthesis}
onChange={toggle('prosthesis')}
>
<div className="mt-1">
<FdiToothChart
readOnly
compact
scale={0.55}
selected={chartData.selectedTeeth}
crownColors={chartData.crownColors}
rootColors={chartData.rootColors}
archHighlight={chartData.archHighlight}
/>
</div>
<p className="mt-1 text-sm text-text-primary">
{prosthesisLines.map((line, i) => (
<span key={`line-${line.target}`}>
{i > 0 ? ' · ' : ''}
{targetLabel(line.target)}:{' '}
{line.applied.map((code, j) => (
<span key={code}>
{j > 0 ? ' + ' : ''}
{labelFor(code, prosthesisCatalog)}
</span>
))}
{line.refused.map((code) => (
<span
key={code}
title={t('voiceStackRefused')}
className="text-text-muted line-through"
>
{' + '}
{labelFor(code, prosthesisCatalog)}
</span>
))}
</span>
))}
{joblessTargets.map((target, i) => (
<span
key={`jobless-${target}`}
className="text-text-muted line-through"
>
{prosthesisLines.length > 0 || i > 0 ? ' · ' : ''}
{targetLabel(target)}: {t('voiceNoProsthesisHeard')}
</span>
))}
</p>
</Row>
) : null}
{available.comment ? (
<Row
label={t('comments')}
@@ -150,29 +297,6 @@ export function VoiceReviewSheet({
</Row>
) : null}
{available.prosthesis && effective.prosthesis ? (
<Row
label={t('prosthesisColType')}
checked={selection.prosthesis}
onChange={toggle('prosthesis')}
warning={
effective.prosthesis.complete
? undefined
: t('voiceProsthesisIncomplete', {
teeth: formatToothList(effective.prosthesis.missingTeeth, locale),
})
}
>
<span className="text-sm text-text-primary">
{Object.entries(effective.prosthesis.byTooth)
.map(
([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`,
)
.join(' · ')}
</span>
</Row>
) : null}
{available.lab ? (
<Row
label={t('entryStepLab')}
@@ -212,23 +336,29 @@ export function VoiceReviewSheet({
{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);
{promptFor(item) ? (
<span className="text-text-muted">{promptFor(item)}</span>
) : null}
{item.candidates.map((code) => {
const picked = isPicked(item, code);
return (
<button
key={tooth}
key={code}
type="button"
aria-pressed={picked}
aria-label={t('toothAria', { fdi: tooth })}
onClick={() => pickCandidate(tooth as FdiToothId)}
aria-label={
item.reason === 'tooth_missing_quadrant'
? t('toothAria', { fdi: code })
: candidateLabel(item, code)
}
onClick={() => pickCandidate(item, code)}
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}
{candidateLabel(item, code)}
</button>
);
})}
@@ -295,12 +425,3 @@ function civilDateToLocalDate(iso: string): Date {
const [year, month, day] = iso.split('-').map(Number);
return new Date(year, (month ?? 1) - 1, day ?? 1);
}
/** Locale-aware list separator — the Arabic comma is not correct in en or nl. */
function formatToothList(teeth: readonly string[], locale: string): string {
try {
return new Intl.ListFormat(locale, { style: 'short', type: 'unit' }).format([...teeth]);
} catch {
return teeth.join(', ');
}
}

View File

@@ -22,7 +22,13 @@ export function pickRecordingMimeType(): string | null {
for (const type of PREFERRED_MIME_TYPES) {
if (MediaRecorder.isTypeSupported(type)) return type;
}
return null;
// None of the preferred containers passed `isTypeSupported` — a Safari version whose check
// exists but answers false for a container it can still record (e.g. plain `audio/mp4`).
// The preference list is not a requirement: fall back to the "let the browser choose" hint
// rather than refusing outright. `onstop` derives the real container from
// `recorder.mimeType`, so this is only wrong when the browser genuinely cannot record at
// all — and `new MediaRecorder()` / `recorder.start()` throwing is handled at the call site.
return '';
}
/** `audio/webm;codecs=opus` → `webm`, which is what the API's `format` field wants. */

View File

@@ -6,10 +6,12 @@ export type VoiceUnresolvedReason =
| 'not_permanent_tooth'
| 'position_out_of_range'
| 'tooth_missing_quadrant'
| 'prosthesis_type_ambiguous'
| 'arch_not_spoken'
| 'code_not_valid_for_target'
| 'malformed'
| 'span_not_same_arch'
| 'unknown_catalog_code'
| 'tooth_not_selected'
| 'invalid_date';
export interface VoiceUnresolvedItem {
@@ -17,17 +19,30 @@ export interface VoiceUnresolvedItem {
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.
* Values still consistent with what was heard, when a choice would settle it — the review
* sheet offers them as chips. FDI codes for `tooth_missing_quadrant`, leaf codes for
* `prosthesis_type_ambiguous`, `'upper'`/`'lower'` for `arch_not_spoken`.
*/
candidates?: string[];
/**
* Set only when this item came from resolving a `prosthesisAssignments` entry. A picked
* chip then inherits that assignment's `types` (or supplies the missing leaf to it) instead
* of resolving to a jobless tooth.
*/
assignmentIndex?: number;
}
export interface VoiceProsthesisResult {
byTooth: Record<string, string>;
/** False means the case cannot ship — every tooth needs a prosthesis type. */
complete: boolean;
missingTeeth: FdiToothId[];
/**
* One spoken instruction, resolved: these targets — FDI codes, or `'UA'`/`'LA'` jaw sentinels
* (`ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER` in `prosthesisTree.ts`) — get these leaf codes. Empty
* `types` means the target was named with no job at all (struck through in the sheet); empty
* `targets` with a `prosthesis_type_ambiguous` unresolved item at this index means the target
* is pending a material pick.
*/
export interface VoiceProsthesisAssignment {
targets: string[];
types: string[];
spoken: string;
}
export interface VoiceExtractionResult {
@@ -36,7 +51,7 @@ export interface VoiceExtractionResult {
teeth: FdiToothId[];
toothSelectionGroups: ToothSelectionGroup[];
comment: string | null;
prosthesis: VoiceProsthesisResult | null;
prosthesisAssignments: VoiceProsthesisAssignment[];
labId: string | null;
/** When false, the lab row must not tick itself — the name only approximately matched. */
labMatchExact: boolean;