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')} +

-

{t('labDispatchSaveHint')}

-
- )} )} 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 && ( -
- -

- {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)} />