fix(frontend): persist the lab case a voice result creates
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string>(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<string>(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;
|
||||
|
||||
Reference in New Issue
Block a user