improvement: v1 standalone treatment/case creation made possible.
This commit is contained in:
@@ -33,6 +33,7 @@ import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||
import { treatmentsApi } from '@/lib/api/treatments';
|
||||
import { notificationsApi } from '@/lib/api/notifications';
|
||||
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
|
||||
import type { DayStripItem } from '@/components/treatment/dayStrip';
|
||||
import {
|
||||
areDetailsPersistable,
|
||||
defaultTreatmentTypeForAppointment,
|
||||
@@ -59,7 +60,7 @@ import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithi
|
||||
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
|
||||
import { tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
|
||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
||||
import { APP_DATE, formatAppDate } from '@/lib/i18n/format';
|
||||
import { APP_DATE, formatAppDate, formatAppTimeRange } from '@/lib/i18n/format';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||
import { useToast } from '@/lib/hooks/useToast';
|
||||
@@ -144,7 +145,7 @@ function enrichDetailsWithLabSendState(
|
||||
}
|
||||
|
||||
function buildWorkspaceSnapshot(
|
||||
appointment: TreatmentAppointment,
|
||||
ctx: { id: string; patientId: string; treatmentAt: string; appointmentId: string | null },
|
||||
details: TreatmentDetailDraft[],
|
||||
labCaseDrafts: LabCaseDraft[],
|
||||
title: string,
|
||||
@@ -153,12 +154,12 @@ function buildWorkspaceSnapshot(
|
||||
const detailsForPreview = enrichDetailsWithLabSendState(details, labCaseDrafts);
|
||||
return {
|
||||
...detailsToPreviewTreatment(detailsForPreview, {
|
||||
id: id ?? `preview-${appointment.id}`,
|
||||
id: id ?? `preview-${ctx.id}`,
|
||||
title,
|
||||
patientId: appointment.patientId,
|
||||
treatmentAt: appointment.startAt,
|
||||
patientId: ctx.patientId,
|
||||
treatmentAt: ctx.treatmentAt,
|
||||
}),
|
||||
appointmentId: appointment.id,
|
||||
appointmentId: ctx.appointmentId,
|
||||
labCases: labCaseDraftsToPast(labCaseDrafts, details),
|
||||
};
|
||||
}
|
||||
@@ -233,7 +234,7 @@ function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
|
||||
destinationOrganizationId: lc.destinationOrganizationId,
|
||||
detailClientId: lc.detail?.clientId ?? null,
|
||||
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
|
||||
detailClientId: lc.detail?.clientId ?? tp.treatmentDetailId,
|
||||
detailClientId: lc.detail?.clientId ?? tp.treatmentDetailId ?? '',
|
||||
tooth: tp.tooth,
|
||||
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||
selectionGroupId: tp.selectionGroupId ?? '',
|
||||
@@ -349,9 +350,11 @@ export function TreatmentWorkspace({
|
||||
|
||||
const [selectedDay, setSelectedDay] = useState(() => startOfLocalDay(new Date()));
|
||||
const [appointments, setAppointments] = useState<TreatmentAppointment[]>([]);
|
||||
const [standaloneTreatments, setStandaloneTreatments] = useState<PastTreatment[]>([]);
|
||||
const [apptsLoading, setApptsLoading] = useState(false);
|
||||
const [selectionLocked, setSelectionLocked] = useState(false);
|
||||
const [selectedAppointmentId, setSelectedAppointmentId] = useState<string | null>(null);
|
||||
const [selectedStandaloneId, setSelectedStandaloneId] = useState<string | null>(null);
|
||||
|
||||
const [history, setHistory] = useState<PastTreatment[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
@@ -460,6 +463,16 @@ export function TreatmentWorkspace({
|
||||
[appointments, selectedAppointmentId],
|
||||
);
|
||||
|
||||
const selectedStandalone = useMemo(
|
||||
() => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null,
|
||||
[standaloneTreatments, selectedStandaloneId],
|
||||
);
|
||||
|
||||
const liveTreatmentId = selectedStandalone?.id ?? null;
|
||||
const hasLiveContext = Boolean(selectedAppointment || selectedStandalone);
|
||||
|
||||
const walkInLabel = t('walkIn');
|
||||
|
||||
const activePatient = useMemo(() => {
|
||||
if (selectedAppointment) {
|
||||
return {
|
||||
@@ -467,6 +480,17 @@ export function TreatmentWorkspace({
|
||||
firstName: selectedAppointment.patientFirstName,
|
||||
lastName: selectedAppointment.patientLastName,
|
||||
purpose: selectedAppointment.purpose,
|
||||
isWalkIn: false,
|
||||
};
|
||||
}
|
||||
if (selectedStandalone) {
|
||||
const isWalkIn = Boolean(selectedStandalone.patient?.isWalkIn);
|
||||
return {
|
||||
id: selectedStandalone.patientId,
|
||||
firstName: isWalkIn ? walkInLabel : (selectedStandalone.patient?.firstName ?? ''),
|
||||
lastName: isWalkIn ? '' : (selectedStandalone.patient?.lastName ?? ''),
|
||||
purpose: selectedStandalone.details[0]?.treatmentType,
|
||||
isWalkIn,
|
||||
};
|
||||
}
|
||||
if (searchedPatient) {
|
||||
@@ -475,14 +499,15 @@ export function TreatmentWorkspace({
|
||||
firstName: searchedPatient.firstName,
|
||||
lastName: searchedPatient.lastName,
|
||||
purpose: undefined as string | undefined,
|
||||
isWalkIn: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [selectedAppointment, searchedPatient]);
|
||||
}, [selectedAppointment, selectedStandalone, searchedPatient, walkInLabel]);
|
||||
|
||||
const activePatientId = activePatient?.id ?? null;
|
||||
const activePatientName = activePatient
|
||||
? `${activePatient.firstName} ${activePatient.lastName}`
|
||||
? `${activePatient.firstName} ${activePatient.lastName}`.trim()
|
||||
: null;
|
||||
|
||||
const unreadUpdatesCount = unreadLabCases.length;
|
||||
@@ -504,10 +529,10 @@ export function TreatmentWorkspace({
|
||||
: t('labShipmentsSubtitle');
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAppointment && searchedPatient?.id === selectedAppointment.patientId) {
|
||||
if (selectedStandalone && searchedPatient?.id === selectedStandalone.patientId) {
|
||||
setSearchedPatient(null);
|
||||
}
|
||||
}, [selectedAppointment, searchedPatient?.id]);
|
||||
}, [selectedAppointment, selectedStandalone, searchedPatient?.id]);
|
||||
|
||||
const isViewingPastDay = useMemo(
|
||||
() => compareLocalDayStart(selectedDay, todayStart) < 0,
|
||||
@@ -516,7 +541,7 @@ export function TreatmentWorkspace({
|
||||
|
||||
const canEditTreatmentForDay =
|
||||
canEdit &&
|
||||
Boolean(selectedAppointment) &&
|
||||
hasLiveContext &&
|
||||
!isViewingPastDay &&
|
||||
workspaceMode === 'live';
|
||||
|
||||
@@ -554,18 +579,75 @@ export function TreatmentWorkspace({
|
||||
[activeDetail, labDependentCodes],
|
||||
);
|
||||
|
||||
const dayStripItems = useMemo<DayStripItem[]>(() => {
|
||||
const timed: DayStripItem[] = appointments.map((a) => ({
|
||||
kind: 'appointment',
|
||||
id: a.id,
|
||||
patientId: a.patientId,
|
||||
patientFirstName: a.patientFirstName,
|
||||
patientLastName: a.patientLastName,
|
||||
patientIsWalkIn: false,
|
||||
colorCode: a.purpose,
|
||||
timeLabel: formatAppTimeRange(a.startAt, a.endAt, locale),
|
||||
subtitle: treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog),
|
||||
}));
|
||||
const unscheduled: DayStripItem[] = standaloneTreatments.map((tr) => {
|
||||
const isWalkIn = Boolean(tr.patient?.isWalkIn);
|
||||
const colorCode = tr.details[0]?.treatmentType || 'visit';
|
||||
return {
|
||||
kind: 'unscheduled' as const,
|
||||
id: tr.id,
|
||||
patientId: tr.patientId,
|
||||
patientFirstName: isWalkIn ? t('walkIn') : (tr.patient?.firstName ?? ''),
|
||||
patientLastName: isWalkIn ? '' : (tr.patient?.lastName ?? ''),
|
||||
patientIsWalkIn: isWalkIn,
|
||||
colorCode,
|
||||
timeLabel: null,
|
||||
subtitle: t('noAppointment'),
|
||||
canDelete: tr.details.length === 0,
|
||||
};
|
||||
});
|
||||
return [...timed, ...unscheduled];
|
||||
}, [appointments, standaloneTreatments, locale, treatmentCatalog, t]);
|
||||
|
||||
const selectedStripItemId = selectedAppointmentId ?? selectedStandaloneId;
|
||||
|
||||
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]);
|
||||
if (selectedAppointment) {
|
||||
return buildWorkspaceSnapshot(
|
||||
{
|
||||
id: selectedAppointment.id,
|
||||
patientId: selectedAppointment.patientId,
|
||||
treatmentAt: selectedAppointment.startAt,
|
||||
appointmentId: selectedAppointment.id,
|
||||
},
|
||||
details,
|
||||
labCaseDrafts,
|
||||
t('treatmentPlanTitle', {
|
||||
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
|
||||
}),
|
||||
'current-draft',
|
||||
);
|
||||
}
|
||||
if (selectedStandalone) {
|
||||
const name = selectedStandalone.patient?.isWalkIn
|
||||
? t('walkIn')
|
||||
: `${selectedStandalone.patient?.firstName ?? ''} ${selectedStandalone.patient?.lastName ?? ''}`.trim();
|
||||
return buildWorkspaceSnapshot(
|
||||
{
|
||||
id: selectedStandalone.id,
|
||||
patientId: selectedStandalone.patientId,
|
||||
treatmentAt: selectedStandalone.treatmentAt,
|
||||
appointmentId: null,
|
||||
},
|
||||
details,
|
||||
labCaseDrafts,
|
||||
t('treatmentPlanTitle', { patientName: name || t('walkIn') }),
|
||||
selectedStandalone.id,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [details, labCaseDrafts, selectedAppointment, selectedStandalone, t]);
|
||||
|
||||
const previewTreatment = useMemo(() => {
|
||||
if (!selectedPreviewId) return currentDraftPreview;
|
||||
@@ -678,25 +760,43 @@ export function TreatmentWorkspace({
|
||||
try {
|
||||
const dayStart = startOfLocalDay(selectedDay);
|
||||
const dayEnd = addCalendarDays(dayStart, 1);
|
||||
const response = await appointmentsApi.list({
|
||||
from: dayStart.toISOString(),
|
||||
to: dayEnd.toISOString(),
|
||||
});
|
||||
const [apptsResponse, standaloneResponse] = await Promise.all([
|
||||
appointmentsApi.list({
|
||||
from: dayStart.toISOString(),
|
||||
to: dayEnd.toISOString(),
|
||||
}),
|
||||
treatmentsApi.listDayStandalone({
|
||||
from: dayStart.toISOString(),
|
||||
to: dayEnd.toISOString(),
|
||||
}),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const list = response.data
|
||||
const list = apptsResponse.data
|
||||
.filter((a) => a.providerUserId === userId)
|
||||
.map(mapAppointment);
|
||||
setAppointments(list);
|
||||
setStandaloneTreatments(standaloneResponse.data);
|
||||
if (!selectionLockedRef.current) {
|
||||
const pendingId = pendingAppointmentIdRef.current;
|
||||
if (pendingId && list.some((appointment) => appointment.id === pendingId)) {
|
||||
setSelectedAppointmentId(pendingId);
|
||||
setSelectedStandaloneId(null);
|
||||
setSelectionLocked(true);
|
||||
pendingAppointmentIdRef.current = null;
|
||||
router.replace('/treatment', { scroll: false });
|
||||
} else {
|
||||
pendingAppointmentIdRef.current = null;
|
||||
setSelectedAppointmentId(pickAutoAppointment(list, selectedDay));
|
||||
const autoAppt = pickAutoAppointment(list, selectedDay);
|
||||
if (autoAppt) {
|
||||
setSelectedAppointmentId(autoAppt);
|
||||
setSelectedStandaloneId(null);
|
||||
} else if (standaloneResponse.data[0]) {
|
||||
setSelectedAppointmentId(null);
|
||||
setSelectedStandaloneId(standaloneResponse.data[0].id);
|
||||
} else {
|
||||
setSelectedAppointmentId(null);
|
||||
setSelectedStandaloneId(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
@@ -866,7 +966,8 @@ export function TreatmentWorkspace({
|
||||
|
||||
useEffect(() => {
|
||||
const appointmentId = selectedAppointment?.id;
|
||||
if (!appointmentId || workspaceMode !== 'live') return;
|
||||
const treatmentId = selectedStandalone?.id;
|
||||
if ((!appointmentId && !treatmentId) || workspaceMode !== 'live') return;
|
||||
|
||||
if (skipNextGetDraftRef.current) {
|
||||
skipNextGetDraftRef.current = false;
|
||||
@@ -882,7 +983,9 @@ export function TreatmentWorkspace({
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await treatmentsApi.getDraft(appointmentId);
|
||||
const response = appointmentId
|
||||
? await treatmentsApi.getDraft(appointmentId)
|
||||
: await treatmentsApi.getDraftByTreatment(treatmentId!);
|
||||
if (cancelled) return;
|
||||
|
||||
if (response.data?.details?.length) {
|
||||
@@ -923,36 +1026,49 @@ export function TreatmentWorkspace({
|
||||
cancelled = true;
|
||||
draftHydratingRef.current = false;
|
||||
};
|
||||
}, [selectedAppointment?.id, selectedAppointment?.purpose, workspaceMode, treatmentCatalog, showError, t]);
|
||||
}, [
|
||||
selectedAppointment?.id,
|
||||
selectedAppointment?.purpose,
|
||||
selectedStandalone?.id,
|
||||
workspaceMode,
|
||||
treatmentCatalog,
|
||||
showError,
|
||||
t,
|
||||
tErrors,
|
||||
]);
|
||||
|
||||
const persistDraft = useCallback(
|
||||
async (options?: { force?: boolean }) => {
|
||||
if (!selectedAppointment) throw new Error('No appointment selected');
|
||||
if (!selectedAppointment && !selectedStandalone) throw new Error('No visit selected');
|
||||
|
||||
const patientName = selectedAppointment
|
||||
? `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`
|
||||
: selectedStandalone?.patient?.isWalkIn
|
||||
? t('walkIn')
|
||||
: `${selectedStandalone?.patient?.firstName ?? ''} ${selectedStandalone?.patient?.lastName ?? ''}`.trim();
|
||||
const patientId = selectedAppointment?.patientId ?? selectedStandalone!.patientId;
|
||||
const treatmentAt = selectedAppointment?.startAt ?? selectedStandalone!.treatmentAt;
|
||||
|
||||
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,
|
||||
title: t('treatmentPlanTitle', { patientName }),
|
||||
patientId,
|
||||
treatmentAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (!areDetailsPersistable(currentDetails)) {
|
||||
return detailsToPreviewTreatment(currentDetails, {
|
||||
title: t('treatmentPlanTitle', {
|
||||
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
|
||||
}),
|
||||
patientId: selectedAppointment.patientId,
|
||||
treatmentAt: selectedAppointment.startAt,
|
||||
title: t('treatmentPlanTitle', { patientName }),
|
||||
patientId,
|
||||
treatmentAt,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
|
||||
const payload = {
|
||||
details: currentDetails.map(
|
||||
({ clientId, id, treatmentType, teeth, toothSelectionGroups, comment, attachmentMetas }) => ({
|
||||
clientId,
|
||||
@@ -964,7 +1080,10 @@ export function TreatmentWorkspace({
|
||||
attachmentIds: attachmentMetas.map((a) => a.id),
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
const response = selectedAppointment
|
||||
? await treatmentsApi.saveDraft(selectedAppointment.id, payload)
|
||||
: await treatmentsApi.saveDraftByTreatment(selectedStandalone!.id, payload);
|
||||
const mapped = response.data.details.map(mapDetailFromApi);
|
||||
const sentSnapshot = serializeDetails(currentDetails);
|
||||
const localNow = detailsRef.current;
|
||||
@@ -990,7 +1109,7 @@ export function TreatmentWorkspace({
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
[selectedAppointment, t],
|
||||
[selectedAppointment, selectedStandalone, t],
|
||||
);
|
||||
|
||||
const refreshHistory = useCallback(async (patientId: string, options?: { silentLabCases?: boolean }) => {
|
||||
@@ -1007,7 +1126,7 @@ export function TreatmentWorkspace({
|
||||
}, [refreshPatientLabCases, showError, t, tErrors]);
|
||||
|
||||
const runDraftSave = useCallback(async () => {
|
||||
if (!selectedAppointment || saveInFlightRef.current) {
|
||||
if (!hasLiveContext || saveInFlightRef.current) {
|
||||
if (saveInFlightRef.current) saveQueuedRef.current = true;
|
||||
return;
|
||||
}
|
||||
@@ -1040,7 +1159,7 @@ export function TreatmentWorkspace({
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedAppointment, persistDraft, showError, t, historyPatientId, refreshHistory]);
|
||||
}, [hasLiveContext, persistDraft, showError, t, historyPatientId, refreshHistory]);
|
||||
|
||||
const flushDraftSave = useCallback(async (): Promise<boolean> => {
|
||||
if (autosaveTimerRef.current) {
|
||||
@@ -1048,7 +1167,7 @@ export function TreatmentWorkspace({
|
||||
autosaveTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (workspaceModeRef.current !== 'live' || !selectedAppointment || !canEditTreatmentForDay) {
|
||||
if (workspaceModeRef.current !== 'live' || !hasLiveContext || !canEditTreatmentForDay) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1066,10 +1185,10 @@ export function TreatmentWorkspace({
|
||||
} catch {
|
||||
return window.confirm(t('confirmDiscard'));
|
||||
}
|
||||
}, [selectedAppointment, canEditTreatmentForDay, runDraftSave, t]);
|
||||
}, [hasLiveContext, canEditTreatmentForDay, runDraftSave, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (draftHydratingRef.current || !canEditTreatmentForDay || !selectedAppointment?.id) {
|
||||
if (draftHydratingRef.current || !canEditTreatmentForDay || !hasLiveContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1091,7 +1210,7 @@ export function TreatmentWorkspace({
|
||||
autosaveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [details, isDirty, canEditTreatmentForDay, selectedAppointment?.id, runDraftSave]);
|
||||
}, [details, isDirty, canEditTreatmentForDay, hasLiveContext, runDraftSave]);
|
||||
|
||||
const resetToLiveContext = useCallback(() => {
|
||||
setWorkspaceMode('live');
|
||||
@@ -1107,11 +1226,90 @@ export function TreatmentWorkspace({
|
||||
setSearchedPatient(null);
|
||||
setSelectionLocked(true);
|
||||
setSelectedAppointmentId(id);
|
||||
setSelectedStandaloneId(null);
|
||||
})();
|
||||
},
|
||||
[flushDraftSave, resetToLiveContext],
|
||||
);
|
||||
|
||||
const onPickStripItem = useCallback(
|
||||
(item: DayStripItem) => {
|
||||
void (async () => {
|
||||
const ok = await flushDraftSave();
|
||||
if (!ok) return;
|
||||
resetToLiveContext();
|
||||
setSearchedPatient(null);
|
||||
setSelectionLocked(true);
|
||||
if (item.kind === 'appointment') {
|
||||
setSelectedAppointmentId(item.id);
|
||||
setSelectedStandaloneId(null);
|
||||
} else {
|
||||
setSelectedAppointmentId(null);
|
||||
setSelectedStandaloneId(item.id);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[flushDraftSave, resetToLiveContext],
|
||||
);
|
||||
|
||||
const createStandaloneTreatment = useCallback(
|
||||
async (opts: { patientId?: string; walkIn?: boolean }) => {
|
||||
const ok = await flushDraftSave();
|
||||
if (!ok) return;
|
||||
try {
|
||||
const created = await treatmentsApi.createStandalone({
|
||||
...opts,
|
||||
treatmentAt: selectedDay.toISOString(),
|
||||
});
|
||||
resetToLiveContext();
|
||||
setSelectionLocked(true);
|
||||
setSelectedAppointmentId(null);
|
||||
setSelectedStandaloneId(created.data.id);
|
||||
setStandaloneTreatments((prev) =>
|
||||
prev.some((row) => row.id === created.data.id) ? prev : [...prev, created.data],
|
||||
);
|
||||
skipNextGetDraftRef.current = true;
|
||||
hydrateFromTreatment(created.data);
|
||||
if (created.data.patientId && !created.data.patient?.isWalkIn) {
|
||||
await refreshHistory(created.data.patientId);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
showError(getUserFacingError(error, tErrors, t('errorCreateTreatment')));
|
||||
}
|
||||
},
|
||||
[flushDraftSave, selectedDay, resetToLiveContext, hydrateFromTreatment, refreshHistory, showError, t, tErrors],
|
||||
);
|
||||
|
||||
const deleteStandaloneTreatment = useCallback(
|
||||
async (item: DayStripItem) => {
|
||||
if (!canEdit || item.kind !== 'unscheduled' || !item.canDelete) return;
|
||||
if (!window.confirm(t('confirmDeleteEmptyTreatment'))) return;
|
||||
if (autosaveTimerRef.current) {
|
||||
clearTimeout(autosaveTimerRef.current);
|
||||
autosaveTimerRef.current = null;
|
||||
}
|
||||
try {
|
||||
await treatmentsApi.deleteStandalone(item.id);
|
||||
setStandaloneTreatments((prev) => prev.filter((row) => row.id !== item.id));
|
||||
if (selectedStandaloneId === item.id) {
|
||||
resetToLiveContext();
|
||||
setSelectedStandaloneId(null);
|
||||
setSelectedAppointmentId(null);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
showError(getUserFacingError(error, tErrors, t('errorDeleteTreatment')));
|
||||
}
|
||||
},
|
||||
[
|
||||
canEdit,
|
||||
resetToLiveContext,
|
||||
selectedStandaloneId,
|
||||
showError,
|
||||
t,
|
||||
tErrors,
|
||||
],
|
||||
);
|
||||
|
||||
const canAccessOrganizations = canAccessDashboardRoute(currentOrganization, '/organizations');
|
||||
|
||||
const onSelectDay = useCallback(
|
||||
@@ -1123,6 +1321,8 @@ export function TreatmentWorkspace({
|
||||
resetToLiveContext();
|
||||
setSearchedPatient(null);
|
||||
setSelectionLocked(false);
|
||||
setSelectedAppointmentId(null);
|
||||
setSelectedStandaloneId(null);
|
||||
setSelectedDay(startOfLocalDay(day));
|
||||
if (patientIdToRefresh) {
|
||||
await refreshHistory(patientIdToRefresh);
|
||||
@@ -1146,17 +1346,14 @@ export function TreatmentWorkspace({
|
||||
focusDetailClientId?: string,
|
||||
options?: { scrollToLabPanel?: boolean },
|
||||
) => {
|
||||
if (!treatment.appointmentId) {
|
||||
showError(t('errorNoAppointmentForTreatment'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const ok = workspaceModeRef.current === 'live' ? await flushDraftSave() : true;
|
||||
if (!ok) return false;
|
||||
|
||||
let treatmentToLoad = treatment;
|
||||
try {
|
||||
const draftResponse = await treatmentsApi.getDraft(treatment.appointmentId);
|
||||
const draftResponse = treatment.appointmentId
|
||||
? await treatmentsApi.getDraft(treatment.appointmentId)
|
||||
: await treatmentsApi.getDraftByTreatment(treatment.id);
|
||||
if (draftResponse.data) {
|
||||
treatmentToLoad = draftResponse.data;
|
||||
}
|
||||
@@ -1170,7 +1367,16 @@ export function TreatmentWorkspace({
|
||||
const nextDay = startOfLocalDay(new Date(treatmentToLoad.treatmentAt));
|
||||
setSelectedDay((prev) => (compareLocalDayStart(prev, nextDay) === 0 ? prev : nextDay));
|
||||
setSelectionLocked(true);
|
||||
setSelectedAppointmentId(treatmentToLoad.appointmentId ?? treatment.appointmentId);
|
||||
if (treatmentToLoad.appointmentId ?? treatment.appointmentId) {
|
||||
setSelectedAppointmentId(treatmentToLoad.appointmentId ?? treatment.appointmentId ?? null);
|
||||
setSelectedStandaloneId(null);
|
||||
} else {
|
||||
setSelectedAppointmentId(null);
|
||||
setSelectedStandaloneId(treatmentToLoad.id);
|
||||
setStandaloneTreatments((prev) =>
|
||||
prev.some((row) => row.id === treatmentToLoad.id) ? prev : [...prev, treatmentToLoad],
|
||||
);
|
||||
}
|
||||
|
||||
skipNextGetDraftRef.current = true;
|
||||
draftHydratingRef.current = true;
|
||||
@@ -1204,12 +1410,20 @@ export function TreatmentWorkspace({
|
||||
|
||||
return true;
|
||||
},
|
||||
[flushDraftSave, hydrateFromTreatment, showError, t, todayStart],
|
||||
[flushDraftSave, hydrateFromTreatment, todayStart],
|
||||
);
|
||||
|
||||
const handleSelectSearchedPatient = useCallback(
|
||||
(patient: Patient) => {
|
||||
void (async () => {
|
||||
const alreadySelected =
|
||||
selectedAppointment?.patientId === patient.id ||
|
||||
selectedStandalone?.patientId === patient.id ||
|
||||
searchedPatient?.id === patient.id;
|
||||
if (alreadySelected && hasLiveContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPatientSearchBusy(true);
|
||||
setSearchedPatient({
|
||||
id: patient.id,
|
||||
@@ -1219,24 +1433,48 @@ export function TreatmentWorkspace({
|
||||
try {
|
||||
const response = await treatmentsApi.listPatientHistory(patient.id, 1);
|
||||
const latest = response.data[0];
|
||||
if (!latest) {
|
||||
showError(t('errorNoTreatmentForPatient'));
|
||||
setSearchedPatient(null);
|
||||
if (latest) {
|
||||
const ok = await loadTreatmentIntoWorkspace(latest);
|
||||
if (!ok) {
|
||||
setSearchedPatient(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const ok = await loadTreatmentIntoWorkspace(latest);
|
||||
if (!ok) {
|
||||
setSearchedPatient(null);
|
||||
}
|
||||
|
||||
const created = await treatmentsApi.createStandalone({
|
||||
patientId: patient.id,
|
||||
treatmentAt: selectedDay.toISOString(),
|
||||
});
|
||||
setSelectionLocked(true);
|
||||
setSelectedAppointmentId(null);
|
||||
setSelectedStandaloneId(created.data.id);
|
||||
setStandaloneTreatments((prev) =>
|
||||
prev.some((row) => row.id === created.data.id) ? prev : [...prev, created.data],
|
||||
);
|
||||
skipNextGetDraftRef.current = true;
|
||||
hydrateFromTreatment(created.data);
|
||||
await refreshHistory(patient.id);
|
||||
} catch (error: unknown) {
|
||||
showError(getUserFacingError(error, tErrors, t('errorNoTreatmentForPatient')));
|
||||
showError(getUserFacingError(error, tErrors, t('errorCreateTreatment')));
|
||||
setSearchedPatient(null);
|
||||
} finally {
|
||||
setPatientSearchBusy(false);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[loadTreatmentIntoWorkspace, showError, t, tErrors],
|
||||
[
|
||||
selectedAppointment?.patientId,
|
||||
selectedStandalone?.patientId,
|
||||
searchedPatient?.id,
|
||||
hasLiveContext,
|
||||
loadTreatmentIntoWorkspace,
|
||||
selectedDay,
|
||||
hydrateFromTreatment,
|
||||
refreshHistory,
|
||||
showError,
|
||||
t,
|
||||
tErrors,
|
||||
],
|
||||
);
|
||||
|
||||
const handleLoadIntoWorkspace = useCallback(() => {
|
||||
@@ -1386,7 +1624,7 @@ export function TreatmentWorkspace({
|
||||
|
||||
const uploadForDetail = useCallback(
|
||||
async (detailClientId: string, files: FileList | File[]) => {
|
||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||
if (!canEditTreatmentForDay || !hasLiveContext) return;
|
||||
const target = detailsRef.current.find((d) => d.clientId === detailClientId);
|
||||
if (target && isDetailLocked(target)) return;
|
||||
const list = files instanceof FileList ? Array.from(files) : files;
|
||||
@@ -1394,11 +1632,17 @@ export function TreatmentWorkspace({
|
||||
|
||||
setUploadBusyDetailId(detailClientId);
|
||||
try {
|
||||
const uploaded = await treatmentsApi.uploadCaseAttachments(
|
||||
selectedAppointment.id,
|
||||
detailClientId,
|
||||
list,
|
||||
);
|
||||
const uploaded = selectedAppointment
|
||||
? await treatmentsApi.uploadCaseAttachments(
|
||||
selectedAppointment.id,
|
||||
detailClientId,
|
||||
list,
|
||||
)
|
||||
: await treatmentsApi.uploadDetailAttachmentsByTreatment(
|
||||
selectedStandalone!.id,
|
||||
detailClientId,
|
||||
list,
|
||||
);
|
||||
setDetails((prev) =>
|
||||
prev.map((d) =>
|
||||
d.clientId === detailClientId
|
||||
@@ -1413,12 +1657,12 @@ export function TreatmentWorkspace({
|
||||
setUploadBusyDetailId(null);
|
||||
}
|
||||
},
|
||||
[canEditTreatmentForDay, isDetailLocked, selectedAppointment, showSuccess, showError, t, tErrors],
|
||||
[canEditTreatmentForDay, isDetailLocked, hasLiveContext, selectedAppointment, selectedStandalone, showSuccess, showError, t, tErrors],
|
||||
);
|
||||
|
||||
const persistLabCases = useCallback(
|
||||
async (savedTreatment: PastTreatment, draftsOverride?: LabCaseDraft[]) => {
|
||||
if (!selectedAppointment) throw new Error('No appointment selected');
|
||||
if (!selectedAppointment && !selectedStandalone) throw new Error('No visit selected');
|
||||
|
||||
const drafts = draftsOverride ?? labCaseDrafts;
|
||||
const detailIdByClientId = new Map(
|
||||
@@ -1467,9 +1711,13 @@ export function TreatmentWorkspace({
|
||||
return savedTreatment;
|
||||
}
|
||||
|
||||
const response = await treatmentsApi.saveLabCases(selectedAppointment.id, {
|
||||
labCases: payload,
|
||||
});
|
||||
const response = selectedAppointment
|
||||
? await treatmentsApi.saveLabCases(selectedAppointment.id, {
|
||||
labCases: payload,
|
||||
})
|
||||
: await treatmentsApi.saveLabCasesByTreatment(selectedStandalone!.id, {
|
||||
labCases: payload,
|
||||
});
|
||||
const mapped = withoutEmptyLabCaseDrafts(response.data.labCases.map(mapLabCaseDraftFromApi));
|
||||
setLabCaseDrafts(mapped);
|
||||
setActiveLabCaseId((prev) => {
|
||||
@@ -1478,7 +1726,7 @@ export function TreatmentWorkspace({
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
[labCaseDrafts, selectedAppointment],
|
||||
[labCaseDrafts, selectedAppointment, selectedStandalone],
|
||||
);
|
||||
|
||||
const handleLabCasesChange = useCallback(
|
||||
@@ -1498,7 +1746,7 @@ export function TreatmentWorkspace({
|
||||
);
|
||||
if (
|
||||
removedPersistedDraft &&
|
||||
selectedAppointment &&
|
||||
hasLiveContext &&
|
||||
canEditTreatmentForDay
|
||||
) {
|
||||
void (async () => {
|
||||
@@ -1565,7 +1813,7 @@ export function TreatmentWorkspace({
|
||||
);
|
||||
|
||||
const handleAddLabCase = useCallback(async () => {
|
||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||
if (!canEditTreatmentForDay || !hasLiveContext) return;
|
||||
|
||||
const cleaned = withoutEmptyLabCaseDrafts(labCaseDrafts);
|
||||
const existing = cleaned.find(
|
||||
@@ -1636,7 +1884,7 @@ export function TreatmentWorkspace({
|
||||
// Auto-open shipment draft when entering Lab (no manual "Add lab shipment" click).
|
||||
useEffect(() => {
|
||||
if (entryStep !== 'lab') return;
|
||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||
if (!canEditTreatmentForDay || !hasLiveContext) return;
|
||||
const activeDetail = details.find((d) => d.clientId === activeDetailId);
|
||||
if (!activeDetail || !isDetailReadyForLabDispatch(activeDetail, labDependentCodes)) return;
|
||||
if (isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)) return;
|
||||
@@ -1660,7 +1908,7 @@ export function TreatmentWorkspace({
|
||||
|
||||
const handleSendLabCase = useCallback(
|
||||
async (labCase: LabCaseDraft, comment?: string) => {
|
||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||
if (!canEditTreatmentForDay || !hasLiveContext) return;
|
||||
if (!labCase.destinationOrganizationId) {
|
||||
showError(t('errorChooseOrg'));
|
||||
return;
|
||||
@@ -1695,7 +1943,9 @@ export function TreatmentWorkspace({
|
||||
|
||||
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
|
||||
|
||||
const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id);
|
||||
const draftResponse = selectedAppointment
|
||||
? await treatmentsApi.getDraft(selectedAppointment.id)
|
||||
: await treatmentsApi.getDraftByTreatment(selectedStandalone!.id);
|
||||
if (draftResponse.data?.details?.length) {
|
||||
const mapped = draftResponse.data.details.map(mapDetailFromApi);
|
||||
detailsRef.current = mapped;
|
||||
@@ -1746,9 +1996,11 @@ export function TreatmentWorkspace({
|
||||
});
|
||||
showSuccess(t('successCaseSent'));
|
||||
notifyTabBadgesChanged();
|
||||
if (selectedAppointment.patientId) {
|
||||
if (selectedAppointment?.patientId) {
|
||||
// Silent: avoid rail/history loading flicker while staying on Lab step.
|
||||
void refreshHistory(selectedAppointment.patientId, { silentLabCases: true });
|
||||
} else if (selectedStandalone?.patientId) {
|
||||
void refreshHistory(selectedStandalone.patientId, { silentLabCases: true });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
showError(getUserFacingError(error, tErrors, t('errorSendCase')));
|
||||
@@ -1792,9 +2044,10 @@ export function TreatmentWorkspace({
|
||||
onToggleStripHidden={() => setStripHidden((s) => !s)}
|
||||
selectedDay={selectedDay}
|
||||
onSelectDay={onSelectDay}
|
||||
appointments={appointments}
|
||||
selectedAppointmentId={selectedAppointmentId}
|
||||
onSelectAppointment={onPickAppointment}
|
||||
items={dayStripItems}
|
||||
selectedItemId={selectedStripItemId}
|
||||
onSelectItem={onPickStripItem}
|
||||
onDeleteUnscheduled={canEdit ? deleteStandaloneTreatment : undefined}
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
loading={apptsLoading}
|
||||
/>
|
||||
@@ -1823,6 +2076,39 @@ export function TreatmentWorkspace({
|
||||
placeholder={tPatients('searchPlaceholder')}
|
||||
emptyResultsMessage={tPatients('noResults')}
|
||||
/>
|
||||
{canEdit && !isViewingPastDay ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={
|
||||
!(
|
||||
searchedPatient?.id ||
|
||||
selectedAppointment?.patientId ||
|
||||
(selectedStandalone && !selectedStandalone.patient?.isWalkIn)
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
const patientId =
|
||||
searchedPatient?.id ??
|
||||
selectedAppointment?.patientId ??
|
||||
selectedStandalone?.patientId;
|
||||
if (!patientId) return;
|
||||
return createStandaloneTreatment({ patientId });
|
||||
}}
|
||||
>
|
||||
{t('newTreatment')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => createStandaloneTreatment({ walkIn: true })}
|
||||
>
|
||||
{t('newWalkInTreatment')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activePatient ? (
|
||||
<div className="space-y-0.5 border-t border-border/60 pt-3">
|
||||
@@ -1844,7 +2130,7 @@ export function TreatmentWorkspace({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{workspaceMode === 'live' && !isBrowsing && selectedAppointment ? (
|
||||
{workspaceMode === 'live' && !isBrowsing && hasLiveContext ? (
|
||||
<TreatmentPreviewCard
|
||||
treatment={currentDraftPreview}
|
||||
heading={t('previewCurrentDraft')}
|
||||
|
||||
Reference in New Issue
Block a user