improvement: treatment plan now save on debounce. save treatment button removed.
This commit is contained in:
@@ -463,6 +463,7 @@
|
|||||||
"sendThisCase": "Send this case",
|
"sendThisCase": "Send this case",
|
||||||
"labDispatchTitle": "Lab dispatch",
|
"labDispatchTitle": "Lab dispatch",
|
||||||
"labDispatchSubtitle": "Group lab-dependent details into shipments and send them to linked labs.",
|
"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",
|
"addLabShipment": "Add lab shipment",
|
||||||
"labShipmentLabel": "Shipment {n}",
|
"labShipmentLabel": "Shipment {n}",
|
||||||
"includeDetails": "Include treatment details",
|
"includeDetails": "Include treatment details",
|
||||||
@@ -482,6 +483,9 @@
|
|||||||
"saveDraft": "Save treatment draft",
|
"saveDraft": "Save treatment draft",
|
||||||
"unsavedChanges": "Unsaved changes",
|
"unsavedChanges": "Unsaved changes",
|
||||||
"draftSaved": "Draft saved",
|
"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.",
|
"sendSavesFirst": "Sending is per case and saves first automatically.",
|
||||||
"historyTitle": "Previous treatments",
|
"historyTitle": "Previous treatments",
|
||||||
"historySubtitle": "Completed treatments for this patient. Each case is listed separately.",
|
"historySubtitle": "Completed treatments for this patient. Each case is listed separately.",
|
||||||
|
|||||||
@@ -463,6 +463,7 @@
|
|||||||
"sendThisCase": "ارسال این پرونده",
|
"sendThisCase": "ارسال این پرونده",
|
||||||
"labDispatchTitle": "ارسال به لابراتوار",
|
"labDispatchTitle": "ارسال به لابراتوار",
|
||||||
"labDispatchSubtitle": "جزئیات وابسته به لاب را در محمولهها گروهبندی کرده و به لابراتوارهای متصل ارسال کنید.",
|
"labDispatchSubtitle": "جزئیات وابسته به لاب را در محمولهها گروهبندی کرده و به لابراتوارهای متصل ارسال کنید.",
|
||||||
|
"labDispatchSendHint": "گروهبندی محموله هنگام ارسال به لاب ذخیره میشود.",
|
||||||
"addLabShipment": "افزودن محموله لاب",
|
"addLabShipment": "افزودن محموله لاب",
|
||||||
"labShipmentLabel": "محموله {n}",
|
"labShipmentLabel": "محموله {n}",
|
||||||
"includeDetails": "شامل جزئیات درمان",
|
"includeDetails": "شامل جزئیات درمان",
|
||||||
@@ -482,6 +483,9 @@
|
|||||||
"saveDraft": "ذخیره پیشنویس درمان",
|
"saveDraft": "ذخیره پیشنویس درمان",
|
||||||
"unsavedChanges": "تغییرات ذخیرهنشده",
|
"unsavedChanges": "تغییرات ذخیرهنشده",
|
||||||
"draftSaved": "پیشنویس ذخیره شد",
|
"draftSaved": "پیشنویس ذخیره شد",
|
||||||
|
"saveStatusSaving": "در حال ذخیره…",
|
||||||
|
"saveStatusSaved": "همه تغییرات ذخیره شد",
|
||||||
|
"saveStatusError": "ذخیره ناموفق بود — اتصال را بررسی کنید",
|
||||||
"sendSavesFirst": "ارسال برای هر پرونده به صورت جداگانه است و ابتدا به طور خودکار ذخیره میکند.",
|
"sendSavesFirst": "ارسال برای هر پرونده به صورت جداگانه است و ابتدا به طور خودکار ذخیره میکند.",
|
||||||
"historyTitle": "درمانهای قبلی",
|
"historyTitle": "درمانهای قبلی",
|
||||||
"historySubtitle": "درمانهای تکمیل شده برای این بیمار. هر پرونده به طور جداگانه فهرست شده است.",
|
"historySubtitle": "درمانهای تکمیل شده برای این بیمار. هر پرونده به طور جداگانه فهرست شده است.",
|
||||||
|
|||||||
@@ -463,6 +463,7 @@
|
|||||||
"sendThisCase": "Verzend deze case",
|
"sendThisCase": "Verzend deze case",
|
||||||
"labDispatchTitle": "Lab-dispatch",
|
"labDispatchTitle": "Lab-dispatch",
|
||||||
"labDispatchSubtitle": "Groepeer lab-afhankelijke details in zendingen en stuur ze naar gekoppelde labs.",
|
"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",
|
"addLabShipment": "Labzending toevoegen",
|
||||||
"labShipmentLabel": "Zending {n}",
|
"labShipmentLabel": "Zending {n}",
|
||||||
"includeDetails": "Behandeldetails opnemen",
|
"includeDetails": "Behandeldetails opnemen",
|
||||||
@@ -482,6 +483,9 @@
|
|||||||
"saveDraft": "Behandelconcept opslaan",
|
"saveDraft": "Behandelconcept opslaan",
|
||||||
"unsavedChanges": "Niet-opgeslagen wijzigingen",
|
"unsavedChanges": "Niet-opgeslagen wijzigingen",
|
||||||
"draftSaved": "Concept opgeslagen",
|
"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.",
|
"sendSavesFirst": "Verzenden is per case en slaat eerst automatisch op.",
|
||||||
"historyTitle": "Eerdere behandelingen",
|
"historyTitle": "Eerdere behandelingen",
|
||||||
"historySubtitle": "Voltooide behandelingen voor deze patiënt. Elke case wordt afzonderlijk weergegeven.",
|
"historySubtitle": "Voltooide behandelingen voor deze patiënt. Elke case wordt afzonderlijk weergegeven.",
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ interface LabCasesDispatchPanelProps {
|
|||||||
recentOrganizationIds: string[];
|
recentOrganizationIds: string[];
|
||||||
onRecentOrganizationPick: (orgId: string) => void;
|
onRecentOrganizationPick: (orgId: string) => void;
|
||||||
sendBusyId: string | null;
|
sendBusyId: string | null;
|
||||||
saveLabBusy: boolean;
|
|
||||||
onAddLabCase: () => void;
|
onAddLabCase: () => void;
|
||||||
onSaveLabCases: () => void;
|
|
||||||
onSendLabCase: (labCase: LabCaseDraft) => void;
|
onSendLabCase: (labCase: LabCaseDraft) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,9 +44,7 @@ export function LabCasesDispatchPanel({
|
|||||||
recentOrganizationIds,
|
recentOrganizationIds,
|
||||||
onRecentOrganizationPick,
|
onRecentOrganizationPick,
|
||||||
sendBusyId,
|
sendBusyId,
|
||||||
saveLabBusy,
|
|
||||||
onAddLabCase,
|
onAddLabCase,
|
||||||
onSaveLabCases,
|
|
||||||
onSendLabCase,
|
onSendLabCase,
|
||||||
}: LabCasesDispatchPanelProps) {
|
}: LabCasesDispatchPanelProps) {
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
@@ -133,7 +129,9 @@ export function LabCasesDispatchPanel({
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
|
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
|
||||||
<p className="text-xs text-text-muted mt-0.5">{t('labDispatchSubtitle')}</p>
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
|
{t('labDispatchSubtitle')} {t('labDispatchSendHint')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -299,20 +297,6 @@ export function LabCasesDispatchPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canEdit && (
|
|
||||||
<div className="flex flex-wrap gap-3 pt-2 border-t border-border/60">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={disabled || saveLabBusy}
|
|
||||||
isLoading={saveLabBusy}
|
|
||||||
onClick={onSaveLabCases}
|
|
||||||
>
|
|
||||||
{t('saveLabShipments')}
|
|
||||||
</Button>
|
|
||||||
<p className="text-xs text-text-muted self-center">{t('labDispatchSaveHint')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,12 +15,10 @@ interface TreatmentDetailsEditorProps {
|
|||||||
isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
|
isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
isDirty: boolean;
|
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
|
||||||
saveBusy: boolean;
|
|
||||||
uploadBusy: boolean;
|
uploadBusy: boolean;
|
||||||
onAddDetail: () => void;
|
onAddDetail: () => void;
|
||||||
onPreview: () => void;
|
onPreview: () => void;
|
||||||
onSave: () => void;
|
|
||||||
onUploadFiles: (files: FileList | null) => void;
|
onUploadFiles: (files: FileList | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,12 +30,10 @@ export function TreatmentDetailsEditor({
|
|||||||
isDetailLocked,
|
isDetailLocked,
|
||||||
disabled,
|
disabled,
|
||||||
canEdit,
|
canEdit,
|
||||||
isDirty,
|
saveStatus,
|
||||||
saveBusy,
|
|
||||||
uploadBusy,
|
uploadBusy,
|
||||||
onAddDetail,
|
onAddDetail,
|
||||||
onPreview,
|
onPreview,
|
||||||
onSave,
|
|
||||||
onUploadFiles,
|
onUploadFiles,
|
||||||
}: TreatmentDetailsEditorProps) {
|
}: TreatmentDetailsEditorProps) {
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
@@ -175,21 +171,19 @@ export function TreatmentDetailsEditor({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && saveStatus !== 'idle' && (
|
||||||
<div className="flex flex-wrap gap-3 pt-2 border-t border-border/60">
|
<p
|
||||||
<Button
|
className={`text-xs pt-2 border-t border-border/60 ${
|
||||||
type="button"
|
saveStatus === 'error' ? 'text-red-500' : 'text-text-muted'
|
||||||
variant="primary"
|
}`}
|
||||||
disabled={disabled || saveBusy}
|
role="status"
|
||||||
isLoading={saveBusy}
|
aria-live="polite"
|
||||||
onClick={onSave}
|
>
|
||||||
>
|
{saveStatus === 'dirty' && t('unsavedChanges')}
|
||||||
{t('saveDraft')}
|
{saveStatus === 'saving' && t('saveStatusSaving')}
|
||||||
</Button>
|
{saveStatus === 'saved' && t('saveStatusSaved')}
|
||||||
<p className="text-xs text-text-muted self-center">
|
{saveStatus === 'error' && t('saveStatusError')}
|
||||||
{isDirty ? t('unsavedChanges') : t('draftSaved')}. {t('detailsSaveHint')}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
function detailsToPreviewTreatment(
|
||||||
details: TreatmentDetailDraft[],
|
details: TreatmentDetailDraft[],
|
||||||
meta: { title: string; patientId: string; treatmentAt: string; status: string; id?: string },
|
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 [activeDetailId, setActiveDetailId] = useState<string>(() => details[0].clientId);
|
||||||
const [activeLabCaseId, setActiveLabCaseId] = useState<string | null>(null);
|
const [activeLabCaseId, setActiveLabCaseId] = useState<string | null>(null);
|
||||||
const [savedSnapshot, setSavedSnapshot] = 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);
|
const selectionLockedRef = useRef(selectionLocked);
|
||||||
selectionLockedRef.current = selectionLocked;
|
selectionLockedRef.current = selectionLocked;
|
||||||
|
|
||||||
const [saveBusy, setSaveBusy] = useState(false);
|
const detailsRef = useRef(details);
|
||||||
const [saveLabBusy, setSaveLabBusy] = useState(false);
|
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 [sendBusyId, setSendBusyId] = useState<string | null>(null);
|
||||||
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
|
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
|
||||||
const [organizationSearch, setOrganizationSearch] = useState('');
|
const [organizationSearch, setOrganizationSearch] = useState('');
|
||||||
@@ -201,12 +219,12 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
[labCaseDrafts],
|
[labCaseDrafts],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isDirty = useMemo(() => {
|
const isDirty = useMemo(
|
||||||
if (savedSnapshot === null) {
|
() => isDetailsDirty(details, savedSnapshot),
|
||||||
return details.length !== 1 || details[0].comment !== '' || details[0].teeth.length > 0;
|
[details, savedSnapshot],
|
||||||
}
|
);
|
||||||
return serializeDetails(details) !== savedSnapshot;
|
|
||||||
}, [details, savedSnapshot]);
|
const AUTOSAVE_DEBOUNCE_MS = 600;
|
||||||
|
|
||||||
const selectedAppointment = useMemo(
|
const selectedAppointment = useMemo(
|
||||||
() => appointments.find((a) => a.id === selectedAppointmentId) ?? null,
|
() => appointments.find((a) => a.id === selectedAppointmentId) ?? null,
|
||||||
@@ -342,6 +360,12 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
if (!appointmentId) return;
|
if (!appointmentId) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
draftHydratingRef.current = true;
|
||||||
|
if (autosaveTimerRef.current) {
|
||||||
|
clearTimeout(autosaveTimerRef.current);
|
||||||
|
autosaveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await treatmentsApi.getDraft(appointmentId);
|
const response = await treatmentsApi.getDraft(appointmentId);
|
||||||
@@ -366,37 +390,165 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
setLabCaseDrafts(mappedLabCases);
|
setLabCaseDrafts(mappedLabCases);
|
||||||
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
|
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
|
||||||
setOrganizationSearch('');
|
setOrganizationSearch('');
|
||||||
|
setSaveStatus('idle');
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
showError(formatApiErrorMessage(error, t('errorLoadDraft')));
|
showError(formatApiErrorMessage(error, t('errorLoadDraft')));
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
draftHydratingRef.current = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
draftHydratingRef.current = false;
|
||||||
};
|
};
|
||||||
}, [selectedAppointment?.id, showError, t]);
|
}, [selectedAppointment?.id, showError, t]);
|
||||||
|
|
||||||
const confirmDiscardIfDirty = useCallback(() => {
|
const persistDraft = useCallback(
|
||||||
if (!isDirty) return true;
|
async (options?: { force?: boolean }) => {
|
||||||
return window.confirm(t('confirmDiscard'));
|
if (!selectedAppointment) throw new Error('No appointment selected');
|
||||||
}, [isDirty, t]);
|
|
||||||
|
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(
|
const onPickAppointment = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
if (!confirmDiscardIfDirty()) return;
|
void (async () => {
|
||||||
setSelectionLocked(true);
|
const ok = await flushDraftSave();
|
||||||
setSelectedAppointmentId(id);
|
if (!ok) return;
|
||||||
|
setSelectionLocked(true);
|
||||||
|
setSelectedAppointmentId(id);
|
||||||
|
})();
|
||||||
},
|
},
|
||||||
[confirmDiscardIfDirty],
|
[flushDraftSave],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSelectDay = useCallback(
|
const onSelectDay = useCallback(
|
||||||
(day: Date) => {
|
(day: Date) => {
|
||||||
if (!confirmDiscardIfDirty()) return;
|
void (async () => {
|
||||||
setSelectedDay(day);
|
const ok = await flushDraftSave();
|
||||||
|
if (!ok) return;
|
||||||
|
setSelectedDay(day);
|
||||||
|
})();
|
||||||
},
|
},
|
||||||
[confirmDiscardIfDirty],
|
[flushDraftSave],
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadForDetail = useCallback(
|
const uploadForDetail = useCallback(
|
||||||
@@ -429,29 +581,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
[canEditTreatmentForDay, selectedAppointment, showSuccess, showError, t],
|
[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(
|
const persistLabCases = useCallback(
|
||||||
async (savedTreatment: PastTreatment) => {
|
async (savedTreatment: PastTreatment) => {
|
||||||
if (!selectedAppointment) throw new Error('No appointment selected');
|
if (!selectedAppointment) throw new Error('No appointment selected');
|
||||||
@@ -488,41 +617,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
[labCaseDrafts, selectedAppointment],
|
[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(
|
const handleSendLabCase = useCallback(
|
||||||
async (labCase: LabCaseDraft) => {
|
async (labCase: LabCaseDraft) => {
|
||||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||||
@@ -537,7 +631,15 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
|
|
||||||
setSendBusyId(labCase.clientId);
|
setSendBusyId(labCase.clientId);
|
||||||
try {
|
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 afterLabCases = await persistLabCases(saved);
|
||||||
|
|
||||||
const refreshedLabCase = afterLabCases.labCases.find(
|
const refreshedLabCase = afterLabCases.labCases.find(
|
||||||
@@ -693,8 +795,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
isDetailLocked={isDetailLocked}
|
isDetailLocked={isDetailLocked}
|
||||||
disabled={!canEditTreatmentForDay}
|
disabled={!canEditTreatmentForDay}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
isDirty={isDirty}
|
saveStatus={saveStatus}
|
||||||
saveBusy={saveBusy}
|
|
||||||
uploadBusy={uploadBusyDetailId === activeDetailId}
|
uploadBusy={uploadBusyDetailId === activeDetailId}
|
||||||
onAddDetail={() => {
|
onAddDetail={() => {
|
||||||
const next = newDetail();
|
const next = newDetail();
|
||||||
@@ -702,7 +803,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
setActiveDetailId(next.clientId);
|
setActiveDetailId(next.clientId);
|
||||||
}}
|
}}
|
||||||
onPreview={openCurrentDraftPreview}
|
onPreview={openCurrentDraftPreview}
|
||||||
onSave={() => void handleSaveAll()}
|
|
||||||
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
|
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -730,13 +830,11 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
sendBusyId={sendBusyId}
|
sendBusyId={sendBusyId}
|
||||||
saveLabBusy={saveLabBusy}
|
|
||||||
onAddLabCase={() => {
|
onAddLabCase={() => {
|
||||||
const next = newLabCaseDraft();
|
const next = newLabCaseDraft();
|
||||||
setLabCaseDrafts((prev) => [...prev, next]);
|
setLabCaseDrafts((prev) => [...prev, next]);
|
||||||
setActiveLabCaseId(next.clientId);
|
setActiveLabCaseId(next.clientId);
|
||||||
}}
|
}}
|
||||||
onSaveLabCases={() => void handleSaveLabCases()}
|
|
||||||
onSendLabCase={(lc) => void handleSendLabCase(lc)}
|
onSendLabCase={(lc) => void handleSendLabCase(lc)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user