diff --git a/CLAUDE.md b/CLAUDE.md index 9351d2f..3002bb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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** | | `npm run build` | Production build (`output: 'standalone'`) | | `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`. @@ -104,7 +104,7 @@ Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B, ### 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 diff --git a/docs/specs/voice-treatment-entry/progress.md b/docs/specs/voice-treatment-entry/progress.md index 300e703..42b7b4c 100644 --- a/docs/specs/voice-treatment-entry/progress.md +++ b/docs/specs/voice-treatment-entry/progress.md @@ -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. 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 - The **manual pass in §12** — none of it has been run. Safari and iPad especially, since that is diff --git a/docs/specs/voice-treatment-entry/spec.md b/docs/specs/voice-treatment-entry/spec.md index 110d0c7..f2dd37f 100644 --- a/docs/specs/voice-treatment-entry/spec.md +++ b/docs/specs/voice-treatment-entry/spec.md @@ -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. 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 An item carrying `candidates` renders them as **tappable chips** — the one place the sheet is diff --git a/frontend/src/components/treatment/treatmentDetailRules.ts b/frontend/src/components/treatment/treatmentDetailRules.ts index a611c38..ffb0eba 100644 --- a/frontend/src/components/treatment/treatmentDetailRules.ts +++ b/frontend/src/components/treatment/treatmentDetailRules.ts @@ -1,5 +1,5 @@ import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; -import type { TreatmentDetailDraft } from '@/types/treatment'; +import type { LabCaseDraft, TreatmentDetailDraft } from '@/types/treatment'; export type LabCaseTaskProgress = { completed: number; @@ -132,3 +132,39 @@ export function canCommentOnDetailLabCase(detail: DetailLike): boolean { if (!detail.sentAt || !detail.labCaseId) return false; 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: [], + }; +} diff --git a/frontend/src/components/treatment/voiceApply.spec.ts b/frontend/src/components/treatment/voiceApply.spec.ts new file mode 100644 index 0000000..704ec72 --- /dev/null +++ b/frontend/src/components/treatment/voiceApply.spec.ts @@ -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 => ({ + defaultTreatmentType: 'prosthesis', + labDependentCodes: new Set(['prosthesis']), + prosthesisCatalog: CATALOG, + ...overrides, +}); + +const result = (overrides: Partial = {}): 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 => ({ + ...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) => { + const teeth = new Set(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']); + }); +}); diff --git a/frontend/src/components/treatment/voiceApply.ts b/frontend/src/components/treatment/voiceApply.ts new file mode 100644 index 0000000..0c72ce4 --- /dev/null +++ b/frontend/src/components/treatment/voiceApply.ts @@ -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; + 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 }; +} diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index ce4ac0d..7f49925 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -48,7 +48,10 @@ import { isDetailTypeSelected, isEmptyDraftDetail, areUnscheduledDetailsStripDeletable, + newDetail, + newLabCaseDraft, } from '@/components/treatment/treatmentDetailRules'; +import { buildVoiceApplyPlan } from '@/components/treatment/voiceApply'; import { applyShiftRange, connectedTeethSet, @@ -62,7 +65,6 @@ import { unlinkAdjacentTeeth, } from '@/components/treatment/toothSelectionGroups'; import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree'; -import { prosthesisTargetLines } from '@/components/treatment/voiceReviewRows'; import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention'; import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention'; 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 { return { id: record.id, @@ -2201,53 +2172,17 @@ export function TreatmentWorkspace({ */ const applyVoiceResult = useCallback( (result: VoiceExtractionResult, selection: VoiceApplySelection) => { - const detail = newDetail( - defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog), - ); - - // Ticked rows land on top of the seeded defaults, 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" 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, - ); - } + // What to write is decided by `buildVoiceApplyPlan`, which is pure and unit tested. + // 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, { + defaultTreatmentType: defaultTreatmentTypeForAppointment( + selectedAppointment?.purpose, + treatmentCatalog, + ), + labDependentCodes, + prosthesisCatalog, + }); const nextDetails = [...detailsRef.current, detail]; setDetails(nextDetails); @@ -2255,36 +2190,8 @@ export function TreatmentWorkspace({ detailsRef.current = nextDetails; setActiveDetailId(detail.clientId); - // 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 = - 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]; + if (labCaseDraft) { + const updatedLabCases = [...labCaseDrafts, labCaseDraft]; labCaseDraftsRef.current = updatedLabCases; setLabCaseDrafts(updatedLabCases);