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:
176
frontend/src/components/treatment/prosthesisTree.spec.ts
Normal file
176
frontend/src/components/treatment/prosthesisTree.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
274
frontend/src/components/treatment/voiceReviewRows.spec.ts
Normal file
274
frontend/src/components/treatment/voiceReviewRows.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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(', ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user