refactor(voice): extract the apply decision so it can be tested

applyVoiceResult decided what to write and wrote it, in one callback inside a
3200-line client component. Nothing could reach it — not exported, and rendering
its component means mocking next-intl, the i18n router and six axios modules.
Three of the five defects found in live use sat in that callback, while 209
green tests covered the helpers around it.

buildVoiceApplyPlan(result, selection, ctx) -> { detail, labCaseDraft } now
holds the decision and writes nothing. The callback keeps only what a component
must do: setDetails, the ref writes the in-flight save reads, and the order that
lets lab rows carry a real treatmentDetailId. newDetail and newLabCaseDraft move
to treatmentDetailRules.ts so the pure module can build a draft without
importing a component.

21 tests. Verified they bite by reverting each bug in place: reading
labDependent from result.treatmentType fails 1, merging the plain teeth list
onto a prosthesis detail fails 2.

One group asserts an invariant that belongs to the backend — every
toothProsthesis row naming a real tooth must be in detail.teeth, which is
TREATMENT_TOOTH_NOT_ON_DETAIL at treatments.service.ts:806. It spans two
processes, so neither side could state it alone before.

Not covered, still manual: the labCaseDraftsRef timing needs a real render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-11 19:55:39 +08:00
parent 78fd3eb092
commit 2c3e28ac79
7 changed files with 557 additions and 112 deletions

View File

@@ -39,7 +39,7 @@ There is **no root `package.json`**. Every npm command runs inside `backend/` or
| `npx tsc --noEmit` | **Verification gate for any type or cross-cutting frontend change** | | `npx tsc --noEmit` | **Verification gate for any type or cross-cutting frontend change** |
| `npm run build` | Production build (`output: 'standalone'`) | | `npm run build` | Production build (`output: 'standalone'`) |
| `npm run lint` | ESLint via Next | | `npm run lint` | ESLint via Next |
| `npx vitest run` | Vitest — pure helpers only (`prosthesisTree.ts`, `voiceReviewRows.ts`, `toothSelectionGroups.ts`) | | `npx vitest run` | Vitest — pure helpers only (`prosthesisTree.ts`, `voiceReviewRows.ts`, `toothSelectionGroups.ts`, `voiceApply.ts`) |
`NEXT_PUBLIC_*` values are baked in at build time — restart `npm run dev` after changing `.env.local`. `NEXT_PUBLIC_*` values are baked in at build time — restart `npm run dev` after changing `.env.local`.
@@ -104,7 +104,7 @@ Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B,
### Tests ### Tests
Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation, voice extraction contract (`backend/src/**`). Frontend has Vitest for its own pure helpers only — no React, no DOM: `prosthesisTree.ts`, `voiceReviewRows.ts` and `toothSelectionGroups.ts` (`frontend/src/components/treatment/*.spec.ts`), run via `npx vitest run`. `npx tsc --noEmit` remains the frontend's cross-cutting gate. Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation, voice extraction contract (`backend/src/**`). Frontend has Vitest for its own pure helpers only — no React, no DOM: `prosthesisTree.ts`, `voiceReviewRows.ts`, `toothSelectionGroups.ts` and `voiceApply.ts` (`frontend/src/components/treatment/*.spec.ts`), run via `npx vitest run`. When a component callback holds a decision worth testing, extract the decision into `components/{feature}/` and leave the commit — state, refs, save order — in the component; `voiceApply.ts` is the worked example. `npx tsc --noEmit` remains the frontend's cross-cutting gate.
## Deployment ## Deployment

View File

