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],
|
[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({
|
const voice = useVoiceCapture({
|
||||||
// The locale the clinician is actually reading and speaking in. Sent explicitly so
|
// 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(
|
const handleRemoveDetail = useCallback(
|
||||||
(detailClientId: string) => {
|
(detailClientId: string) => {
|
||||||
if (!canEditTreatmentForDay) return;
|
if (!canEditTreatmentForDay) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user