fix(voice): stop the review sheet from contradicting itself about a target

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-11 22:42:18 +08:00
parent c57abbe895
commit 98e2599535
4 changed files with 29 additions and 7 deletions

View File

@@ -232,6 +232,16 @@ describe('joblessProsthesisTargets', () => {
expect(joblessProsthesisTargets(result)).toEqual(['13']); 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', () => { it('does not call a target jobless while it is pending a material pick', () => {
const result = baseResult({ const result = baseResult({
prosthesisAssignments: [{ targets: ['13'], types: [], spoken: 'روکش' }], prosthesisAssignments: [{ targets: ['13'], types: [], spoken: 'روکش' }],

View File

@@ -239,7 +239,11 @@ export function joblessProsthesisTargets(result: VoiceExtractionResult): string[
if (!covered.has(tooth) && !pending.has(tooth)) jobless.add(tooth); 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 = { export type VoiceProsthesisChartData = {

View File

@@ -2191,7 +2191,9 @@ export function TreatmentWorkspace({
setActiveDetailId(detail.clientId); setActiveDetailId(detail.clientId);
if (labCaseDraft) { 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; labCaseDraftsRef.current = updatedLabCases;
setLabCaseDrafts(updatedLabCases); setLabCaseDrafts(updatedLabCases);
@@ -2215,7 +2217,6 @@ export function TreatmentWorkspace({
setVoiceResult(null); setVoiceResult(null);
}, },
[ [
labCaseDrafts,
labDependentCodes, labDependentCodes,
persistDraft, persistDraft,
persistLabCases, persistLabCases,

View File

@@ -106,6 +106,13 @@ export function VoiceReviewSheet({
[effective, prosthesisCatalog], [effective, prosthesisCatalog],
); );
const joblessTargets = useMemo(() => joblessProsthesisTargets(effective), [effective]); 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( const chartData = useMemo(
() => prosthesisChartData(prosthesisLines, joblessTargets, prosthesisCatalog), () => prosthesisChartData(prosthesisLines, joblessTargets, prosthesisCatalog),
[prosthesisLines, joblessTargets, prosthesisCatalog], [prosthesisLines, joblessTargets, prosthesisCatalog],
@@ -269,7 +276,7 @@ export function VoiceReviewSheet({
/> />
</div> </div>
<p className="mt-1 text-sm text-text-primary"> <p className="mt-1 text-sm text-text-primary">
{prosthesisLines.map((line, i) => ( {shownLines.map((line, i) => (
<span key={`line-${line.target}`}> <span key={`line-${line.target}`}>
{i > 0 ? ' · ' : ''} {i > 0 ? ' · ' : ''}
{targetLabel(line.target)}:{' '} {targetLabel(line.target)}:{' '}
@@ -279,13 +286,13 @@ export function VoiceReviewSheet({
{labelFor(code, prosthesisCatalog)} {labelFor(code, prosthesisCatalog)}
</span> </span>
))} ))}
{line.refused.map((code) => ( {line.refused.map((code, j) => (
<span <span
key={code} key={code}
title={t('voiceStackRefused')} title={t('voiceStackRefused')}
className="text-text-muted line-through" className="text-text-muted line-through"
> >
{' + '} {line.applied.length > 0 || j > 0 ? ' + ' : ''}
{labelFor(code, prosthesisCatalog)} {labelFor(code, prosthesisCatalog)}
</span> </span>
))} ))}
@@ -296,7 +303,7 @@ export function VoiceReviewSheet({
key={`jobless-${target}`} key={`jobless-${target}`}
className="text-text-muted line-through" className="text-text-muted line-through"
> >
{prosthesisLines.length > 0 || i > 0 ? ' · ' : ''} {shownLines.length > 0 || i > 0 ? ' · ' : ''}
{targetLabel(target)}: {t('voiceNoProsthesisHeard')} {targetLabel(target)}: {t('voiceNoProsthesisHeard')}
</span> </span>
))} ))}