@@ -550,6 +550,41 @@ Files: `backend/src/modules/voice/extraction.prompt.ts`,
Verified: `npx eslint src/modules/voice/**/*.ts` clean, `npm test -- extraction` 74/74. Verified: `npx eslint src/modules/voice/**/*.ts` clean, `npm test -- extraction` 74/74.
No unit test — both are prompt text, which the extraction specs deliberately do not assert on. No unit test — both are prompt text, which the extraction specs deliberately do not assert on.
## 2026-09-11 — the apply decision is now testable (dyolink)
Pulled the decision out of `applyVoiceResult` into
`frontend/src/components/treatment/voiceApply.ts` — `buildVoiceApplyPlan(result, selection, ctx)`
returns `{ detail, labCaseDraft }` and writes nothing. The callback shrank from ~115 lines to the
commit it always should have been: two `setState`s, two ref writes, and the save order.
`newDetail` / `newLabCaseDraft` moved from `TreatmentWorkspace.tsx` to `treatmentDetailRules.ts`,
so the pure module can build a draft without importing a component.
21 new tests in `voiceApply.spec.ts`. Proved they bite by reverting each bug in place:
| Reintroduced bug | Failing tests |
|---|---|
| `labDependent` read from `result.treatmentType` (decision 41) | 1 |
| plain `teeth` merged onto a prosthesis detail (decision 40) | 2 |
One group of tests asserts an invariant that belongs to the *backend*: every `toothProsthesis`
row naming a real tooth must appear in `detail.teeth`. That is
`TREATMENT_TOOTH_NOT_ON_DETAIL` (`treatments.service.ts:806`) — the error the clinic saw — now
checked on the client side of the wire, across the four shapes that produced it: two separated
teeth, one tooth, a two-code stack, and a jaw plus a tooth.
Still not covered, and still manual (§12): the `labCaseDraftsRef` timing, which needs a real
render, and the persist ordering.
Gate: `tsc --noEmit` clean, `vitest run` 73/73 (was 52), `next build` compiled, eslint back to its
pre-change 10 warnings (the 11th was an import I had orphaned).
Files: `frontend/src/components/treatment/voiceApply.ts` (new),
`frontend/src/components/treatment/voiceApply.spec.ts` (new),
`frontend/src/components/treatment/treatmentDetailRules.ts`,
`frontend/src/components/ui/treatment/TreatmentWorkspace.tsx`,
`docs/specs/voice-treatment-entry/spec.md`, `CLAUDE.md`.
## Remaining before this is shippable ## Remaining before this is shippable
- The **manual pass in §12** — none of it has been run. Safari and iPad especially, since that is - The **manual pass in §12** — none of it has been run. Safari and iPad especially, since that is

View File

@@ -744,6 +744,29 @@ chart writes through — and `applyVoiceResult` applies through it too. A job th
refuse, such as an implant plus a post & core on one tooth, is shown struck through and named. refuse, such as an implant plus a post & core on one tooth, is shown struck through and named.
Not silently dropped, and not silently applied. The rules stay in one file (§6). Not silently dropped, and not silently applied. The rules stay in one file (§6).
### What to apply is a pure function, applying it is not
`applyVoiceResult` used to decide and commit in one callback inside a 3200-line client
component. Nothing could reach it: it is not exported, and rendering its component means mocking
next-intl, the i18n router and six axios modules. Three of the five defects found in live use
sat in exactly that callback, and the 209 tests that were green at the time all covered helpers
around it.
The decision now lives in `frontend/src/components/treatment/voiceApply.ts` as
`buildVoiceApplyPlan(result, selection, ctx) -> { detail, labCaseDraft }`. It is pure apart from
the two client-id generators, so a test states a dictation and a set of ticks and reads back the
exact rows that will be written. The component keeps what only a component can do — `setDetails`,
the ref writes the in-flight save reads, and the order that lets lab rows carry a real
`treatmentDetailId`.
`voiceApply.spec.ts` pins the three rules that were broken, and one more that is not ours:
every `toothProsthesis` row naming a real tooth must be in `detail.teeth`, which is
`TREATMENT_TOOTH_NOT_ON_DETAIL` at `treatments.service.ts:806` asserted on the client side of the
wire. That invariant spans two processes, so neither side could state it alone before.
What this does **not** cover: the ref-versus-state timing, which needs a real render, and the
save order. Those stay manual (§12).
### Three chip kinds, one pattern ### Three chip kinds, one pattern
An item carrying `candidates` renders them as **tappable chips** — the one place the sheet is An item carrying `candidates` renders them as **tappable chips** — the one place the sheet is

View File

