From 5d8393c1eb7b4b9694b0c6fbf822397df52884a1 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 17:16:46 +0800 Subject: [PATCH] fix(frontend): stop the remembered prosthesis default rewriting the map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/specs/voice-treatment-entry/spec.md | 6 ++ .../ui/treatment/LabCasesDispatchPanel.tsx | 70 ++++++++++++++++++- .../ui/treatment/TreatmentWorkspace.tsx | 14 ++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/docs/specs/voice-treatment-entry/spec.md b/docs/specs/voice-treatment-entry/spec.md index 0b83fcd..8c37d76 100644 --- a/docs/specs/voice-treatment-entry/spec.md +++ b/docs/specs/voice-treatment-entry/spec.md @@ -482,6 +482,12 @@ that justified this whole design. - any row carrying an unresolved item or an incomplete prosthesis map. - Unresolved items are shown with what was heard ("دندان شیری — بازشناسی نشد"), so the clinician can see what the system did not understand. +- **The sheet is a contract: confirm fills exactly what it previewed — no more.** Per-detail + conveniences that would top the case up afterwards are suppressed for a voice-created + case; concretely, the dispatch panel's remembered-prosthesis default + (`previewConfirmedCaseIds`). A default that quietly adds a prosthesis type to a tooth the + sheet never mentioned turns the confirmation step into a lie about what it was going to + do, which is the whole reason the step exists. - An item that carries `candidates` renders them as **tappable chips** — the one place the sheet is interactive. Picking one folds the tooth into the result (`withChosenTeeth`) and ticks the teeth row, so an under-specified tooth is one tap from resolved instead of a diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx index 6b8bc7a..92b1a1b 100644 --- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx +++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx @@ -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 = new Set(); + 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; 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, diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 23a41ee..0aca70e 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -417,6 +417,16 @@ export function TreatmentWorkspace({ const [details, setDetails] = useState(() => [newDetail()]); const [labCaseDrafts, setLabCaseDrafts] = useState([]); + /** + * 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>( + () => new Set(), + ); const [activeDetailId, setActiveDetailId] = useState(() => details[0].clientId); const [activeLabCaseId, setActiveLabCaseId] = useState(null); const [savedSnapshot, setSavedSnapshot] = useState(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}