From 98e259953584a79f84915e12e937496c9a1bbd3e Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 11 Sep 2026 22:42:18 +0800 Subject: [PATCH] fix(voice): stop the review sheet from contradicting itself about a target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tooth named twice — once bare, once with a job — was counted as jobless. joblessProsthesisTargets adds an uncovered target inside the loop, but a LATER assignment can still cover it, and nothing re-checked at the end. The sheet then showed 13 with its crown AND struck through as "no prosthesis heard". prosthesisTargetLines also returns a line per target including one with nothing applied and nothing refused, so the sheet printed a dangling "13: " beside the jobless entry for the same tooth. The sheet now renders only lines carrying a code; the unfiltered list still feeds the chart, so a target pending a material pick stays selected. A line whose codes were all refused no longer opens with a leading " + ". applyVoiceResult now reads labCaseDraftsRef.current rather than the state. Every other handler writes that ref beside setLabCaseDrafts precisely because a save in the same tick reads it; this path was the one left reading state, so a draft added earlier in the same tick could be dropped. Found by /code-review. One new test, verified to fail without the fix. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/treatment/voiceReviewRows.spec.ts | 10 ++++++++++ .../src/components/treatment/voiceReviewRows.ts | 6 +++++- .../ui/treatment/TreatmentWorkspace.tsx | 5 +++-- .../components/ui/treatment/VoiceReviewSheet.tsx | 15 +++++++++++---- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/treatment/voiceReviewRows.spec.ts b/frontend/src/components/treatment/voiceReviewRows.spec.ts index e6929d3..8a100cb 100644 --- a/frontend/src/components/treatment/voiceReviewRows.spec.ts +++ b/frontend/src/components/treatment/voiceReviewRows.spec.ts @@ -232,6 +232,16 @@ describe('joblessProsthesisTargets', () => { expect(joblessProsthesisTargets(result)).toEqual(['13']); }); + it('does not call a target jobless when another assignment already gave it a job', () => { + const result = baseResult({ + prosthesisAssignments: [ + { targets: ['13'], types: [], spoken: '' }, + { targets: ['13'], types: ['pfm_crown'], spoken: '' }, + ], + }); + expect(joblessProsthesisTargets(result)).toEqual([]); + }); + it('does not call a target jobless while it is pending a material pick', () => { const result = baseResult({ prosthesisAssignments: [{ targets: ['13'], types: [], spoken: 'روکش' }], diff --git a/frontend/src/components/treatment/voiceReviewRows.ts b/frontend/src/components/treatment/voiceReviewRows.ts index 6170c29..78df511 100644 --- a/frontend/src/components/treatment/voiceReviewRows.ts +++ b/frontend/src/components/treatment/voiceReviewRows.ts @@ -239,7 +239,11 @@ export function joblessProsthesisTargets(result: VoiceExtractionResult): string[ if (!covered.has(tooth) && !pending.has(tooth)) jobless.add(tooth); } - return [...jobless]; + // A target named twice — once with a job, once bare — is not jobless. Without this the + // sheet shows the same tooth with its crown AND struck through as "no prosthesis heard". + return [...jobless].filter( + (target) => !covered.has(target) && !pending.has(target), + ); } export type VoiceProsthesisChartData = { diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 7f49925..54a12e9 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -2191,7 +2191,9 @@ export function TreatmentWorkspace({ setActiveDetailId(detail.clientId); if (labCaseDraft) { - const updatedLabCases = [...labCaseDrafts, labCaseDraft]; + // The ref, not the state: every other handler now writes the ref beside + // setLabCaseDrafts, so a draft added earlier in the same tick is only there. + const updatedLabCases = [...labCaseDraftsRef.current, labCaseDraft]; labCaseDraftsRef.current = updatedLabCases; setLabCaseDrafts(updatedLabCases); @@ -2215,7 +2217,6 @@ export function TreatmentWorkspace({ setVoiceResult(null); }, [ - labCaseDrafts, labDependentCodes, persistDraft, persistLabCases, diff --git a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx index ccb978d..ef001e3 100644 --- a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx +++ b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx @@ -106,6 +106,13 @@ export function VoiceReviewSheet({ [effective, prosthesisCatalog], ); const joblessTargets = useMemo(() => joblessProsthesisTargets(effective), [effective]); + // A target whose assignment named no usable job still produces a line, and the jobless list + // already names it. Rendering both printed a dangling "13: " beside "13: no prosthesis + // heard". The unfiltered list still feeds the chart, so a pending target stays selected. + const shownLines = useMemo( + () => prosthesisLines.filter((line) => line.applied.length > 0 || line.refused.length > 0), + [prosthesisLines], + ); const chartData = useMemo( () => prosthesisChartData(prosthesisLines, joblessTargets, prosthesisCatalog), [prosthesisLines, joblessTargets, prosthesisCatalog], @@ -269,7 +276,7 @@ export function VoiceReviewSheet({ />

- {prosthesisLines.map((line, i) => ( + {shownLines.map((line, i) => ( {i > 0 ? ' · ' : ''} {targetLabel(line.target)}:{' '} @@ -279,13 +286,13 @@ export function VoiceReviewSheet({ {labelFor(code, prosthesisCatalog)} ))} - {line.refused.map((code) => ( + {line.refused.map((code, j) => ( - {' + '} + {line.applied.length > 0 || j > 0 ? ' + ' : ''} {labelFor(code, prosthesisCatalog)} ))} @@ -296,7 +303,7 @@ export function VoiceReviewSheet({ key={`jobless-${target}`} className="text-text-muted line-through" > - {prosthesisLines.length > 0 || i > 0 ? ' · ' : ''} + {shownLines.length > 0 || i > 0 ? ' · ' : ''} {targetLabel(target)}: {t('voiceNoProsthesisHeard')} ))}