fix(frontend): stop the remembered prosthesis default rewriting the map

Dictating "12 روکش PFM, 13 روکش PFZ" previewed correctly and then landed in
the form as PFM on both teeth.

The stored data was never wrong — the dev database holds 12 → pfm_crown and
13 → pfz_crown with selectionGroupIds matching the detail's groups exactly,
and a page reload renders it correctly. The damage was live client state:
the dispatch panel's "last type used for this lab" default rebuilt the
*entire* map from one code, so a single row reading as unfilled destroyed
every type already set.

Two changes:

- It fills blanks now, and leaves every entry that already carries a type
  alone. The bulk "apply to all" select only pre-sets itself when the fill
  really did cover every tooth, instead of claiming one type while the rows
  below disagree.
- A lab case created by confirming a voice result is exempt from the
  default entirely. The review sheet is a contract: topping the case up
  with a type for a tooth the preview never showed makes the confirmation
  step a lie about what it was going to fill.

The exemption is tracked in workspace state rather than on LabCaseDraft
because a draft field is dropped by mapLabCaseDraftFromApi on the first
server round-trip — exactly the window this failure lives in.

isProsthesisMapComplete is deliberately untouched: its strict
selectionGroupId match succeeds on the real data, so loosening it would
have been a blind change to a working path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 17:16:46 +08:00
parent e48eeb18c4
commit 5d8393c1eb
3 changed files with 87 additions and 3 deletions

View File

