diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 72695a5..19821a4 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -463,6 +463,7 @@
"sendThisCase": "Send this case",
"labDispatchTitle": "Lab dispatch",
"labDispatchSubtitle": "Group lab-dependent details into shipments and send them to linked labs.",
+ "labDispatchSendHint": "Shipment grouping is saved when you send to the lab.",
"addLabShipment": "Add lab shipment",
"labShipmentLabel": "Shipment {n}",
"includeDetails": "Include treatment details",
@@ -482,6 +483,9 @@
"saveDraft": "Save treatment draft",
"unsavedChanges": "Unsaved changes",
"draftSaved": "Draft saved",
+ "saveStatusSaving": "Saving…",
+ "saveStatusSaved": "All changes saved",
+ "saveStatusError": "Could not save — check your connection",
"sendSavesFirst": "Sending is per case and saves first automatically.",
"historyTitle": "Previous treatments",
"historySubtitle": "Completed treatments for this patient. Each case is listed separately.",
diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json
index 6c0560f..6302772 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -463,6 +463,7 @@
"sendThisCase": "ارسال این پرونده",
"labDispatchTitle": "ارسال به لابراتوار",
"labDispatchSubtitle": "جزئیات وابسته به لاب را در محمولهها گروهبندی کرده و به لابراتوارهای متصل ارسال کنید.",
+ "labDispatchSendHint": "گروهبندی محموله هنگام ارسال به لاب ذخیره میشود.",
"addLabShipment": "افزودن محموله لاب",
"labShipmentLabel": "محموله {n}",
"includeDetails": "شامل جزئیات درمان",
@@ -482,6 +483,9 @@
"saveDraft": "ذخیره پیشنویس درمان",
"unsavedChanges": "تغییرات ذخیرهنشده",
"draftSaved": "پیشنویس ذخیره شد",
+ "saveStatusSaving": "در حال ذخیره…",
+ "saveStatusSaved": "همه تغییرات ذخیره شد",
+ "saveStatusError": "ذخیره ناموفق بود — اتصال را بررسی کنید",
"sendSavesFirst": "ارسال برای هر پرونده به صورت جداگانه است و ابتدا به طور خودکار ذخیره میکند.",
"historyTitle": "درمانهای قبلی",
"historySubtitle": "درمانهای تکمیل شده برای این بیمار. هر پرونده به طور جداگانه فهرست شده است.",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index c22dbfb..917473f 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -463,6 +463,7 @@
"sendThisCase": "Verzend deze case",
"labDispatchTitle": "Lab-dispatch",
"labDispatchSubtitle": "Groepeer lab-afhankelijke details in zendingen en stuur ze naar gekoppelde labs.",
+ "labDispatchSendHint": "Groepering wordt opgeslagen wanneer u naar het lab verzendt.",
"addLabShipment": "Labzending toevoegen",
"labShipmentLabel": "Zending {n}",
"includeDetails": "Behandeldetails opnemen",
@@ -482,6 +483,9 @@
"saveDraft": "Behandelconcept opslaan",
"unsavedChanges": "Niet-opgeslagen wijzigingen",
"draftSaved": "Concept opgeslagen",
+ "saveStatusSaving": "Opslaan…",
+ "saveStatusSaved": "Alle wijzigingen opgeslagen",
+ "saveStatusError": "Opslaan mislukt — controleer uw verbinding",
"sendSavesFirst": "Verzenden is per case en slaat eerst automatisch op.",
"historyTitle": "Eerdere behandelingen",
"historySubtitle": "Voltooide behandelingen voor deze patiënt. Elke case wordt afzonderlijk weergegeven.",
diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
index 068173f..f0aec59 100644
--- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
+++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx
@@ -25,9 +25,7 @@ interface LabCasesDispatchPanelProps {
recentOrganizationIds: string[];
onRecentOrganizationPick: (orgId: string) => void;
sendBusyId: string | null;
- saveLabBusy: boolean;
onAddLabCase: () => void;
- onSaveLabCases: () => void;
onSendLabCase: (labCase: LabCaseDraft) => void;
}
@@ -46,9 +44,7 @@ export function LabCasesDispatchPanel({
recentOrganizationIds,
onRecentOrganizationPick,
sendBusyId,
- saveLabBusy,
onAddLabCase,
- onSaveLabCases,
onSendLabCase,
}: LabCasesDispatchPanelProps) {
const t = useTranslations('treatment');
@@ -133,7 +129,9 @@ export function LabCasesDispatchPanel({
{t('labDispatchTitle')}
-
{t('labDispatchSubtitle')}
+
+ {t('labDispatchSubtitle')} {t('labDispatchSendHint')}
+
diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
index 768586d..b3693fb 100644
--- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx
@@ -15,12 +15,10 @@ interface TreatmentDetailsEditorProps {
isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
disabled: boolean;
canEdit: boolean;
- isDirty: boolean;
- saveBusy: boolean;
+ saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
uploadBusy: boolean;
onAddDetail: () => void;
onPreview: () => void;
- onSave: () => void;
onUploadFiles: (files: FileList | null) => void;
}
@@ -32,12 +30,10 @@ export function TreatmentDetailsEditor({
isDetailLocked,
disabled,
canEdit,
- isDirty,
- saveBusy,
+ saveStatus,
uploadBusy,
onAddDetail,
onPreview,
- onSave,
onUploadFiles,
}: TreatmentDetailsEditorProps) {
const t = useTranslations('treatment');
@@ -175,21 +171,19 @@ export function TreatmentDetailsEditor({
- {canEdit && (
-
-
- {t('saveDraft')}
-
-
- {isDirty ? t('unsavedChanges') : t('draftSaved')}. {t('detailsSaveHint')}
-
-
+ {canEdit && saveStatus !== 'idle' && (
+
+ {saveStatus === 'dirty' && t('unsavedChanges')}
+ {saveStatus === 'saving' && t('saveStatusSaving')}
+ {saveStatus === 'saved' && t('saveStatusSaved')}
+ {saveStatus === 'error' && t('saveStatusError')}
+
)}
);
diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
index 083dbff..fb6a251 100644
--- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
+++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx
@@ -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(() => details[0].clientId);
const [activeLabCaseId, setActiveLabCaseId] = useState(null);
const [savedSnapshot, setSavedSnapshot] = useState(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 | null>(null);
+ const saveInFlightRef = useRef(false);
+ const saveQueuedRef = useRef(false);
+ const draftHydratingRef = useRef(false);
+
const [sendBusyId, setSendBusyId] = useState(null);
const [uploadBusyDetailId, setUploadBusyDetailId] = useState(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 => {
+ 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)}
/>