feature/cases #55

Merged
rameen merged 18 commits from feature/cases into master 2026-07-10 18:26:56 +03:30
6 changed files with 211 additions and 123 deletions
Showing only changes of commit 7b8ed48fa0 - Show all commits

View File

@@ -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.",

View File

@@ -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": "درمان‌های تکمیل شده برای این بیمار. هر پرونده به طور جداگانه فهرست شده است.",

View File

@@ -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.",

View File

@@ -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({
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<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>
<Button
type="button"
@@ -299,20 +297,6 @@ export function LabCasesDispatchPanel({
</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>

View File

@@ -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({
</div>
</div>
{canEdit && (
<div className="flex flex-wrap gap-3 pt-2 border-t border-border/60">
<Button
type="button"
variant="primary"
disabled={disabled || saveBusy}
isLoading={saveBusy}
onClick={onSave}
>
{t('saveDraft')}
</Button>
<p className="text-xs text-text-muted self-center">
{isDirty ? t('unsavedChanges') : t('draftSaved')}. {t('detailsSaveHint')}
</p>
</div>
{canEdit && saveStatus !== 'idle' && (
<p
className={`text-xs pt-2 border-t border-border/60 ${
saveStatus === 'error' ? 'text-red-500' : 'text-text-muted'
}`}
role="status"
aria-live="polite"
>
{saveStatus === 'dirty' && t('unsavedChanges')}
{saveStatus === 'saving' && t('saveStatusSaving')}
{saveStatus === 'saved' && t('saveStatusSaved')}
{saveStatus === 'error' && t('saveStatusError')}
</p>
)}
</div>
);

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>