improvement: treatment plan now save on debounce. save treatment button removed.

This commit is contained in:
2026-06-28 18:08:29 +03:30
parent 478cfa085a
commit 7b8ed48fa0
6 changed files with 211 additions and 123 deletions

View File

@@ -122,6 +122,16 @@ function serializeDetails(details: TreatmentDetailDraft[]) {
);
}
function isDetailsDirty(
details: TreatmentDetailDraft[],
savedSnapshot: string | null,
): boolean {
if (savedSnapshot === null) {
return details.length !== 1 || details[0].comment !== '' || details[0].teeth.length > 0;
}
return serializeDetails(details) !== savedSnapshot;
}
function detailsToPreviewTreatment(
details: TreatmentDetailDraft[],
meta: { title: string; patientId: string; treatmentAt: string; status: string; id?: string },
@@ -180,12 +190,20 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [activeDetailId, setActiveDetailId] = useState<string>(() => details[0].clientId);
const [activeLabCaseId, setActiveLabCaseId] = useState<string | null>(null);
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'dirty' | 'saving' | 'saved' | 'error'>('idle');
const selectionLockedRef = useRef(selectionLocked);
selectionLockedRef.current = selectionLocked;
const [saveBusy, setSaveBusy] = useState(false);
const [saveLabBusy, setSaveLabBusy] = useState(false);
const detailsRef = useRef(details);
detailsRef.current = details;
const savedSnapshotRef = useRef(savedSnapshot);
savedSnapshotRef.current = savedSnapshot;
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveInFlightRef = useRef(false);
const saveQueuedRef = useRef(false);
const draftHydratingRef = useRef(false);
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
const [organizationSearch, setOrganizationSearch] = useState('');
@@ -201,12 +219,12 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[labCaseDrafts],
);
const isDirty = useMemo(() => {
if (savedSnapshot === null) {
return details.length !== 1 || details[0].comment !== '' || details[0].teeth.length > 0;
}
return serializeDetails(details) !== savedSnapshot;
}, [details, savedSnapshot]);
const isDirty = useMemo(
() => isDetailsDirty(details, savedSnapshot),
[details, savedSnapshot],
);
const AUTOSAVE_DEBOUNCE_MS = 600;
const selectedAppointment = useMemo(
() => appointments.find((a) => a.id === selectedAppointmentId) ?? null,
@@ -342,6 +360,12 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
if (!appointmentId) return;
let cancelled = false;
draftHydratingRef.current = true;
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
autosaveTimerRef.current = null;
}
void (async () => {
try {
const response = await treatmentsApi.getDraft(appointmentId);
@@ -366,37 +390,165 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
setLabCaseDrafts(mappedLabCases);
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
setSaveStatus('idle');
} catch (error: unknown) {
if (!cancelled) {
showError(formatApiErrorMessage(error, t('errorLoadDraft')));
}
} finally {
if (!cancelled) {
draftHydratingRef.current = false;
}
}
})();
return () => {
cancelled = true;
draftHydratingRef.current = false;
};
}, [selectedAppointment?.id, showError, t]);
const confirmDiscardIfDirty = useCallback(() => {
if (!isDirty) return true;
return window.confirm(t('confirmDiscard'));
}, [isDirty, t]);
const persistDraft = useCallback(
async (options?: { force?: boolean }) => {
if (!selectedAppointment) throw new Error('No appointment selected');
const currentDetails = detailsRef.current;
const dirty = isDetailsDirty(currentDetails, savedSnapshotRef.current);
if (!options?.force && !dirty) {
return detailsToPreviewTreatment(currentDetails, {
title: t('draftTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}),
patientId: selectedAppointment.patientId,
treatmentAt: selectedAppointment.startAt,
status: 'draft',
});
}
const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
details: currentDetails.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
clientId,
id,
treatmentType,
teeth,
comment,
attachmentIds: attachmentMetas.map((a) => a.id),
})),
});
const mapped = response.data.details.map(mapDetailFromApi);
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev;
});
setSavedSnapshot(serializeDetails(mapped));
return response.data;
},
[selectedAppointment, t],
);
const runDraftSave = useCallback(async () => {
if (!selectedAppointment || saveInFlightRef.current) {
if (saveInFlightRef.current) saveQueuedRef.current = true;
return;
}
if (
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current)
) {
return;
}
saveInFlightRef.current = true;
setSaveStatus('saving');
try {
await persistDraft();
setSaveStatus('saved');
} catch (error: unknown) {
setSaveStatus('error');
showError(formatApiErrorMessage(error, t('errorSaveDraft')));
throw error;
} finally {
saveInFlightRef.current = false;
if (saveQueuedRef.current) {
saveQueuedRef.current = false;
if (isDetailsDirty(detailsRef.current, savedSnapshotRef.current)) {
void runDraftSave();
}
}
}
}, [selectedAppointment, persistDraft, showError, t]);
const flushDraftSave = useCallback(async (): Promise<boolean> => {
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
autosaveTimerRef.current = null;
}
if (!selectedAppointment || !canEditTreatmentForDay) return true;
while (saveInFlightRef.current) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
if (!isDetailsDirty(detailsRef.current, savedSnapshotRef.current)) {
return true;
}
try {
await runDraftSave();
return true;
} catch {
return window.confirm(t('confirmDiscard'));
}
}, [selectedAppointment, canEditTreatmentForDay, runDraftSave, t]);
useEffect(() => {
if (draftHydratingRef.current || !canEditTreatmentForDay || !selectedAppointment?.id) {
return;
}
if (!isDirty) {
return;
}
setSaveStatus('dirty');
if (autosaveTimerRef.current) clearTimeout(autosaveTimerRef.current);
autosaveTimerRef.current = setTimeout(() => {
autosaveTimerRef.current = null;
void runDraftSave();
}, AUTOSAVE_DEBOUNCE_MS);
return () => {
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
autosaveTimerRef.current = null;
}
};
}, [details, isDirty, canEditTreatmentForDay, selectedAppointment?.id, runDraftSave]);
const onPickAppointment = useCallback(
(id: string) => {
if (!confirmDiscardIfDirty()) return;
setSelectionLocked(true);
setSelectedAppointmentId(id);
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
setSelectionLocked(true);
setSelectedAppointmentId(id);
})();
},
[confirmDiscardIfDirty],
[flushDraftSave],
);
const onSelectDay = useCallback(
(day: Date) => {
if (!confirmDiscardIfDirty()) return;
setSelectedDay(day);
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
setSelectedDay(day);
})();
},
[confirmDiscardIfDirty],
[flushDraftSave],
);
const uploadForDetail = useCallback(
@@ -429,29 +581,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[canEditTreatmentForDay, selectedAppointment, showSuccess, showError, t],
);
const persistDraft = useCallback(async () => {
if (!selectedAppointment) throw new Error('No appointment selected');
const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
details: details.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
clientId,
id,
treatmentType,
teeth,
comment,
attachmentIds: attachmentMetas.map((a) => a.id),
})),
});
const mapped = response.data.details.map(mapDetailFromApi);
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev;
});
setSavedSnapshot(serializeDetails(mapped));
return response.data;
}, [details, selectedAppointment]);
const persistLabCases = useCallback(
async (savedTreatment: PastTreatment) => {
if (!selectedAppointment) throw new Error('No appointment selected');
@@ -488,41 +617,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[labCaseDrafts, selectedAppointment],
);
const handleSaveAll = useCallback(async () => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
setSaveBusy(true);
try {
await persistDraft();
showSuccess(t('successDraftSaved'));
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorSaveDraft')));
} finally {
setSaveBusy(false);
}
}, [canEditTreatmentForDay, selectedAppointment, persistDraft, showSuccess, showError, t]);
const handleSaveLabCases = useCallback(async () => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
setSaveLabBusy(true);
try {
const saved = await persistDraft();
await persistLabCases(saved);
showSuccess(t('successLabShipmentsSaved'));
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
} finally {
setSaveLabBusy(false);
}
}, [
canEditTreatmentForDay,
selectedAppointment,
persistDraft,
persistLabCases,
showSuccess,
showError,
t,
]);
const handleSendLabCase = useCallback(
async (labCase: LabCaseDraft) => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
@@ -537,7 +631,15 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
setSendBusyId(labCase.clientId);
try {
const saved = await persistDraft();
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
autosaveTimerRef.current = null;
}
while (saveInFlightRef.current) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
const saved = await persistDraft({ force: true });
const afterLabCases = await persistLabCases(saved);
const refreshedLabCase = afterLabCases.labCases.find(
@@ -693,8 +795,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
isDetailLocked={isDetailLocked}
disabled={!canEditTreatmentForDay}
canEdit={canEdit}
isDirty={isDirty}
saveBusy={saveBusy}
saveStatus={saveStatus}
uploadBusy={uploadBusyDetailId === activeDetailId}
onAddDetail={() => {
const next = newDetail();
@@ -702,7 +803,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
setActiveDetailId(next.clientId);
}}
onPreview={openCurrentDraftPreview}
onSave={() => void handleSaveAll()}
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
/>
@@ -730,13 +830,11 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
);
}}
sendBusyId={sendBusyId}
saveLabBusy={saveLabBusy}
onAddLabCase={() => {
const next = newLabCaseDraft();
setLabCaseDrafts((prev) => [...prev, next]);
setActiveLabCaseId(next.clientId);
}}
onSaveLabCases={() => void handleSaveLabCases()}
onSendLabCase={(lc) => void handleSendLabCase(lc)}
/>
</div>