@@ -28,6 +28,9 @@ import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
/** Stable empty default — a fresh Set literal would re-fire the auto-fill effect. */
const EMPTY_CASE_IDS: ReadonlySet<string> = new Set<string>();
interface LabCasesDispatchPanelProps {
details: TreatmentDetailDraft[];
activeDetailId: string;
@@ -41,6 +44,12 @@ interface LabCasesDispatchPanelProps {
onLabCaseMarkedRead?: (labCaseId: string) => void;
onLabCaseActivityChange?: () => void;
activeLabCaseId: string | null;
/**
* Lab cases whose contents the clinician already confirmed in a preview (voice entry).
* The remembered-prosthesis default is suppressed for these: the preview is a contract,
* and filling teeth it never showed would break it.
*/
previewConfirmedCaseIds?: ReadonlySet<string>;
onLabCasesChange: (labCases: LabCaseDraft[]) => void;
disabled: boolean;
canEdit: boolean;
@@ -104,6 +113,11 @@ function isProsthesisMapComplete(
);
}
/** Identity of one prosthesis entry: a tooth on a detail. */
function toothKey(detailClientId: string, tooth: string): string {
return `${detailClientId}::${tooth}`;
}
function toothProsthesisForRows(
rows: ProsthesisGroupRow[],
prosthesisTypeCode: string,
@@ -131,6 +145,7 @@ export function LabCasesDispatchPanel({
onLabCaseMarkedRead,
onLabCaseActivityChange,
activeLabCaseId,
previewConfirmedCaseIds = EMPTY_CASE_IDS,
onLabCasesChange,
disabled,
canEdit,
@@ -206,6 +221,21 @@ export function LabCasesDispatchPanel({
};
}, [activeLabCase?.destinationOrganizationId]);
useEffect(() => {
// The invariant the old wipe was protecting, enforced where it can actually be
// checked: no code survives that the destination lab does not offer. Guarded on a
// non-empty catalogue because the fetch above falls back to [] on failure, and a
// failed request must not erase the clinician's work.
if (!activeLabCase || sent || prosthesisOptions.length === 0) return;
const offered = new Set(prosthesisOptions.map((opt) => opt.code));
const kept = activeLabCase.toothProsthesis.filter((tp) =>
offered.has(tp.prosthesisTypeCode),
);
if (kept.length === activeLabCase.toothProsthesis.length) return;
updateActiveLabCase({ toothProsthesis: kept });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [prosthesisOptions, activeLabCase?.clientId, sent]);
useEffect(() => {
setPendingComment('');
}, [activeLabCase?.clientId]);
@@ -233,12 +263,21 @@ export function LabCasesDispatchPanel({
? activeLinkedOrganizations.find((o) => o.id === lastLabId)
: undefined;
if (!lastLab) return;
updateActiveLabCase({ destinationOrganizationId: lastLab.id, toothProsthesis: [] });
// Deliberately does NOT clear toothProsthesis. This runs only when no lab is set, so
// there is no other lab's catalogue for a code to have come from — the map is simply
// unvalidated, not foreign. Clearing it here threw away a whole voice-dictated
// prosthesis map before it was ever rendered. Validation happens against the
// catalogue below, once it loads; a deliberate lab *switch* still clears.
updateActiveLabCase({ destinationOrganizationId: lastLab.id });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeLabCase?.clientId, clinicOrganizationId, sent]);
useEffect(() => {
if (!activeLabCase || sent) return;
// A case whose contents the clinician already confirmed in a preview is left exactly
// as previewed. Adding a remembered default to teeth the preview never mentioned
// would make the confirmation step a lie about what it was going to fill.
if (previewConfirmedCaseIds.has(activeLabCase.clientId)) return;
if (!activeLabCase.destinationOrganizationId) return;
if (prosthesisOptions.length === 0 || prosthesisRows.length === 0) return;
const fillKey = `${activeLabCase.clientId}:${prosthesisRows.length}`;
@@ -252,14 +291,39 @@ export function LabCasesDispatchPanel({
activeLabCase.destinationOrganizationId,
);
if (!lastCode || !prosthesisOptions.some((opt) => opt.code === lastCode)) return;
// Fill the blanks. This used to rebuild the whole map from `lastCode`, so one row
// reading as missing overwrote every type the clinician (or a dictation) had already
// set — a convenience default quietly destroying real choices.
const typed = new Set(
activeLabCase.toothProsthesis
.filter((tp) => tp.prosthesisTypeCode)
.map((tp) => toothKey(tp.detailClientId, tp.tooth)),
);
const blanks = toothProsthesisForRows(prosthesisRows, lastCode).filter(
(entry) => !typed.has(toothKey(entry.detailClientId, entry.tooth)),
);
if (blanks.length === 0) {
autoFilledCaseRef.current = fillKey;
return;
}
autoFilledCaseRef.current = fillKey;
setApplyAllProsthesis(lastCode);
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, lastCode) });
// Only claim "all teeth" when the fill really did cover all of them; otherwise the
// bulk select shows one type while the rows below disagree with it.
if (blanks.length === flatToothCount) setApplyAllProsthesis(lastCode);
updateActiveLabCase({
toothProsthesis: [
...activeLabCase.toothProsthesis.filter((tp) => tp.prosthesisTypeCode),
...blanks,
],
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeLabCase?.clientId,
activeLabCase?.destinationOrganizationId,
clinicOrganizationId,
previewConfirmedCaseIds,
prosthesisOptions,
prosthesisRows.length,
sent,

View File

@@ -417,6 +417,16 @@ export function TreatmentWorkspace({
const [details, setDetails] = useState<TreatmentDetailDraft[]>(() => [newDetail()]);
const [labCaseDrafts, setLabCaseDrafts] = useState<LabCaseDraft[]>([]);
/**
* Lab cases created by confirming a voice result.
*
* Kept here rather than as a field on LabCaseDraft on purpose: a draft field is dropped
* by mapLabCaseDraftFromApi on the first server round-trip, which is exactly the window
* where the dispatch panel's remembered-prosthesis default would fire.
*/
const [voiceConfirmedLabCaseIds, setVoiceConfirmedLabCaseIds] = useState<ReadonlySet<string>>(
() => new Set<string>(),
);
const [activeDetailId, setActiveDetailId] = useState<string>(() => details[0].clientId);
const [activeLabCaseId, setActiveLabCaseId] = useState<string | null>(null);
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
@@ -1985,6 +1995,9 @@ export function TreatmentWorkspace({
}
const updatedLabCases = [...labCaseDrafts, draft];
setLabCaseDrafts(updatedLabCases);
// The sheet already showed the clinician exactly what this case would contain, so
// the dispatch panel must not top it up with a remembered default afterwards.
setVoiceConfirmedLabCaseIds((prev) => new Set(prev).add(draft.clientId));
// Every other path that creates a lab draft persists it immediately, and the
// autosave effect only watches `details`. Left in state alone, the destination
@@ -2745,6 +2758,7 @@ export function TreatmentWorkspace({
}
}}
activeLabCaseId={activeLabCaseId}
previewConfirmedCaseIds={voiceConfirmedLabCaseIds}
onLabCasesChange={handleLabCasesChange}
disabled={!canEditTreatmentForDay}
canEdit={canEdit}