From d226a2b2941bf450f74d53b49fc8c61758b37a87 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 05:04:15 +0800 Subject: [PATCH] fix(frontend): persist the lab case a voice result creates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyVoiceResult put the lab draft in state and stopped there. Every other path that creates a LabCaseDraft — handleContinueToLab, handleLabCasesChange — immediately runs persistDraft + persistLabCases, and the autosave effect only watches `details`. So applying a voice result carrying a lab, a due date and a prosthesis map, then reloading, kept the detail and silently dropped all three: the surviving detail made it look like the save worked. applyVoiceResult moves below persistDraft/persistLabCases so it can call them, and writes detailsRef itself before persisting — persistDraft reads that ref, and setDetails has not rendered by the time the save runs. The ref is already written imperatively elsewhere for the same reason. Co-Authored-By: Claude Opus 5 (1M context) --- .../ui/treatment/TreatmentWorkspace.tsx | 180 ++++++++++-------- 1 file changed, 104 insertions(+), 76 deletions(-) diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 7feb852..23a41ee 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -496,82 +496,6 @@ export function TreatmentWorkspace({ [appointments, selectedAppointmentId], ); - /** - * Voice entry. - * - * Confirm always appends a NEW detail — it never edits an existing one, and never - * touches onAddDetail. Nothing is created until this runs, so cancelling or a failed - * recording leaves the chip strip untouched. - */ - 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; - } - if (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; - } - - setDetails((prev) => [...prev, detail]); - setActiveDetailId(detail.clientId); - setEntryStep('treatment'); - - // 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 = - (selection.prosthesis && result.prosthesis) || - (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; - } - if (selection.prosthesis && result.prosthesis) { - // byTooth keys are plain strings; the group's teeth are FdiToothId. - const groupOf = (tooth: string) => - result.toothSelectionGroups.find((group) => - (group.teeth as readonly string[]).includes(tooth), - )?.groupId ?? ''; - // Only teeth that actually landed on the detail. Unticking "teeth" while - // leaving "prosthesis" ticked would otherwise attach prosthesis rows for teeth - // the treatment does not contain — nothing downstream filters them, and they - // would reach task generation as work for teeth nobody is treating. - const detailTeeth = new Set(detail.teeth); - draft.toothProsthesis = Object.entries(result.prosthesis.byTooth) - .filter(([tooth]) => detailTeeth.has(tooth)) - .map(([tooth, prosthesisTypeCode]) => ({ - detailClientId: detail.clientId, - tooth, - prosthesisTypeCode, - selectionGroupId: groupOf(tooth), - })); - } - setLabCaseDrafts((prev) => [...prev, draft]); - } - - setVoiceResult(null); - }, - [selectedAppointment?.purpose, treatmentCatalog], - ); const voice = useVoiceCapture({ // The locale the clinician is actually reading and speaking in. Sent explicitly so @@ -1986,6 +1910,110 @@ export function TreatmentWorkspace({ ], ); + /** + * Voice entry. + * + * Confirm always appends a NEW detail — it never edits an existing one, and never + * touches onAddDetail. Nothing is created until this runs, so cancelling or a failed + * recording leaves the chip strip untouched. + */ + 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; + } + if (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; + } + + const nextDetails = [...detailsRef.current, detail]; + setDetails(nextDetails); + // persistDraft reads detailsRef, and setDetails has not rendered yet. The codebase + // already writes this ref imperatively after a save for the same reason. + detailsRef.current = nextDetails; + setActiveDetailId(detail.clientId); + setEntryStep('treatment'); + + // 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 = + (selection.prosthesis && result.prosthesis) || + (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; + } + if (selection.prosthesis && result.prosthesis) { + // byTooth keys are plain strings; the group's teeth are FdiToothId. + const groupOf = (tooth: string) => + result.toothSelectionGroups.find((group) => + (group.teeth as readonly string[]).includes(tooth), + )?.groupId ?? ''; + // Only teeth that actually landed on the detail. Unticking "teeth" while + // leaving "prosthesis" ticked would otherwise attach prosthesis rows for teeth + // the treatment does not contain — nothing downstream filters them, and they + // would reach task generation as work for teeth nobody is treating. + const detailTeeth = new Set(detail.teeth); + draft.toothProsthesis = Object.entries(result.prosthesis.byTooth) + .filter(([tooth]) => detailTeeth.has(tooth)) + .map(([tooth, prosthesisTypeCode]) => ({ + detailClientId: detail.clientId, + tooth, + prosthesisTypeCode, + selectionGroupId: groupOf(tooth), + })); + } + const updatedLabCases = [...labCaseDrafts, draft]; + setLabCaseDrafts(updatedLabCases); + + // Every other path that creates a lab draft persists it immediately, and the + // autosave effect only watches `details`. Left in state alone, the destination + // lab, the due date and the whole prosthesis map vanish on the next reload — + // silently, because the detail itself does survive. + void (async () => { + try { + const saved = await persistDraft({ force: true }); + await persistLabCases(saved, updatedLabCases); + } catch (error: unknown) { + showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments'))); + } + })(); + } + + setVoiceResult(null); + }, + [ + labCaseDrafts, + persistDraft, + persistLabCases, + selectedAppointment?.purpose, + showError, + t, + tErrors, + treatmentCatalog, + ], + ); + const handleRemoveDetail = useCallback( (detailClientId: string) => { if (!canEditTreatmentForDay) return;