Files
dyolink/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx

1274 lines
43 KiB
TypeScript

'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
import { ToastStack } from '@/components/ui/shared/Toast';
import { treatmentTypeLabelFromCatalog, treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
import {
addCalendarDays,
compareLocalDayStart,
isSameLocalCalendarDay,
startOfLocalDay,
} from '@/components/appointments/appointmentTime';
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 { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast';
import type { Organization } from '@/types/organization';
import type { AppointmentRecord } from '@/types/appointment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type {
FdiToothId,
LabCaseDraft,
LinkedOrganizationOption,
PastLabCase,
PastTreatment,
PastTreatmentCase,
TreatmentAppointment,
TreatmentDetailDraft,
} from '@/types/treatment';
type WorkspaceMode = 'live' | 'historical';
function isTreatmentDayHistorical(treatmentAt: string, todayStart: Date): boolean {
return compareLocalDayStart(new Date(treatmentAt), todayStart) < 0;
}
function withoutEmptyLabCaseDrafts(drafts: LabCaseDraft[]): LabCaseDraft[] {
return drafts.filter((lc) => lc.sentAt || Boolean(lc.detailClientId));
}
function labCaseDraftsToPast(
labCaseDrafts: LabCaseDraft[],
details: TreatmentDetailDraft[],
): PastLabCase[] {
return labCaseDrafts.map((lc) => {
const linkedDetail = lc.detailClientId
? details.find((d) => d.clientId === lc.detailClientId)
: undefined;
return {
id: lc.id ?? lc.clientId,
clientId: lc.clientId,
destinationOrganizationId: lc.destinationOrganizationId,
sentAt: lc.sentAt ?? null,
treatmentDetailId: linkedDetail?.id ?? null,
detail: linkedDetail
? {
id: linkedDetail.id ?? linkedDetail.clientId,
clientId: linkedDetail.clientId,
treatmentType: linkedDetail.treatmentType,
teeth: linkedDetail.teeth,
}
: null,
sends: lc.sends ?? [],
};
});
}
function enrichDetailsWithLabSendState(
details: TreatmentDetailDraft[],
labCaseDrafts: LabCaseDraft[],
): TreatmentDetailDraft[] {
return details.map((detail) => {
const sentLabCase = labCaseDrafts.find(
(lc) => lc.sentAt && lc.detailClientId === detail.clientId,
);
if (!sentLabCase) return detail;
return {
...detail,
labCaseId: sentLabCase.id ?? detail.labCaseId,
sentAt: sentLabCase.sentAt ?? detail.sentAt,
sends: sentLabCase.sends ?? detail.sends,
sendToOrganizationIds: sentLabCase.destinationOrganizationId
? [sentLabCase.destinationOrganizationId]
: detail.sendToOrganizationIds,
};
});
}
function buildWorkspaceSnapshot(
appointment: TreatmentAppointment,
details: TreatmentDetailDraft[],
labCaseDrafts: LabCaseDraft[],
title: string,
id?: string,
): PastTreatment {
const detailsForPreview = enrichDetailsWithLabSendState(details, labCaseDrafts);
return {
...detailsToPreviewTreatment(detailsForPreview, {
id: id ?? `preview-${appointment.id}`,
title,
patientId: appointment.patientId,
treatmentAt: appointment.startAt,
}),
appointmentId: appointment.id,
labCases: labCaseDraftsToPast(labCaseDrafts, details),
};
}
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 {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: defaultTreatmentType ?? 'restoration',
teeth: [],
comment: '',
attachmentMetas: [],
sendToOrganizationIds: [],
sentAt: null,
};
}
function newLabCaseDraft(): LabCaseDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `lab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
destinationOrganizationId: null,
detailClientId: null,
toothProsthesis: [],
attachmentIds: [],
sentAt: null,
sends: [],
};
}
function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
return {
id: record.id,
patientId: record.patientId,
patientFirstName: record.patient.firstName,
patientLastName: record.patient.lastName,
providerUserId: record.providerUserId,
startAt: record.startAt,
endAt: record.endAt,
purpose: record.purpose,
};
}
function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
return {
clientId: d.clientId,
id: d.id,
treatmentType: d.treatmentType,
teeth: d.teeth,
comment: d.notes ?? '',
attachmentMetas: d.attachmentMetas ?? [],
labCaseId: d.labCaseId ?? null,
sendToOrganizationIds: d.destinationOrganizationId ? [d.destinationOrganizationId] : [],
sends: d.sends ?? [],
sentAt: d.sentAt ?? null,
};
}
function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
return {
clientId: lc.clientId,
id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId,
detailClientId: lc.detail?.clientId ?? null,
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
detailClientId: lc.detail?.clientId ?? tp.treatmentDetailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
})),
attachmentIds: (lc.attachments ?? []).map((a) => a.id),
sentAt: lc.sentAt ?? null,
sends: lc.sends ?? [],
};
}
function serializeDetails(details: TreatmentDetailDraft[]) {
return JSON.stringify(
details.map((d) => ({
clientId: d.clientId,
id: d.id,
treatmentType: d.treatmentType,
teeth: d.teeth,
comment: d.comment,
attachmentMetas: d.attachmentMetas,
})),
);
}
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; id?: string },
): PastTreatment {
return {
id: meta.id ?? 'current-draft',
patientId: meta.patientId,
title: meta.title,
treatmentAt: meta.treatmentAt,
details: details.map((d, idx) => ({
id: d.id ?? d.clientId ?? `draft-${idx + 1}`,
clientId: d.clientId,
treatmentType: d.treatmentType,
teeth: d.teeth,
notes: d.comment || null,
attachmentMetas: d.attachmentMetas,
labCaseId: d.labCaseId ?? null,
destinationOrganizationId: d.sendToOrganizationIds[0] ?? null,
sends: d.sends ?? [],
sentAt: d.sentAt ?? null,
})),
labCases: [],
documents: [],
};
}
interface TreatmentWorkspaceProps {
userId: string;
currentOrganization: Organization | null;
}
export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWorkspaceProps) {
const t = useTranslations('treatment');
const { showError, showSuccess, messages: toastMessages } = useToast();
const canView = canViewTreatment(currentOrganization);
const canEdit = canEditTreatment(currentOrganization);
const [stripHidden, setStripHidden] = useState(false);
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
const [selectedDay, setSelectedDay] = useState(() => startOfLocalDay(new Date()));
const [appointments, setAppointments] = useState<TreatmentAppointment[]>([]);
const [apptsLoading, setApptsLoading] = useState(false);
const [selectionLocked, setSelectionLocked] = useState(false);
const [selectedAppointmentId, setSelectedAppointmentId] = useState<string | null>(null);
const [history, setHistory] = useState<PastTreatment[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyPatientId, setHistoryPatientId] = useState<string | null>(null);
const [orgs, setOrgs] = useState<LinkedOrganizationOption[]>([]);
const [labDependentCodes, setLabDependentCodes] = useState<Set<string>>(new Set());
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const treatmentDropdownCatalog = useMemo(
() => treatmentCatalog.filter((entry) => entry.availableInTreatment),
[treatmentCatalog],
);
const [details, setDetails] = useState<TreatmentDetailDraft[]>(() => [newDetail()]);
const [labCaseDrafts, setLabCaseDrafts] = useState<LabCaseDraft[]>([]);
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 [selectedPreviewId, setSelectedPreviewId] = useState<string | null>(null);
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('live');
const selectionLockedRef = useRef(selectionLocked);
selectionLockedRef.current = selectionLocked;
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 workspaceModeRef = useRef(workspaceMode);
workspaceModeRef.current = workspaceMode;
const labCaseDraftsRef = useRef(labCaseDrafts);
labCaseDraftsRef.current = labCaseDrafts;
const skipNextGetDraftRef = useRef(false);
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
const isDetailLocked = useCallback(
(detail: TreatmentDetailDraft) =>
labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientId === detail.clientId),
[labCaseDrafts],
);
const isDirty = useMemo(
() => isDetailsDirty(details, savedSnapshot),
[details, savedSnapshot],
);
const AUTOSAVE_DEBOUNCE_MS = 600;
const selectedAppointment = useMemo(
() => appointments.find((a) => a.id === selectedAppointmentId) ?? null,
[appointments, selectedAppointmentId],
);
const isViewingPastDay = useMemo(
() => compareLocalDayStart(selectedDay, todayStart) < 0,
[selectedDay, todayStart],
);
const canEditTreatmentForDay =
canEdit &&
Boolean(selectedAppointment) &&
!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 currentDraftPreview = useMemo<PastTreatment | null>(() => {
if (!selectedAppointment) return null;
return buildWorkspaceSnapshot(
selectedAppointment,
details,
labCaseDrafts,
t('treatmentPlanTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}),
'current-draft',
);
}, [details, labCaseDrafts, selectedAppointment, t]);
const previewTreatment = useMemo(() => {
if (!selectedPreviewId) return currentDraftPreview;
return historyPanelItems.find((item) => item.id === selectedPreviewId) ?? currentDraftPreview;
}, [selectedPreviewId, 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 hydrateFromTreatment = useCallback((treatment: PastTreatment) => {
const mapped = treatment.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));
const mappedLabCases = withoutEmptyLabCaseDrafts(
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
);
setLabCaseDrafts(mappedLabCases);
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
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(() => {
const set = new Set<FdiToothId>();
for (const detail of details) {
for (const tooth of detail.teeth) set.add(tooth);
}
return set;
}, [details]);
const wholePlanToothColors = useMemo(() => {
const colors: Partial<Record<FdiToothId, string>> = {};
for (let i = 0; i < details.length; i++) {
const detail = details[i];
const catalogIndex = treatmentCatalog.findIndex((e) => e.code === detail.treatmentType);
const color = treatmentTypeColor(detail.treatmentType, catalogIndex >= 0 ? catalogIndex : i);
for (const tooth of detail.teeth) {
if (!(tooth in colors)) colors[tooth] = color;
}
}
return colors;
}, [details, treatmentCatalog]);
const chartSelectedTeeth = showWholeTreatmentPlan ? wholePlanTeethSet : selectedTeethSet;
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
// Reset whole-plan overview when switching details.
useEffect(() => {
setShowWholeTreatmentPlan(false);
}, [activeDetailId]);
// Sync active lab shipment when the selected treatment detail changes.
useEffect(() => {
const match = labCaseDrafts.find((lc) => lc.detailClientId === activeDetailId);
setActiveLabCaseId(match?.clientId ?? null);
}, [activeDetailId, labCaseDrafts]);
useEffect(() => {
setSelectionLocked(false);
}, [selectedDay]);
useEffect(() => {
let cancelled = false;
setApptsLoading(true);
void (async () => {
try {
const dayStart = startOfLocalDay(selectedDay);
const dayEnd = addCalendarDays(dayStart, 1);
const response = await appointmentsApi.list({
from: dayStart.toISOString(),
to: dayEnd.toISOString(),
});
if (cancelled) return;
const list = response.data
.filter((a) => a.providerUserId === userId)
.map(mapAppointment);
setAppointments(list);
if (!selectionLockedRef.current) {
setSelectedAppointmentId(pickAutoAppointment(list, selectedDay));
}
} catch (error: unknown) {
if (!cancelled) {
showError(formatApiErrorMessage(error, t('errorLoadAppointments')));
}
} finally {
if (!cancelled) setApptsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [userId, selectedDay, showError, t]);
useEffect(() => {
const today = startOfLocalDay(new Date());
if (!isSameLocalCalendarDay(selectedDay, today) || selectionLocked) return;
const id = window.setInterval(() => {
setSelectedAppointmentId((prev) => {
const next = pickAutoAppointment(appointments, selectedDay);
return next ?? prev;
});
}, 60_000);
return () => window.clearInterval(id);
}, [selectedDay, appointments, selectionLocked]);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const [orgsResponse, catalogResponse] = await Promise.all([
treatmentsApi.listLinkedOrganizations(),
treatmentCatalogApi.list(),
]);
if (cancelled) return;
setOrgs(orgsResponse.data);
setTreatmentCatalog(catalogResponse.data);
setLabDependentCodes(
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
);
} catch (error: unknown) {
if (!cancelled) {
showError(formatApiErrorMessage(error, t('errorLoadOrgs')));
}
}
})();
return () => {
cancelled = true;
};
}, [showError, t]);
useEffect(() => {
if (!selectedAppointment?.patientId) {
setHistoryPatientId(null);
setHistory([]);
setHistoryLoading(false);
return;
}
setHistoryPatientId(selectedAppointment.patientId);
}, [selectedAppointment?.patientId]);
useEffect(() => {
if (!historyPatientId) return;
let cancelled = false;
setHistoryLoading(true);
void (async () => {
try {
const response = await treatmentsApi.listPatientHistory(historyPatientId);
if (!cancelled) setHistory(response.data);
} catch (error: unknown) {
if (!cancelled) {
showError(formatApiErrorMessage(error, t('errorLoadHistory')));
}
} finally {
if (!cancelled) setHistoryLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [historyPatientId, showError, t]);
useEffect(() => {
const appointmentId = selectedAppointment?.id;
if (!appointmentId || workspaceMode !== 'live') return;
if (skipNextGetDraftRef.current) {
skipNextGetDraftRef.current = false;
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);
if (cancelled) return;
if (response.data?.details?.length) {
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;
});
setSavedSnapshot(serializeDetails(mapped));
} else {
const first = newDetail(
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
);
setDetails([first]);
setActiveDetailId(first.clientId);
setSavedSnapshot(serializeDetails([first]));
}
const mappedLabCases = withoutEmptyLabCaseDrafts(
(response.data?.labCases ?? []).map(mapLabCaseDraftFromApi),
);
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, selectedAppointment?.purpose, workspaceMode, treatmentCatalog, showError, 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('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,
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 refreshHistory = useCallback(async (patientId: string) => {
try {
const response = await treatmentsApi.listPatientHistory(patientId);
setHistory(response.data);
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorLoadHistory')));
}
}, [showError, t]);
const flushDraftSave = useCallback(async (): Promise<boolean> => {
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
autosaveTimerRef.current = null;
}
if (workspaceModeRef.current !== 'live' || !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();
if (historyPatientId) {
await refreshHistory(historyPatientId);
}
return true;
} catch {
return window.confirm(t('confirmDiscard'));
}
}, [selectedAppointment, canEditTreatmentForDay, runDraftSave, historyPatientId, refreshHistory, 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 resetToLiveContext = useCallback(() => {
setWorkspaceMode('live');
setSelectedPreviewId(null);
}, []);
const onPickAppointment = useCallback(
(id: string) => {
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
resetToLiveContext();
setSelectionLocked(true);
setSelectedAppointmentId(id);
})();
},
[flushDraftSave, resetToLiveContext],
);
const onSelectDay = useCallback(
(day: Date) => {
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
const patientIdToRefresh = historyPatientId;
resetToLiveContext();
setSelectedDay(day);
if (patientIdToRefresh) {
await refreshHistory(patientIdToRefresh);
}
})();
},
[flushDraftSave, resetToLiveContext, historyPatientId, refreshHistory],
);
const handleSelectPreviewTreatment = useCallback((treatment: PastTreatment) => {
setSelectedPreviewId(treatment.id);
}, []);
const handleOpenTreatment = useCallback(() => {
void (async () => {
const treatment = previewTreatment;
if (!treatment?.appointmentId) {
showError(t('errorNoAppointmentForTreatment'));
return;
}
if (isPreviewAlreadyOpen) return;
const ok = workspaceModeRef.current === 'live' ? await flushDraftSave() : true;
if (!ok) return;
const isHistorical = isTreatmentDayHistorical(treatment.treatmentAt, todayStart);
setWorkspaceMode(isHistorical ? 'historical' : 'live');
setSelectedPreviewId(treatment.id);
setSelectedDay(startOfLocalDay(new Date(treatment.treatmentAt)));
setSelectionLocked(true);
setSelectedAppointmentId(treatment.appointmentId);
skipNextGetDraftRef.current = true;
draftHydratingRef.current = true;
hydrateFromTreatment(treatment);
draftHydratingRef.current = false;
})();
}, [
previewTreatment,
isPreviewAlreadyOpen,
flushDraftSave,
hydrateFromTreatment,
showError,
t,
todayStart,
]);
const uploadForDetail = useCallback(
async (detailClientId: string, files: FileList | File[]) => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
const list = files instanceof FileList ? Array.from(files) : files;
if (!list.length) return;
setUploadBusyDetailId(detailClientId);
try {
const uploaded = await treatmentsApi.uploadCaseAttachments(
selectedAppointment.id,
detailClientId,
list,
);
setDetails((prev) =>
prev.map((d) =>
d.clientId === detailClientId
? { ...d, attachmentMetas: [...d.attachmentMetas, ...uploaded.data] }
: d,
),
);
showSuccess(t('successFilesUploaded', { count: uploaded.data.length }));
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorUpload')));
} finally {
setUploadBusyDetailId(null);
}
},
[canEditTreatmentForDay, selectedAppointment, showSuccess, showError, t],
);
const persistLabCases = useCallback(
async (savedTreatment: PastTreatment, draftsOverride?: LabCaseDraft[]) => {
if (!selectedAppointment) throw new Error('No appointment selected');
const drafts = draftsOverride ?? labCaseDrafts;
const detailIdByClientId = new Map(
savedTreatment.details.map((d) => [d.clientId, d.id]),
);
const payload = drafts
.map((lc) => {
if (!lc.detailClientId) return null;
const treatmentDetailId = detailIdByClientId.get(lc.detailClientId);
if (!treatmentDetailId) return null;
return {
clientId: lc.clientId,
id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
treatmentDetailId,
toothProsthesis: lc.toothProsthesis
.map((tp) => {
const detailId = detailIdByClientId.get(tp.detailClientId);
if (!detailId) return null;
return {
treatmentDetailId: detailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
};
})
.filter(
(row): row is { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string } =>
row !== null,
),
attachmentIds: lc.attachmentIds,
};
})
.filter((row): row is NonNullable<typeof row> => row !== null);
if (payload.length === 0) {
return savedTreatment;
}
const response = await treatmentsApi.saveLabCases(selectedAppointment.id, {
labCases: payload,
});
const mapped = withoutEmptyLabCaseDrafts(response.data.labCases.map(mapLabCaseDraftFromApi));
setLabCaseDrafts(mapped);
setActiveLabCaseId((prev) => {
if (prev && mapped.some((lc) => lc.clientId === prev)) return prev;
return mapped[0]?.clientId ?? null;
});
return response.data;
},
[labCaseDrafts, selectedAppointment],
);
const handleLabCasesChange = useCallback(
(next: LabCaseDraft[]) => {
const prevCleaned = withoutEmptyLabCaseDrafts(labCaseDrafts);
const cleaned = withoutEmptyLabCaseDrafts(next);
setLabCaseDrafts(cleaned);
if (!cleaned.some((lc) => lc.detailClientId === activeDetailId)) {
setActiveLabCaseId((prev) =>
prev && cleaned.some((lc) => lc.clientId === prev) ? prev : null,
);
}
const removedPersistedDraft = prevCleaned.some(
(lc) => lc.id && !cleaned.some((row) => row.clientId === lc.clientId),
);
if (
removedPersistedDraft &&
selectedAppointment &&
canEditTreatmentForDay
) {
void (async () => {
try {
const saved = await persistDraft({ force: true });
await persistLabCases(saved, cleaned);
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
}
})();
}
},
[
activeDetailId,
canEditTreatmentForDay,
labCaseDrafts,
persistDraft,
persistLabCases,
selectedAppointment,
showError,
t,
],
);
const handleAddLabCase = useCallback(async () => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
const cleaned = withoutEmptyLabCaseDrafts(labCaseDrafts);
const existing = cleaned.find(
(lc) => !lc.sentAt && lc.detailClientId === activeDetailId,
);
if (existing) {
setActiveLabCaseId(existing.clientId);
return;
}
const activeDetail = details.find((d) => d.clientId === activeDetailId);
const shouldIncludeActive =
Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType));
const orphan = cleaned.find((lc) => !lc.sentAt && !lc.detailClientId);
if (orphan && shouldIncludeActive) {
const updatedLabCases = cleaned.map((lc) =>
lc.clientId === orphan.clientId ? { ...lc, detailClientId: activeDetailId } : lc,
);
setLabCaseDrafts(updatedLabCases);
setActiveLabCaseId(orphan.clientId);
try {
const saved = await persistDraft({ force: true });
await persistLabCases(saved, updatedLabCases);
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
}
return;
}
const next: LabCaseDraft = {
...newLabCaseDraft(),
detailClientId: shouldIncludeActive ? activeDetailId : null,
};
const updatedLabCases = [...cleaned, next];
setLabCaseDrafts(updatedLabCases);
setActiveLabCaseId(next.clientId);
try {
const saved = await persistDraft({ force: true });
await persistLabCases(saved, updatedLabCases);
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
}
}, [
activeDetailId,
canEditTreatmentForDay,
details,
labCaseDrafts,
labDependentCodes,
persistDraft,
persistLabCases,
selectedAppointment,
showError,
t,
]);
const handleSendLabCase = useCallback(
async (labCase: LabCaseDraft, comment?: string) => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
if (!labCase.destinationOrganizationId) {
showError(t('errorChooseOrg'));
return;
}
if (!labCase.detailClientId) {
showError(t('errorLabCaseNeedsDetails'));
return;
}
setSendBusyId(labCase.clientId);
try {
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(
(lc) => lc.clientId === labCase.clientId || lc.id === labCase.id,
);
if (!refreshedLabCase?.id) throw new Error(t('errorSendCase'));
const trimmedComment = comment?.trim();
if (trimmedComment) {
await treatmentsApi.addLabCaseComment(refreshedLabCase.id, { body: trimmedComment });
}
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,
};
}),
);
setLabCaseDrafts((prev) =>
prev.map((lc) =>
lc.clientId === labCase.clientId
? {
...lc,
id: response.data.id,
sentAt: response.data.sentAt,
destinationOrganizationId: response.data.destinationOrganizationId,
sends: response.data.sends,
}
: lc,
),
);
setRecentOrganizationIds((prev) => {
const orgId = labCase.destinationOrganizationId!;
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
});
showSuccess(t('successCaseSent'));
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorSendCase')));
} finally {
setSendBusyId(null);
}
},
[
canEditTreatmentForDay,
selectedAppointment,
persistDraft,
persistLabCases,
showSuccess,
showError,
t,
],
);
if (!canView) {
return (
<div className="surface-card p-6 max-w-xl">
<h2 className="text-lg font-semibold text-text-primary">{t('noPermissionTitle')}</h2>
<p className="text-sm text-text-secondary mt-2">{t('noPermissionBody')}</p>
</div>
);
}
return (
<div className="space-y-4">
<header className="space-y-1">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary">
{canEdit ? t('subtitleEditPhase4') : t('subtitleReadOnly')}
</p>
</header>
<ToastStack {...toastMessages} />
<AppointmentsStrip
stripHidden={stripHidden}
onToggleStripHidden={() => setStripHidden((s) => !s)}
selectedDay={selectedDay}
onSelectDay={onSelectDay}
appointments={appointments}
selectedAppointmentId={selectedAppointmentId}
onSelectAppointment={onPickAppointment}
treatmentCatalog={treatmentCatalog}
loading={apptsLoading}
/>
{workspaceMode === 'historical' && (
<p className="text-sm text-emerald-700 dark:text-emerald-400 rounded-[var(--radius-md)] border border-emerald-500/40 bg-emerald-500/10 px-3 py-2">
{t('historicalReadonlyNotice')}
</p>
)}
{isViewingPastDay && workspaceMode === 'live' && (
<p className="text-sm text-text-secondary rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/50 px-3 py-2">
{t('pastDayNotice')}
</p>
)}
<div className="grid grid-cols-1 xl:grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-4 items-start">
<div className="space-y-3 min-w-0 xl:max-w-[380px]">
{selectedAppointment ? (
<div className="surface-card p-3 space-y-0.5">
<p className="text-[10px] uppercase tracking-wide text-text-muted">{t('selectedPatient')}</p>
<p className="text-base font-semibold text-text-primary">
{selectedAppointment.patientFirstName} {selectedAppointment.patientLastName}
</p>
<p className="text-[11px] text-text-secondary">
{t('purposeLabel')}{' '}
<span className="text-text-primary">
{treatmentTypeLabelFromCatalog(selectedAppointment.purpose, treatmentCatalog)}
</span>
</p>
</div>
) : (
<div className="surface-card p-3 text-sm text-text-muted">
{apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')}
</div>
)}
<TreatmentPreviewCard
treatment={previewTreatment}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
orgs={orgs}
openDisabled={isPreviewAlreadyOpen}
onOpen={handleOpenTreatment}
/>
<PastTreatmentsPanel
items={historyPanelItems}
treatmentCatalog={treatmentCatalog}
loading={historyLoading}
selectedPreviewId={selectedPreviewId}
onSelectTreatment={handleSelectPreviewTreatment}
/>
</div>
<div className="space-y-3 min-w-0 w-full">
<FdiToothChart
selected={chartSelectedTeeth}
toothColors={chartToothColors}
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>
) : undefined
}
onToggle={(fdi) => {
if (!canEditTreatmentForDay || isDetailLocked(activeDetail) || showWholeTreatmentPlan) return;
setDetails((prev) =>
prev.map((d) => {
if (d.clientId !== activeDetailId) return d;
const set = new Set(d.teeth);
if (set.has(fdi)) set.delete(fdi);
else set.add(fdi);
return { ...d, teeth: [...set].sort() as FdiToothId[] };
}),
);
}}
disabled={!canEditTreatmentForDay || isDetailLocked(activeDetail)}
/>
<TreatmentDetailsEditor
details={details}
activeDetailId={activeDetailId}
onActiveDetailChange={setActiveDetailId}
onDetailsChange={setDetails}
isDetailLocked={isDetailLocked}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentDropdownCatalog}
disabled={!canEditTreatmentForDay}
canEdit={canEdit}
saveStatus={saveStatus}
uploadBusy={uploadBusyDetailId === activeDetailId}
onAddDetail={() => {
const next = newDetail(
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
);
setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId);
}}
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
/>
<LabCasesDispatchPanel
details={details}
activeDetailId={activeDetailId}
labCases={labCaseDrafts}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
activeLabCaseId={activeLabCaseId}
onLabCasesChange={handleLabCasesChange}
disabled={!canEditTreatmentForDay}
canEdit={canEdit}
orgs={orgs}
organizationSearch={organizationSearch}
onOrganizationSearchChange={setOrganizationSearch}
recentOrganizationIds={recentOrganizationIds}
onRecentOrganizationPick={(orgId) => {
setLabCaseDrafts((prev) => {
const targetId =
activeLabCaseId ??
prev.find((lc) => !lc.sentAt && lc.detailClientId === activeDetailId)
?.clientId;
if (!targetId) return prev;
return prev.map((lc) =>
lc.clientId === targetId && !lc.sentAt
? { ...lc, destinationOrganizationId: orgId }
: lc,
);
});
}}
sendBusyId={sendBusyId}
onAddLabCase={() => void handleAddLabCase()}
onSendLabCase={(lc, comment) => void handleSendLabCase(lc, comment)}
onCommentError={showError}
/>
</div>
</div>
</div>
);
}