@@ -1,5 +1,5 @@
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { TreatmentDetailDraft } from '@/types/treatment'; import type { LabCaseDraft, TreatmentDetailDraft } from '@/types/treatment';
export type LabCaseTaskProgress = { export type LabCaseTaskProgress = {
completed: number; completed: number;
@@ -132,3 +132,39 @@ export function canCommentOnDetailLabCase(detail: DetailLike): boolean {
if (!detail.sentAt || !detail.labCaseId) return false; if (!detail.sentAt || !detail.labCaseId) return false;
return !isLabCaseCompleted(detail.taskProgress); return !isLabCaseCompleted(detail.taskProgress);
} }
/**
* Fresh-draft factories. They live here rather than in the workspace component so the pure
* helpers that build a draft (see `voiceApply.ts`) can call them without importing JSX.
* `crypto.randomUUID` is the id; the fallback covers the older Safari versions the clinic uses.
*/
export function newDetail(defaultTreatmentType?: string): TreatmentDetailDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: defaultTreatmentType ?? '',
teeth: [],
toothSelectionGroups: [],
comment: '',
attachmentMetas: [],
sendToOrganizationIds: [],
sentAt: null,
};
}
export function newLabCaseDraft(): LabCaseDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `lab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
destinationOrganizationId: null,
detailClientId: null,
toothProsthesis: [],
attachmentIds: [],
sentAt: null,
sends: [],
};
}

View File

@@ -0,0 +1,331 @@
import { describe, expect, it } from 'vitest';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
import { ARCH_TOOTH_UPPER } from './prosthesisTree';
import { buildVoiceApplyPlan, type VoiceApplyContext } from './voiceApply';
const CATALOG: ProsthesisCatalogEntry[] = [
{
code: 'pfm_crown',
sortOrder: 1,
label: 'PFM Crown',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'full_metal_crown',
sortOrder: 2,
label: 'Full Metal Crown',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'cast_post_core',
sortOrder: 3,
label: 'Cast Post & Core',
category: 'post_core',
subcategory: '',
chartRegion: 'root',
stackGroup: 'post_core',
},
{
code: 'complete_denture',
sortOrder: 4,
label: 'Complete Denture',
category: 'denture',
subcategory: '',
chartRegion: 'arch',
stackGroup: 'arch',
},
];
const ctx = (overrides: Partial<VoiceApplyContext> = {}): VoiceApplyContext => ({
defaultTreatmentType: 'prosthesis',
labDependentCodes: new Set(['prosthesis']),
prosthesisCatalog: CATALOG,
...overrides,
});
const result = (overrides: Partial<VoiceExtractionResult> = {}): VoiceExtractionResult => ({
treatmentType: 'prosthesis',
teeth: [],
toothSelectionGroups: [],
comment: null,
prosthesisAssignments: [],
labId: null,
labMatchExact: false,
dueDate: null,
unresolved: [],
...overrides,
});
const allTicked: VoiceApplySelection = {
treatmentType: true,
teeth: true,
comment: true,
prosthesis: true,
lab: true,
dueDate: true,
};
const tick = (overrides: Partial<VoiceApplySelection> = {}): VoiceApplySelection => ({
...allTicked,
...overrides,
});
describe('buildVoiceApplyPlan — the treatment type that is actually written', () => {
it('takes the dictated type when its row is ticked', () => {
const plan = buildVoiceApplyPlan(
result({ treatmentType: 'prosthesis' }),
tick(),
ctx({ defaultTreatmentType: 'restoration' }),
);
expect(plan.detail.treatmentType).toBe('prosthesis');
});
it('keeps the appointment default when the type row is unticked', () => {
const plan = buildVoiceApplyPlan(
result({ treatmentType: 'prosthesis' }),
tick({ treatmentType: false }),
ctx({ defaultTreatmentType: 'restoration' }),
);
expect(plan.detail.treatmentType).toBe('restoration');
});
it('reads labDependent from the detail it writes, not from the dictation', () => {
// Decision 41. Unticking the type row leaves a NON lab-dependent default, so the
// prosthesis branch must not run — it used to, and saved lab rows on the seeded type.
const plan = buildVoiceApplyPlan(
result({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: ['12'], types: ['pfm_crown'], spoken: '' }],
}),
tick({ treatmentType: false }),
ctx({ defaultTreatmentType: 'restoration' }),
);
expect(plan.detail.treatmentType).toBe('restoration');
expect(plan.labCaseDraft).toBeNull();
expect(plan.detail.teeth).toEqual([]);
});
});
describe('buildVoiceApplyPlan — teeth on a lab-dependent detail', () => {
it('puts a tooth on the detail only when it carries a job', () => {
// Decision 40. A target whose codes were all refused is not a selection.
const plan = buildVoiceApplyPlan(
result({
teeth: ['12', '14', '16'],
prosthesisAssignments: [
{ targets: ['12', '14'], types: ['full_metal_crown'], spoken: '' },
],
}),
tick(),
ctx(),
);
expect(plan.detail.teeth).toEqual(['12', '14']);
// 16 was dictated as a bare tooth, but a prosthesis detail never takes the plain list.
expect(plan.detail.teeth).not.toContain('16');
});
it('ignores the plain teeth list entirely for a lab-dependent type', () => {
const plan = buildVoiceApplyPlan(result({ teeth: ['21', '22'] }), tick(), ctx());
expect(plan.detail.teeth).toEqual([]);
expect(plan.labCaseDraft).toBeNull();
});
it('takes the plain teeth list for a type that is not lab-dependent', () => {
const plan = buildVoiceApplyPlan(
result({ treatmentType: 'restoration', teeth: ['21', '22'] }),
tick(),
ctx({ defaultTreatmentType: 'restoration' }),
);
expect(plan.detail.teeth).toEqual(['21', '22']);
});
});
describe('buildVoiceApplyPlan — the contract the backend validates', () => {
// treatments.service.ts:806 rejects the save with TREATMENT_TOOTH_NOT_ON_DETAIL when a
// lab row names a tooth the detail does not carry. This is that rule, asserted on our side
// of the wire — it is the error the clinic actually saw.
const everyToothIsOnTheDetail = (plan: ReturnType<typeof buildVoiceApplyPlan>) => {
const teeth = new Set<string>(plan.detail.teeth);
return (plan.labCaseDraft?.toothProsthesis ?? [])
.filter((row) => row.tooth !== 'UA' && row.tooth !== 'LA')
.every((row) => teeth.has(row.tooth));
};
it('holds for two separated teeth sharing one crown type', () => {
const plan = buildVoiceApplyPlan(
result({
prosthesisAssignments: [
{ targets: ['12', '14'], types: ['full_metal_crown'], spoken: '' },
],
}),
tick(),
ctx(),
);
expect(everyToothIsOnTheDetail(plan)).toBe(true);
expect(plan.labCaseDraft?.toothProsthesis).toHaveLength(2);
});
it('holds for a single tooth', () => {
const plan = buildVoiceApplyPlan(
result({
prosthesisAssignments: [{ targets: ['26'], types: ['pfm_crown'], spoken: '' }],
}),
tick(),
ctx(),
);
expect(everyToothIsOnTheDetail(plan)).toBe(true);
});
it('holds for a stack of two codes on one tooth', () => {
const plan = buildVoiceApplyPlan(
result({
prosthesisAssignments: [
{ targets: ['26'], types: ['cast_post_core', 'pfm_crown'], spoken: '' },
],
}),
tick(),
ctx(),
);
expect(everyToothIsOnTheDetail(plan)).toBe(true);
expect(plan.labCaseDraft?.toothProsthesis.map((r) => r.prosthesisTypeCode)).toEqual([
'cast_post_core',
'pfm_crown',
]);
});
it('holds when a jaw and a tooth are dictated together', () => {
const plan = buildVoiceApplyPlan(
result({
prosthesisAssignments: [
{ targets: [ARCH_TOOTH_UPPER], types: ['complete_denture'], spoken: '' },
{ targets: ['36'], types: ['pfm_crown'], spoken: '' },
],
}),
tick(),
ctx(),
);
expect(everyToothIsOnTheDetail(plan)).toBe(true);
// The jaw sentinel rides on the lab rows but is never a tooth on the detail.
expect(plan.detail.teeth).toEqual(['36']);
expect(plan.labCaseDraft?.toothProsthesis.map((r) => r.tooth)).toEqual([
ARCH_TOOTH_UPPER,
'36',
]);
});
});
describe('buildVoiceApplyPlan — selection groups on the lab rows', () => {
it('gives a jaw appliance no selection group', () => {
const plan = buildVoiceApplyPlan(
result({
prosthesisAssignments: [
{ targets: [ARCH_TOOTH_UPPER], types: ['complete_denture'], spoken: '' },
],
}),
tick(),
ctx(),
);
expect(plan.labCaseDraft?.toothProsthesis[0]?.selectionGroupId).toBe('');
});
it('never lets two non-adjacent teeth share a group', () => {
// 12 and 14 are two apart, with 13 between them. Sharing one selectionGroupId told the
// lab to build them as a single bridge.
const plan = buildVoiceApplyPlan(
result({
toothSelectionGroups: [{ groupId: 'g1', kind: 'connected', teeth: ['12', '13', '14'] }],
prosthesisAssignments: [
{ targets: ['12', '14'], types: ['full_metal_crown'], spoken: '' },
],
}),
tick(),
ctx(),
);
const groupIds = plan.labCaseDraft?.toothProsthesis.map((r) => r.selectionGroupId) ?? [];
expect(new Set(groupIds).size).toBe(2);
expect(groupIds.every((id) => id !== '')).toBe(true);
});
it('keeps one group when the teeth really are a span', () => {
const plan = buildVoiceApplyPlan(
result({
toothSelectionGroups: [{ groupId: 'g1', kind: 'connected', teeth: ['12', '13'] }],
prosthesisAssignments: [
{ targets: ['12', '13'], types: ['full_metal_crown'], spoken: '' },
],
}),
tick(),
ctx(),
);
const groupIds = plan.labCaseDraft?.toothProsthesis.map((r) => r.selectionGroupId) ?? [];
expect(new Set(groupIds).size).toBe(1);
});
});
describe('buildVoiceApplyPlan — the lab case draft', () => {
it('is null when nothing lab-side was dictated', () => {
const plan = buildVoiceApplyPlan(result({ comment: 'hello' }), tick(), ctx());
expect(plan.labCaseDraft).toBeNull();
});
it('exists for a lab with no prosthesis work at all', () => {
const plan = buildVoiceApplyPlan(result({ labId: 'lab-1' }), tick(), ctx());
expect(plan.labCaseDraft?.destinationOrganizationId).toBe('lab-1');
expect(plan.labCaseDraft?.toothProsthesis).toEqual([]);
});
it('exists for a due date alone', () => {
const plan = buildVoiceApplyPlan(result({ dueDate: '2026-09-20' }), tick(), ctx());
expect(plan.labCaseDraft?.dueDate).toBe('2026-09-20');
});
it('drops the lab and the due date when their rows are unticked', () => {
const plan = buildVoiceApplyPlan(
result({ labId: 'lab-1', dueDate: '2026-09-20' }),
tick({ lab: false, dueDate: false }),
ctx(),
);
expect(plan.labCaseDraft).toBeNull();
});
it('points at the detail it was built with', () => {
const plan = buildVoiceApplyPlan(result({ labId: 'lab-1' }), tick(), ctx());
expect(plan.labCaseDraft?.detailClientId).toBe(plan.detail.clientId);
expect(plan.detail.clientId).toBeTruthy();
});
});
describe('buildVoiceApplyPlan — the comment', () => {
it('is written when its row is ticked', () => {
const plan = buildVoiceApplyPlan(result({ comment: 'سلام چطوری' }), tick(), ctx());
expect(plan.detail.comment).toBe('سلام چطوری');
});
it('is left blank when its row is unticked', () => {
const plan = buildVoiceApplyPlan(
result({ comment: 'سلام چطوری' }),
tick({ comment: false }),
ctx(),
);
expect(plan.detail.comment).toBe('');
});
});
describe('buildVoiceApplyPlan — it does not mutate what it was given', () => {
it('copies the dictated teeth rather than aliasing them', () => {
const dictated = result({ treatmentType: 'restoration', teeth: ['21'] });
const plan = buildVoiceApplyPlan(
dictated,
tick(),
ctx({ defaultTreatmentType: 'restoration' }),
);
plan.detail.teeth.push('22');
expect(dictated.teeth).toEqual(['21']);
});
});

View File

@@ -0,0 +1,113 @@
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { FdiToothId, LabCaseDraft, TreatmentDetailDraft } from '@/types/treatment';
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
import { newDetail, newLabCaseDraft } from './treatmentDetailRules';
import { groupsFromFlatTeeth } from './toothSelectionGroups';
import { prosthesisTargetLines } from './voiceReviewRows';
export interface VoiceApplyContext {
/**
* Seeded from the appointment purpose, and undefined when the purpose maps to none. It
* survives an unticked treatment-type row, which is the point of seeding it.
*/
defaultTreatmentType: string | undefined;
labDependentCodes: ReadonlySet<string>;
prosthesisCatalog: readonly ProsthesisCatalogEntry[];
}
export interface VoiceApplyPlan {
detail: TreatmentDetailDraft;
/** Null when the dictation produced no lab-side work at all. */
labCaseDraft: LabCaseDraft | null;
}
/**
* Turns a confirmed review sheet into the rows that will be written — one new detail, and the
* lab case draft that rides on it. Pure apart from the two id generators, so the decisions that
* used to live inside the workspace's `applyVoiceResult` callback can be tested directly.
*
* It decides only *what* to write. Committing it — state, refs, and the save order that lets lab
* rows reference a real treatmentDetailId — stays in the component.
*/
export function buildVoiceApplyPlan(
result: VoiceExtractionResult,
selection: VoiceApplySelection,
ctx: VoiceApplyContext,
): VoiceApplyPlan {
const detail = newDetail(ctx.defaultTreatmentType);
// Ticked rows land on top of the seeded default, so unticking the type row leaves the
// appointment-purpose default rather than a blank.
if (selection.treatmentType && result.treatmentType) {
detail.treatmentType = result.treatmentType;
}
// Teeth and prosthesis are one row for a lab-dependent type (decision 39): ticking "teeth"
// on its own could save an empty detail, since `persistDraft` prunes a lab-dependent detail
// down to its jobs. Derived from the detail actually being written, never from
// `result.treatmentType` — reading the result meant an unticked type row still took the
// prosthesis branch and saved lab rows on the seeded type (decision 41).
const labDependent = ctx.labDependentCodes.has(detail.treatmentType);
if (!labDependent && selection.teeth) {
detail.teeth = [...result.teeth];
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
...group,
teeth: [...group.teeth],
}));
}
if (selection.comment && result.comment) {
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 lines =
labDependent && selection.prosthesis
? prosthesisTargetLines(result.prosthesisAssignments, ctx.prosthesisCatalog)
: [];
if (labDependent && selection.prosthesis) {
// 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 teeth = lines
.filter((line) => !line.isJaw && line.applied.length > 0)
.map((line) => line.target as FdiToothId);
detail.teeth = teeth;
detail.toothSelectionGroups = groupsFromFlatTeeth(teeth, result.toothSelectionGroups);
}
const wantsLabDraft =
lines.some((line) => line.applied.length > 0) ||
Boolean(selection.lab && result.labId) ||
Boolean(selection.dueDate && result.dueDate);
if (!wantsLabDraft) return { detail, labCaseDraft: null };
// Keyed by the detail's *client* id, so a brand-new unsaved detail can still carry one.
const labCaseDraft = newLabCaseDraft();
labCaseDraft.detailClientId = detail.clientId;
if (selection.lab && result.labId) {
labCaseDraft.destinationOrganizationId = result.labId;
}
if (selection.dueDate && result.dueDate) {
labCaseDraft.dueDate = result.dueDate;
}
labCaseDraft.toothProsthesis = lines.flatMap((line) => {
// A jaw appliance belongs to no selection group; a tooth takes the group it landed in.
const selectionGroupId = 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,
}));
});
return { detail, labCaseDraft };
}

View File

@@ -48,7 +48,10 @@ import {
isDetailTypeSelected, isDetailTypeSelected,
isEmptyDraftDetail, isEmptyDraftDetail,
areUnscheduledDetailsStripDeletable, areUnscheduledDetailsStripDeletable,
newDetail,
newLabCaseDraft,
} from '@/components/treatment/treatmentDetailRules'; } from '@/components/treatment/treatmentDetailRules';
import { buildVoiceApplyPlan } from '@/components/treatment/voiceApply';
import { import {
applyShiftRange, applyShiftRange,
connectedTeethSet, connectedTeethSet,
@@ -62,7 +65,6 @@ import {
unlinkAdjacentTeeth, unlinkAdjacentTeeth,
} from '@/components/treatment/toothSelectionGroups'; } from '@/components/treatment/toothSelectionGroups';
import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree'; import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree';
import { prosthesisTargetLines } from '@/components/treatment/voiceReviewRows';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention'; import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention'; import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import { import {
@@ -200,37 +202,6 @@ function buildWorkspaceSnapshot(
}; };
} }
function newDetail(defaultTreatmentType?: string): TreatmentDetailDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: defaultTreatmentType ?? '',
teeth: [],
toothSelectionGroups: [],
comment: '',
attachmentMetas: [],
sendToOrganizationIds: [],
sentAt: null,
};
}
function newLabCaseDraft(): LabCaseDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `lab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
destinationOrganizationId: null,
detailClientId: null,
toothProsthesis: [],
attachmentIds: [],
sentAt: null,
sends: [],
};
}
function mapAppointment(record: AppointmentRecord): TreatmentAppointment { function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
return { return {
id: record.id, id: record.id,
@@ -2201,53 +2172,17 @@ export function TreatmentWorkspace({
*/ */
const applyVoiceResult = useCallback( const applyVoiceResult = useCallback(
(result: VoiceExtractionResult, selection: VoiceApplySelection) => { (result: VoiceExtractionResult, selection: VoiceApplySelection) => {
const detail = newDetail( // What to write is decided by `buildVoiceApplyPlan`, which is pure and unit tested.
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog), // Everything below is the commit: state, the refs the save reads, and the order that
); // lets lab rows reference a real treatmentDetailId.
const { detail, labCaseDraft } = buildVoiceApplyPlan(result, selection, {
// Ticked rows land on top of the seeded defaults, so unticking the type row leaves defaultTreatmentType: defaultTreatmentTypeForAppointment(
// the appointment-purpose default rather than a blank. selectedAppointment?.purpose,
if (selection.treatmentType && result.treatmentType) { treatmentCatalog,
detail.treatmentType = result.treatmentType; ),
} labDependentCodes,
prosthesisCatalog,
// 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.
// Derived from the detail actually being written, never from `result.treatmentType`.
// Reading the result meant that unticking the type row still took the prosthesis branch,
// saving lab rows on whatever type the appointment purpose had seeded (decision 41).
const labDependent = labDependentCodes.has(detail.treatmentType);
if (!labDependent && selection.teeth) {
detail.teeth = [...result.teeth];
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
...group,
teeth: [...group.teeth],
}));
}
if (selection.comment && result.comment) {
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]; const nextDetails = [...detailsRef.current, detail];
setDetails(nextDetails); setDetails(nextDetails);
@@ -2255,36 +2190,8 @@ export function TreatmentWorkspace({
detailsRef.current = nextDetails; detailsRef.current = nextDetails;
setActiveDetailId(detail.clientId); setActiveDetailId(detail.clientId);
// Lab-side rows ride on a lab case draft keyed by the detail's *client* id, so a if (labCaseDraft) {
// brand-new unsaved detail can still carry one; it is persisted after the detail is. const updatedLabCases = [...labCaseDrafts, labCaseDraft];
const wantsLabDraft =
prosthesisLines.some((line) => line.applied.length > 0) ||
(selection.lab && result.labId) ||
(selection.dueDate && result.dueDate);
if (wantsLabDraft) {
const draft = newLabCaseDraft();
draft.detailClientId = detail.clientId;
if (selection.lab && result.labId) {
draft.destinationOrganizationId = result.labId;
}
if (selection.dueDate && result.dueDate) {
draft.dueDate = result.dueDate;
}
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];
labCaseDraftsRef.current = updatedLabCases; labCaseDraftsRef.current = updatedLabCases;
setLabCaseDrafts(updatedLabCases); setLabCaseDrafts(updatedLabCases);