improvement: treatment UX fully overhauled.

This commit is contained in:
2026-07-13 01:03:26 +03:30
parent abf0371a5b
commit f28cd06615
27 changed files with 1370 additions and 212 deletions

View File

@@ -3,9 +3,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice';
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
@@ -20,7 +24,17 @@ import { appointmentsApi } from '@/lib/api/appointments';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentsApi } from '@/lib/api/treatments';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import { canEditTreatment, canViewTreatment } from '@/components/shared/permissions';
import {
areDetailsPersistable,
defaultTreatmentTypeForAppointment,
isDetailReadyForLabDispatch,
isEmptyDraftDetail,
isLabDependentDetailMissingTeeth,
} from '@/components/treatment/treatmentDetailRules';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
import type { Organization } from '@/types/organization';
@@ -116,24 +130,13 @@ function buildWorkspaceSnapshot(
};
}
function defaultTreatmentTypeForAppointment(
purpose: string | undefined,
catalog: TreatmentCatalogEntry[],
): TreatmentDetailDraft['treatmentType'] {
const treatmentOptions = catalog.filter((entry) => entry.availableInTreatment);
if (purpose && treatmentOptions.some((entry) => entry.code === purpose)) {
return purpose as TreatmentDetailDraft['treatmentType'];
}
return (treatmentOptions[0]?.code ?? 'restoration') as TreatmentDetailDraft['treatmentType'];
}
function newDetail(defaultTreatmentType?: TreatmentDetailDraft['treatmentType']): TreatmentDetailDraft {
function newDetail(defaultTreatmentType?: string): TreatmentDetailDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: defaultTreatmentType ?? 'restoration',
treatmentType: defaultTreatmentType ?? '',
teeth: [],
comment: '',
attachmentMetas: [],
@@ -179,6 +182,7 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
comment: d.notes ?? '',
attachmentMetas: d.attachmentMetas ?? [],
labCaseId: d.labCaseId ?? null,
taskProgress: d.taskProgress ?? null,
sendToOrganizationIds: d.destinationOrganizationId ? [d.destinationOrganizationId] : [],
sends: d.sends ?? [],
sentAt: d.sentAt ?? null,
@@ -220,7 +224,7 @@ function isDetailsDirty(
savedSnapshot: string | null,
): boolean {
if (savedSnapshot === null) {
return details.length !== 1 || details[0].comment !== '' || details[0].teeth.length > 0;
return details.some((d) => !isEmptyDraftDetail(d)) || details.length > 1;
}
return serializeDetails(details) !== savedSnapshot;
}
@@ -242,6 +246,7 @@ function detailsToPreviewTreatment(
notes: d.comment || null,
attachmentMetas: d.attachmentMetas,
labCaseId: d.labCaseId ?? null,
taskProgress: d.taskProgress ?? null,
destinationOrganizationId: d.sendToOrganizationIds[0] ?? null,
sends: d.sends ?? [],
sentAt: d.sentAt ?? null,
@@ -316,6 +321,8 @@ export function TreatmentWorkspace({
labCaseDraftsRef.current = labCaseDrafts;
const skipNextGetDraftRef = useRef(false);
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
const labPanelRef = useRef<HTMLDivElement>(null);
const historyRequestRef = useRef(0);
useEffect(() => {
pendingAppointmentIdRef.current = initialAppointmentId;
@@ -360,18 +367,25 @@ export function TreatmentWorkspace({
!isViewingPastDay &&
workspaceMode === 'live';
const historyPanelItems = useMemo(() => {
return history.filter((item) => {
if (
workspaceMode === 'live' &&
selectedAppointmentId &&
item.appointmentId === selectedAppointmentId
) {
return false;
}
return true;
});
}, [history, selectedAppointmentId, workspaceMode]);
const historyPanelItems = history;
const activeDetail = useMemo(
() => details.find((d) => d.clientId === activeDetailId) ?? details[0] ?? null,
[details, activeDetailId],
);
const showLabDispatchPanel = useMemo(
() => details.some((d) => isDetailReadyForLabDispatch(d, labDependentCodes)),
[details, labDependentCodes],
);
const showLabShipmentBlocked = useMemo(
() =>
Boolean(
activeDetail && isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes),
),
[activeDetail, labDependentCodes],
);
const currentDraftPreview = useMemo<PastTreatment | null>(() => {
if (!selectedAppointment) return null;
@@ -388,17 +402,29 @@ export function TreatmentWorkspace({
const previewTreatment = useMemo(() => {
if (!selectedPreviewId) return currentDraftPreview;
return historyPanelItems.find((item) => item.id === selectedPreviewId) ?? currentDraftPreview;
}, [selectedPreviewId, historyPanelItems, currentDraftPreview]);
return (
history.find((item) => item.id === selectedPreviewId) ??
historyPanelItems.find((item) => item.id === selectedPreviewId) ??
currentDraftPreview
);
}, [selectedPreviewId, history, historyPanelItems, currentDraftPreview]);
const isPreviewAlreadyOpen = useMemo(() => {
if (!previewTreatment?.appointmentId || !selectedAppointmentId) return false;
if (selectedAppointmentId !== previewTreatment.appointmentId) return false;
if (workspaceMode === 'historical') return true;
if (workspaceMode === 'live' && selectedPreviewId === null) return true;
if (workspaceMode === 'live' && selectedPreviewId === previewTreatment.id) return true;
return false;
}, [previewTreatment, selectedAppointmentId, workspaceMode, selectedPreviewId]);
const isBrowsing = selectedPreviewId !== null;
const previewHeading = isBrowsing
? t('previewBrowsingTitle')
: t('previewCurrentDraft');
const labAttentionItems = useMemo(
() =>
collectLabDispatchAttention(
labDependentCodes,
currentDraftPreview,
history,
selectedAppointmentId,
),
[labDependentCodes, currentDraftPreview, history, selectedAppointmentId],
);
const hydrateFromTreatment = useCallback((treatment: PastTreatment) => {
const mapped = treatment.details.map(mapDetailFromApi);
@@ -417,11 +443,6 @@ export function TreatmentWorkspace({
setSaveStatus('idle');
}, []);
const activeDetail = useMemo(
() => details.find((d) => d.clientId === activeDetailId) ?? details[0],
[details, activeDetailId],
);
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const wholePlanTeethSet = useMemo(() => {
@@ -459,10 +480,6 @@ export function TreatmentWorkspace({
setActiveLabCaseId(match?.clientId ?? null);
}, [activeDetailId, labCaseDrafts]);
useEffect(() => {
setSelectionLocked(false);
}, [selectedDay]);
useEffect(() => {
let cancelled = false;
setApptsLoading(true);
@@ -550,28 +567,34 @@ export function TreatmentWorkspace({
setHistoryLoading(false);
return;
}
setHistoryPatientId(selectedAppointment.patientId);
const nextPatientId = selectedAppointment.patientId;
setHistoryPatientId((prev) => {
if (prev !== nextPatientId) {
setHistory([]);
setHistoryLoading(true);
}
return nextPatientId;
});
}, [selectedAppointment?.patientId]);
useEffect(() => {
if (!historyPatientId) return;
let cancelled = false;
const requestId = ++historyRequestRef.current;
setHistoryLoading(true);
void (async () => {
try {
const response = await treatmentsApi.listPatientHistory(historyPatientId);
if (!cancelled) setHistory(response.data);
const response = await treatmentsApi.listPatientHistory(historyPatientId, 50);
if (requestId !== historyRequestRef.current) return;
setHistory(response.data);
} catch (error: unknown) {
if (!cancelled) {
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
}
if (requestId !== historyRequestRef.current) return;
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
} finally {
if (!cancelled) setHistoryLoading(false);
if (requestId === historyRequestRef.current) {
setHistoryLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [historyPatientId, showError, t]);
useEffect(() => {
@@ -652,6 +675,16 @@ export function TreatmentWorkspace({
});
}
if (!areDetailsPersistable(currentDetails)) {
return detailsToPreviewTreatment(currentDetails, {
title: t('treatmentPlanTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}),
patientId: selectedAppointment.patientId,
treatmentAt: selectedAppointment.startAt,
});
}
const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
details: currentDetails.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
clientId,
@@ -674,6 +707,18 @@ export function TreatmentWorkspace({
[selectedAppointment, t],
);
const refreshHistory = useCallback(async (patientId: string) => {
const requestId = ++historyRequestRef.current;
try {
const response = await treatmentsApi.listPatientHistory(patientId, 50);
if (requestId !== historyRequestRef.current) return;
setHistory(response.data);
} catch (error: unknown) {
if (requestId !== historyRequestRef.current) return;
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
}
}, [showError, t]);
const runDraftSave = useCallback(async () => {
if (!selectedAppointment || saveInFlightRef.current) {
if (saveInFlightRef.current) saveQueuedRef.current = true;
@@ -681,7 +726,8 @@ export function TreatmentWorkspace({
}
if (
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current)
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current) ||
!areDetailsPersistable(detailsRef.current)
) {
return;
}
@@ -691,6 +737,9 @@ export function TreatmentWorkspace({
try {
await persistDraft();
setSaveStatus('saved');
if (historyPatientId) {
await refreshHistory(historyPatientId);
}
} catch (error: unknown) {
setSaveStatus('error');
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
@@ -704,16 +753,7 @@ export function TreatmentWorkspace({
}
}
}
}, [selectedAppointment, persistDraft, showError, t]);
const refreshHistory = useCallback(async (patientId: string) => {
try {
const response = await treatmentsApi.listPatientHistory(patientId);
setHistory(response.data);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
}
}, [showError, t]);
}, [selectedAppointment, persistDraft, showError, t, historyPatientId, refreshHistory]);
const flushDraftSave = useCallback(async (): Promise<boolean> => {
if (autosaveTimerRef.current) {
@@ -735,14 +775,11 @@ export function TreatmentWorkspace({
try {
await runDraftSave();
if (historyPatientId) {
await refreshHistory(historyPatientId);
}
return true;
} catch {
return window.confirm(t('confirmDiscard'));
}
}, [selectedAppointment, canEditTreatmentForDay, runDraftSave, historyPatientId, refreshHistory, t]);
}, [selectedAppointment, canEditTreatmentForDay, runDraftSave, t]);
useEffect(() => {
if (draftHydratingRef.current || !canEditTreatmentForDay || !selectedAppointment?.id) {
@@ -787,6 +824,8 @@ export function TreatmentWorkspace({
[flushDraftSave, resetToLiveContext],
);
const canAccessOrganizations = canAccessDashboardRoute(currentOrganization, '/organizations');
const onSelectDay = useCallback(
(day: Date) => {
void (async () => {
@@ -794,7 +833,8 @@ export function TreatmentWorkspace({
if (!ok) return;
const patientIdToRefresh = historyPatientId;
resetToLiveContext();
setSelectedDay(day);
setSelectionLocked(false);
setSelectedDay(startOfLocalDay(day));
if (patientIdToRefresh) {
await refreshHistory(patientIdToRefresh);
}
@@ -807,22 +847,23 @@ export function TreatmentWorkspace({
setSelectedPreviewId(treatment.id);
}, []);
const handleOpenTreatment = useCallback(() => {
void (async () => {
const treatment = previewTreatment;
if (!treatment?.appointmentId) {
const exitBrowse = useCallback(() => {
setSelectedPreviewId(null);
}, []);
const loadTreatmentIntoWorkspace = useCallback(
async (treatment: PastTreatment, focusDetailClientId?: string) => {
if (!treatment.appointmentId) {
showError(t('errorNoAppointmentForTreatment'));
return;
return false;
}
if (isPreviewAlreadyOpen) return;
const ok = workspaceModeRef.current === 'live' ? await flushDraftSave() : true;
if (!ok) return;
if (!ok) return false;
const isHistorical = isTreatmentDayHistorical(treatment.treatmentAt, todayStart);
setWorkspaceMode(isHistorical ? 'historical' : 'live');
setSelectedPreviewId(treatment.id);
setSelectedPreviewId(null);
setSelectedDay(startOfLocalDay(new Date(treatment.treatmentAt)));
setSelectionLocked(true);
setSelectedAppointmentId(treatment.appointmentId);
@@ -831,16 +872,58 @@ export function TreatmentWorkspace({
draftHydratingRef.current = true;
hydrateFromTreatment(treatment);
draftHydratingRef.current = false;
})();
}, [
previewTreatment,
isPreviewAlreadyOpen,
flushDraftSave,
hydrateFromTreatment,
showError,
t,
todayStart,
]);
if (focusDetailClientId) {
setActiveDetailId(focusDetailClientId);
const mappedLabCases = withoutEmptyLabCaseDrafts(
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
);
const linked = mappedLabCases.find(
(lc) => !lc.sentAt && lc.detailClientId === focusDetailClientId,
);
if (linked) {
setActiveLabCaseId(linked.clientId);
}
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
}
return true;
},
[flushDraftSave, hydrateFromTreatment, showError, t, todayStart],
);
const handleLoadIntoWorkspace = useCallback(() => {
if (!previewTreatment) return;
void loadTreatmentIntoWorkspace(previewTreatment);
}, [loadTreatmentIntoWorkspace, previewTreatment]);
const handleGoToLabDispatch = useCallback(
(item: LabDispatchAttentionItem) => {
if (item.isCurrentDraft) {
exitBrowse();
setActiveDetailId(item.detailClientId);
const linked = labCaseDrafts.find(
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
);
if (linked) {
setActiveLabCaseId(linked.clientId);
}
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
return;
}
const treatment =
history.find((entry) => entry.id === item.treatmentId) ??
historyPanelItems.find((entry) => entry.id === item.treatmentId);
if (!treatment) return;
void loadTreatmentIntoWorkspace(treatment, item.detailClientId);
},
[exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace],
);
const uploadForDetail = useCallback(
async (detailClientId: string, files: FileList | File[]) => {
@@ -984,8 +1067,17 @@ export function TreatmentWorkspace({
}
const activeDetail = details.find((d) => d.clientId === activeDetailId);
const shouldIncludeActive =
Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType));
if (
activeDetail &&
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)
) {
showError(t('labShipmentBlockedBody'));
return;
}
const shouldIncludeActive = Boolean(
activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes),
);
const orphan = cleaned.find((lc) => !lc.sentAt && !lc.detailClientId);
if (orphan && shouldIncludeActive) {
@@ -1068,21 +1160,28 @@ export function TreatmentWorkspace({
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
const sentDetailClientId = labCase.detailClientId;
setDetails((prev) =>
prev.map((detail) => {
if (detail.clientId !== sentDetailClientId) return detail;
return {
...detail,
labCaseId: response.data.id,
sentAt: response.data.sentAt,
sends: response.data.sends,
sendToOrganizationIds: response.data.destinationOrganizationId
? [response.data.destinationOrganizationId]
: detail.sendToOrganizationIds,
};
}),
);
const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id);
if (draftResponse.data?.details?.length) {
const mapped = draftResponse.data.details.map(mapDetailFromApi);
setDetails(mapped);
setSavedSnapshot(serializeDetails(mapped));
} else {
const sentDetailClientId = labCase.detailClientId;
setDetails((prev) =>
prev.map((detail) => {
if (detail.clientId !== sentDetailClientId) return detail;
return {
...detail,
labCaseId: response.data.id,
sentAt: response.data.sentAt,
sends: response.data.sends,
sendToOrganizationIds: response.data.destinationOrganizationId
? [response.data.destinationOrganizationId]
: detail.sendToOrganizationIds,
};
}),
);
}
setLabCaseDrafts((prev) =>
prev.map((lc) =>
@@ -1183,18 +1282,59 @@ export function TreatmentWorkspace({
</div>
)}
<LabDispatchAttentionPanel
items={labAttentionItems}
treatmentCatalog={treatmentCatalog}
labDependentCodes={labDependentCodes}
orgs={orgs}
onGoToDispatch={handleGoToLabDispatch}
/>
{isBrowsing && previewTreatment ? (
<div className="rounded-[var(--radius-md)] border border-primary/40 bg-primary/5 px-3 py-3 space-y-3">
<p className="text-sm text-text-primary">
{t('browseBanner', {
date: new Date(previewTreatment.treatmentAt).toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
}),
})}
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Button type="button" variant="primary" onClick={handleLoadIntoWorkspace}>
{t('loadIntoWorkspace')}
</Button>
<Button type="button" variant="ghost" onClick={exitBrowse}>
{t('backToCurrentDraft')}
</Button>
</div>
</div>
) : null}
<TreatmentPreviewCard
treatment={previewTreatment}
heading={previewHeading}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
orgs={orgs}
openDisabled={isPreviewAlreadyOpen}
onOpen={handleOpenTreatment}
/>
<PastTreatmentsPanel
items={historyPanelItems}
currentDraft={
workspaceMode === 'live' && !isBrowsing ? currentDraftPreview : null
}
patientName={
selectedAppointment
? `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`
: undefined
}
currentAppointmentId={selectedAppointmentId}
treatmentCatalog={treatmentCatalog}
labDependentCodes={labDependentCodes}
orgs={orgs}
loading={historyLoading}
selectedPreviewId={selectedPreviewId}
onSelectTreatment={handleSelectPreviewTreatment}
@@ -1208,15 +1348,12 @@ export function TreatmentWorkspace({
readOnly={showWholeTreatmentPlan}
headerControl={
details.length > 1 ? (
<label className="flex items-center gap-2 text-[11px] text-text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={showWholeTreatmentPlan}
onChange={(e) => setShowWholeTreatmentPlan(e.target.checked)}
className="rounded border-border"
/>
{t('toothChartWholePlan')}
</label>
<Checkbox
checked={showWholeTreatmentPlan}
onChange={setShowWholeTreatmentPlan}
label={t('toothChartWholePlan')}
className="text-[11px] [&_span:last-child]:text-[11px] [&_span:last-child]:text-text-muted"
/>
) : undefined
}
onToggle={(fdi) => {
@@ -1254,8 +1391,12 @@ export function TreatmentWorkspace({
setActiveDetailId(next.clientId);
}}
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
onCommentError={showError}
/>
<div ref={labPanelRef}>
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
{showLabDispatchPanel ? (
<LabCasesDispatchPanel
details={details}
activeDetailId={activeDetailId}
@@ -1288,7 +1429,11 @@ export function TreatmentWorkspace({
onAddLabCase={() => void handleAddLabCase()}
onSendLabCase={(lc, comment) => void handleSendLabCase(lc, comment)}
onCommentError={showError}
canInviteLab={canAccessOrganizations}
onInviteLab={() => router.push('/organizations?action=invite-lab')}
/>
) : null}
</div>
</div>
</div>
</div>