improvement: v1 standalone treatment/case creation made possible.

This commit is contained in:
2026-08-19 15:05:58 +03:30
parent e5c39c6b7e
commit 8bfa8c88fe
39 changed files with 3296 additions and 387 deletions

View File

@@ -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')}