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

2627 lines
94 KiB
TypeScript

'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { Button } from '@/components/ui/shared/Button';
import { WizardStepper } from '@/components/ui/shared/WizardStepper';
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
import {
TreatmentLabCasesPanel,
type TreatmentLabCasesScope,
} from '@/components/ui/treatment/TreatmentLabCasesPanel';
import { TreatmentRailSection } from '@/components/ui/treatment/TreatmentRailSection';
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
import { treatmentTypeLabelFromCatalog, treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
import {
addCalendarDays,
compareLocalDayStart,
isSameLocalCalendarDay,
startOfLocalDay,
} from '@/components/appointments/appointmentTime';
import { appointmentsApi } from '@/lib/api/appointments';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
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,
isDetailReadyForLabDispatch,
isDetailTypeSelected,
isEmptyDraftDetail,
isLabDependentDetailMissingTeeth,
areUnscheduledDetailsStripDeletable,
} from '@/components/treatment/treatmentDetailRules';
import {
applyShiftRange,
connectedTeethSet,
deriveTeethFromGroups,
groupsFromFlatTeeth,
linkAdjacentTeeth,
linkedEdgesFromGroups,
pruneToothProsthesisForGroups,
toothEdgeKey,
toggleToothInGroups,
unlinkAdjacentTeeth,
} from '@/components/treatment/toothSelectionGroups';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import {
loadRecentLabIds,
MAX_RECENT_LABS,
rememberRecentLab,
} from '@/components/treatment/labDispatchDefaults';
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
import { tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
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';
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
import type { Organization } from '@/types/organization';
import type { Patient } from '@/types/patient';
import type { AppointmentRecord } from '@/types/appointment';
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type {
FdiToothId,
LabCaseDraft,
LinkedOrganizationOption,
PastLabCase,
PastTreatment,
PastTreatmentCase,
PastTreatmentDetail,
TreatmentAppointment,
TreatmentDetailDraft,
} from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
type WorkspaceMode = 'live' | 'historical';
type EntryStep = 'treatment' | 'lab';
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,
toothSelectionGroups: linkedDetail.toothSelectionGroups,
}
: 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(
ctx: { id: string; patientId: string; treatmentAt: string; appointmentId: string | null },
details: TreatmentDetailDraft[],
labCaseDrafts: LabCaseDraft[],
title: string,
id?: string,
): PastTreatment {
const detailsForPreview = enrichDetailsWithLabSendState(details, labCaseDrafts);
return {
...detailsToPreviewTreatment(detailsForPreview, {
id: id ?? `preview-${ctx.id}`,
title,
patientId: ctx.patientId,
treatmentAt: ctx.treatmentAt,
}),
appointmentId: ctx.appointmentId,
labCases: labCaseDraftsToPast(labCaseDrafts, details),
};
}
function newDetail(defaultTreatmentType?: string): TreatmentDetailDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: defaultTreatmentType ?? '',
teeth: [],
toothSelectionGroups: [],
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 {
const teeth = d.teeth;
const toothSelectionGroups = groupsFromFlatTeeth(teeth, d.toothSelectionGroups ?? null);
return {
clientId: d.clientId,
id: d.id,
treatmentType: d.treatmentType,
teeth,
toothSelectionGroups,
comment: d.notes ?? '',
attachmentMetas: d.attachmentMetas ?? [],
labCaseId: d.labCaseId ?? null,
taskProgress: d.taskProgress ?? null,
sendToOrganizationIds: d.destinationOrganizationId ? [d.destinationOrganizationId] : [],
sends: d.sends ?? [],
sentAt: d.sentAt ?? null,
};
}
function draftToPastDetail(d: TreatmentDetailDraft): PastTreatmentDetail {
return {
id: d.id ?? d.clientId,
clientId: d.clientId,
treatmentType: d.treatmentType,
teeth: d.teeth,
toothSelectionGroups: d.toothSelectionGroups,
notes: d.comment,
attachmentMetas: d.attachmentMetas,
labCaseId: d.labCaseId ?? null,
taskProgress: d.taskProgress ?? null,
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,
selectionGroupId: tp.selectionGroupId ?? '',
})),
attachmentIds: (lc.attachments ?? []).map((a) => a.id),
sentAt: lc.sentAt ?? null,
sends: lc.sends ?? [],
dueDate: lc.dueDate ?? null,
taskProgress: lc.taskProgress ?? null,
};
}
function serializeDetails(details: TreatmentDetailDraft[]) {
return JSON.stringify(
details.map((d) => ({
clientId: d.clientId,
id: d.id,
treatmentType: d.treatmentType,
teeth: d.teeth,
toothSelectionGroups: d.toothSelectionGroups,
comment: d.comment,
attachmentMetas: d.attachmentMetas,
})),
);
}
function isDetailsDirty(
details: TreatmentDetailDraft[],
savedSnapshot: string | null,
): boolean {
if (savedSnapshot === null) {
return details.some((d) => !isEmptyDraftDetail(d)) || details.length > 1;
}
return serializeDetails(details) !== savedSnapshot;
}
/** Apply server ids/attachments onto local rows without replacing newer local edits. */
function mergeServerIdsIntoDetails(
local: TreatmentDetailDraft[],
fromServer: TreatmentDetailDraft[],
): TreatmentDetailDraft[] {
const byClientId = new Map(fromServer.map((d) => [d.clientId, d]));
return local.map((d) => {
const s = byClientId.get(d.clientId);
if (!s) return d;
return {
...d,
id: s.id ?? d.id,
labCaseId: s.labCaseId ?? d.labCaseId,
taskProgress: s.taskProgress ?? d.taskProgress,
attachmentMetas:
d.attachmentMetas.length > 0 ? d.attachmentMetas : s.attachmentMetas,
};
});
}
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,
toothSelectionGroups: d.toothSelectionGroups,
notes: d.comment || null,
attachmentMetas: d.attachmentMetas,
labCaseId: d.labCaseId ?? null,
taskProgress: d.taskProgress ?? null,
destinationOrganizationId: d.sendToOrganizationIds[0] ?? null,
sends: d.sends ?? [],
sentAt: d.sentAt ?? null,
})),
labCases: [],
documents: [],
};
}
interface TreatmentWorkspaceProps {
userId: string;
currentOrganization: Organization | null;
initialAppointmentId?: string | null;
initialLabCaseId?: string | null;
}
export function TreatmentWorkspace({
userId,
currentOrganization,
initialAppointmentId = null,
initialLabCaseId = null,
}: TreatmentWorkspaceProps) {
const locale = useLocale();
const t = useTranslations('treatment');
const tErrors = useTranslations('errors');
const tPatients = useTranslations('patients');
const router = useRouter();
const { user } = useAuth();
const { showError, showSuccess, messages: toastMessages } = useToast();
const canView = canViewTreatment(currentOrganization);
const canEdit = canEditTreatment(currentOrganization);
useMarkTabReadOnVisit();
const tabBadgeCounts = useTabBadgeCounts();
const initialLabCasesScopeSetRef = useRef(false);
const [stripHidden, setStripHidden] = useState(false);
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
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);
const [historyPatientId, setHistoryPatientId] = useState<string | null>(null);
const [patientLabCases, setPatientLabCases] = useState<PatientLabCaseSummary[]>([]);
const [patientLabCasesLoading, setPatientLabCasesLoading] = useState(false);
const [unreadLabCases, setUnreadLabCases] = useState<PatientLabCaseSummary[]>([]);
const [unreadLabCasesLoading, setUnreadLabCasesLoading] = useState(false);
const [labCasesScope, setLabCasesScope] = useState<TreatmentLabCasesScope>('patient');
const [selectedRailLabCaseId, setSelectedRailLabCaseId] = useState<string | null>(null);
const [searchedPatient, setSearchedPatient] = useState<Pick<
Patient,
'id' | 'firstName' | 'lastName'
> | null>(null);
const [patientSearchBusy, setPatientSearchBusy] = useState(false);
const [newTreatmentPickerOpen, setNewTreatmentPickerOpen] = useState(false);
const [creatingStandalone, setCreatingStandalone] = useState(false);
const {
search: patientSearch,
setSearch: setPatientSearch,
patients: patientSearchResults,
loading: patientSearchLoading,
} = usePatientSearchQuery(canView);
const [orgs, setOrgs] = useState<LinkedOrganizationOption[]>([]);
const [labDependentCodes, setLabDependentCodes] = useState<Set<string>>(new Set());
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
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 pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
const labPanelRef = useRef<HTMLDivElement>(null);
const historyRequestRef = useRef(0);
/** When set, activeDetailId effect opens this step instead of resetting to treatment. */
const pendingEntryStepRef = useRef<EntryStep | null>(null);
useEffect(() => {
pendingAppointmentIdRef.current = initialAppointmentId;
if (initialAppointmentId) {
setSelectedDay(startOfLocalDay(new Date()));
setSelectionLocked(false);
}
}, [initialAppointmentId]);
useEffect(() => {
pendingLabCaseIdRef.current = initialLabCaseId;
}, [initialLabCaseId]);
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 [entryStep, setEntryStep] = useState<EntryStep>('treatment');
const isDetailLocked = useCallback(
(detail: TreatmentDetailDraft) =>
labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientId === detail.clientId),
[labCaseDrafts],
);
const activeSentLabCaseId = useMemo(() => {
const match = labCaseDrafts.find(
(lc) => lc.detailClientId === activeDetailId && lc.sentAt && lc.id,
);
return match?.id ?? null;
}, [labCaseDrafts, activeDetailId]);
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 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 {
id: selectedAppointment.patientId,
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) {
return {
id: searchedPatient.id,
firstName: searchedPatient.firstName,
lastName: searchedPatient.lastName,
purpose: undefined as string | undefined,
isWalkIn: false,
};
}
return null;
}, [selectedAppointment, selectedStandalone, searchedPatient, walkInLabel]);
const activePatientId = activePatient?.id ?? null;
const activePatientName = activePatient
? `${activePatient.firstName} ${activePatient.lastName}`.trim()
: null;
const unreadUpdatesCount = unreadLabCases.length;
const otherPatientsUnreadCount = useMemo(
() => unreadLabCases.filter((item) => item.patientId !== activePatientId).length,
[unreadLabCases, activePatientId],
);
const displayedLabCases = labCasesScope === 'updates' ? unreadLabCases : patientLabCases;
const labCasesListLoading =
labCasesScope === 'updates'
? unreadLabCasesLoading && unreadLabCases.length === 0
: patientLabCasesLoading && patientLabCases.length === 0;
const showLabShipmentsSection = Boolean(activePatient) || unreadUpdatesCount > 0;
const labShipmentsSubtitle =
labCasesScope === 'updates'
? t('labShipmentsUpdatesScope')
: activePatientName
? t('labShipmentsPatientScope', { patientName: activePatientName })
: t('labShipmentsSubtitle');
useEffect(() => {
if (selectedStandalone && searchedPatient?.id === selectedStandalone.patientId) {
setSearchedPatient(null);
}
}, [selectedAppointment, selectedStandalone, searchedPatient?.id]);
const isViewingPastDay = useMemo(
() => compareLocalDayStart(selectedDay, todayStart) < 0,
[selectedDay, todayStart],
);
const canEditTreatmentForDay =
canEdit &&
hasLiveContext &&
!isViewingPastDay &&
workspaceMode === 'live';
const historyPanelItems = history;
const activeDetail = useMemo(
() => details.find((d) => d.clientId === activeDetailId) ?? details[0] ?? null,
[details, activeDetailId],
);
const showLabDispatchPanel = useMemo(
() => details.some((d) => isDetailReadyForLabDispatch(d, labDependentCodes)),
[details, labDependentCodes],
);
/** Lab send sheet only for prosthesis (lab-dependent) types on the active detail. */
const showLabWizardStep = useMemo(
() => Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType)),
[activeDetail, labDependentCodes],
);
const showLabShipmentBlocked = useMemo(
() =>
Boolean(
activeDetail && isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes),
),
[activeDetail, labDependentCodes],
);
const activeTypeSelected = Boolean(activeDetail && isDetailTypeSelected(activeDetail));
const activeLocked = Boolean(activeDetail && isDetailLocked(activeDetail));
useEffect(() => {
if (!selectedStandaloneId || draftHydratingRef.current) return;
const nextDetails = details.map(draftToPastDetail);
setStandaloneTreatments((prev) => {
const current = prev.find((row) => row.id === selectedStandaloneId);
if (!current) return prev;
const prevColor = current.details.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
const nextColor = nextDetails.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
if (
prevColor === nextColor &&
areUnscheduledDetailsStripDeletable(current.details) ===
areUnscheduledDetailsStripDeletable(nextDetails)
) {
return prev;
}
return prev.map((row) =>
row.id === selectedStandaloneId ? { ...row, details: nextDetails } : row,
);
});
}, [selectedStandaloneId, details]);
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 sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current
? details
: tr.details;
const typedDetail = sourceDetails.find((d) => Boolean(d.treatmentType?.trim()));
const colorCode = typedDetail?.treatmentType?.trim() ?? '';
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: areUnscheduledDetailsStripDeletable(sourceDetails),
};
});
return [...timed, ...unscheduled];
}, [
appointments,
standaloneTreatments,
selectedStandaloneId,
details,
locale,
treatmentCatalog,
t,
]);
const selectedStripItemId = selectedAppointmentId ?? selectedStandaloneId;
const currentDraftPreview = useMemo<PastTreatment | null>(() => {
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;
const fromHistory =
history.find((item) => item.id === selectedPreviewId) ??
historyPanelItems.find((item) => item.id === selectedPreviewId);
if (
workspaceMode === 'live' &&
currentDraftPreview?.appointmentId &&
(selectedPreviewId === currentDraftPreview.id ||
fromHistory?.appointmentId === currentDraftPreview.appointmentId)
) {
return currentDraftPreview;
}
return fromHistory ?? currentDraftPreview;
}, [selectedPreviewId, history, historyPanelItems, currentDraftPreview, workspaceMode]);
const isBrowsing = selectedPreviewId !== null;
const labAttentionItems = useMemo(
() =>
collectLabDispatchAttention(
labDependentCodes,
currentDraftPreview,
history,
selectedAppointmentId,
),
[labDependentCodes, currentDraftPreview, history, selectedAppointmentId],
);
const hydrateFromTreatment = useCallback((
treatment: PastTreatment,
options?: { seedBlankIfEmpty?: boolean },
) => {
const mapped = treatment.details.map(mapDetailFromApi);
const nextDetails =
mapped.length > 0
? mapped
: options?.seedBlankIfEmpty
? [newDetail()]
: [];
setDetails(nextDetails);
setActiveDetailId((prev) => {
const stillExists = nextDetails.some((d) => d.clientId === prev);
return stillExists ? prev : (nextDetails[0]?.clientId ?? '');
});
setSavedSnapshot(serializeDetails(nextDetails));
const mappedLabCases = withoutEmptyLabCaseDrafts(
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
);
setLabCaseDrafts(mappedLabCases);
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
setSaveStatus('idle');
}, []);
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const connectedSelectedTeeth = useMemo(
() => connectedTeethSet(activeDetail?.toothSelectionGroups ?? []),
[activeDetail?.toothSelectionGroups],
);
const linkedToothEdges = useMemo(
() => linkedEdgesFromGroups(activeDetail?.toothSelectionGroups ?? []),
[activeDetail?.toothSelectionGroups],
);
const rangeAnchorRef = useRef<FdiToothId | null>(null);
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 wholePlanLinkedEdges = useMemo(() => {
const edges = new Set<string>();
for (const detail of details) {
for (const key of linkedEdgesFromGroups(detail.toothSelectionGroups ?? [])) {
edges.add(key);
}
}
return edges;
}, [details]);
const chartSelectedTeeth = showWholeTreatmentPlan ? wholePlanTeethSet : selectedTeethSet;
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
const chartLinkedEdges = showWholeTreatmentPlan ? wholePlanLinkedEdges : linkedToothEdges;
// Reset whole-plan overview when switching details.
// Prefer pendingEntryStepRef (e.g. Lab shipments → Lab step) over defaulting to treatment.
useEffect(() => {
setShowWholeTreatmentPlan(false);
rangeAnchorRef.current = null;
setOrganizationSearch('');
const pending = pendingEntryStepRef.current;
pendingEntryStepRef.current = null;
setEntryStep(pending ?? 'treatment');
}, [activeDetailId]);
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
useEffect(() => {
if (entryStep === 'lab' && !showLabWizardStep) {
setEntryStep('treatment');
}
}, [entryStep, showLabWizardStep]);
// 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(() => {
let cancelled = false;
setApptsLoading(true);
void (async () => {
try {
const dayStart = startOfLocalDay(selectedDay);
const dayEnd = addCalendarDays(dayStart, 1);
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 = 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;
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) {
if (!cancelled) {
showError(getUserFacingError(error, tErrors, t('errorLoadAppointments')));
}
} finally {
if (!cancelled) setApptsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [userId, selectedDay, showError, t, router]);
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, prosthesisResponse] = await Promise.all([
treatmentsApi.listLinkedOrganizations(),
treatmentCatalogApi.list(),
prosthesisCatalogApi.list(),
]);
if (cancelled) return;
setOrgs(orgsResponse.data);
setTreatmentCatalog(catalogResponse.data);
setProsthesisCatalog(prosthesisResponse.data);
setLabDependentCodes(
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
);
const recentIds = loadRecentLabIds(currentOrganization?.id).filter((id) =>
orgsResponse.data.some((o) => o.id === id && o.active),
);
if (recentIds.length > 0) {
setRecentOrganizationIds(recentIds);
}
} catch (error: unknown) {
if (!cancelled) {
showError(getUserFacingError(error, tErrors, t('errorLoadOrgs')));
}
}
})();
return () => {
cancelled = true;
};
}, [showError, t, currentOrganization?.id]);
useEffect(() => {
if (!activePatientId) {
setHistoryPatientId(null);
setHistory([]);
setHistoryLoading(false);
setPatientLabCases([]);
setPatientLabCasesLoading(false);
setSelectedRailLabCaseId(null);
return;
}
setHistoryPatientId((prev) => {
if (prev !== activePatientId) {
setHistory([]);
setHistoryLoading(true);
}
return activePatientId;
});
}, [activePatientId]);
const refreshPatientLabCases = useCallback(
async (patientId: string, options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setPatientLabCasesLoading(true);
try {
const response = await treatmentsApi.listPatientLabCases(patientId);
setPatientLabCases(response.data ?? []);
} catch {
if (!silent) setPatientLabCases([]);
} finally {
if (!silent) setPatientLabCasesLoading(false);
}
},
[],
);
const refreshUnreadLabCases = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false;
if (!silent) setUnreadLabCasesLoading(true);
try {
const response = await treatmentsApi.listUnreadLabCases();
setUnreadLabCases(response.data ?? []);
} catch {
if (!silent) setUnreadLabCases([]);
} finally {
if (!silent) setUnreadLabCasesLoading(false);
}
}, []);
const handleLabCaseMarkedRead = useCallback((labCaseId: string) => {
setPatientLabCases((prev) =>
prev.map((item) => (item.labCaseId === labCaseId ? { ...item, hasUnread: false } : item)),
);
setUnreadLabCases((prev) => prev.filter((item) => item.labCaseId !== labCaseId));
}, []);
const activeLabCaseSummary = useMemo(() => {
if (activeSentLabCaseId) {
return patientLabCases.find((item) => item.labCaseId === activeSentLabCaseId) ?? null;
}
return patientLabCases.find((item) => item.detailClientId === activeDetailId) ?? null;
}, [patientLabCases, activeSentLabCaseId, activeDetailId]);
const handleLabCaseSummaryChange = useCallback((summary: PatientLabCaseSummary) => {
setPatientLabCases((prev) =>
prev.map((item) => (item.labCaseId === summary.labCaseId ? summary : item)),
);
}, []);
useEffect(() => {
void refreshUnreadLabCases();
}, [refreshUnreadLabCases]);
useEffect(() => {
if (initialLabCasesScopeSetRef.current) return;
if ((tabBadgeCounts.treatment ?? 0) > 0) {
setLabCasesScope('updates');
initialLabCasesScopeSetRef.current = true;
return;
}
if (activePatientId) {
setLabCasesScope('patient');
initialLabCasesScopeSetRef.current = true;
}
}, [tabBadgeCounts.treatment, activePatientId]);
useEffect(() => {
if (unreadLabCases.length === 0 && labCasesScope === 'updates' && activePatientId) {
setLabCasesScope('patient');
}
if (unreadLabCases.length > 0 && !activePatientId && labCasesScope === 'patient') {
setLabCasesScope('updates');
}
}, [unreadLabCases.length, labCasesScope, activePatientId]);
useEffect(() => {
if (!historyPatientId) return;
const requestId = ++historyRequestRef.current;
setHistoryLoading(true);
void (async () => {
try {
const response = await treatmentsApi.listPatientHistory(historyPatientId, 50);
if (requestId !== historyRequestRef.current) return;
setHistory(response.data);
void refreshPatientLabCases(historyPatientId);
} catch (error: unknown) {
if (requestId !== historyRequestRef.current) return;
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
} finally {
if (requestId === historyRequestRef.current) {
setHistoryLoading(false);
}
}
})();
}, [historyPatientId, refreshPatientLabCases, showError, t, tErrors]);
useEffect(() => {
const onBadgesChanged = () => {
if (historyPatientId) void refreshPatientLabCases(historyPatientId, { silent: true });
void refreshUnreadLabCases({ silent: true });
};
window.addEventListener(tabBadgesChangedEventName(), onBadgesChanged);
return () => window.removeEventListener(tabBadgesChangedEventName(), onBadgesChanged);
}, [historyPatientId, refreshPatientLabCases, refreshUnreadLabCases]);
useEffect(() => {
const appointmentId = selectedAppointment?.id;
const treatmentId = selectedStandalone?.id;
if ((!appointmentId && !treatmentId) || 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 = appointmentId
? await treatmentsApi.getDraft(appointmentId)
: await treatmentsApi.getDraftByTreatment(treatmentId!);
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 if (response.data) {
setDetails([]);
setActiveDetailId('');
setSavedSnapshot(serializeDetails([]));
} 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(getUserFacingError(error, tErrors, t('errorLoadDraft')));
}
} finally {
if (!cancelled) {
draftHydratingRef.current = false;
}
}
})();
return () => {
cancelled = true;
draftHydratingRef.current = false;
};
}, [
selectedAppointment?.id,
selectedAppointment?.purpose,
selectedStandalone?.id,
workspaceMode,
treatmentCatalog,
showError,
t,
tErrors,
]);
const persistDraft = useCallback(
async (options?: { force?: boolean }) => {
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 }),
patientId,
treatmentAt,
});
}
if (!areDetailsPersistable(currentDetails)) {
return detailsToPreviewTreatment(currentDetails, {
title: t('treatmentPlanTitle', { patientName }),
patientId,
treatmentAt,
});
}
const payload = {
details: currentDetails.map(
({ clientId, id, treatmentType, teeth, toothSelectionGroups, comment, attachmentMetas }) => ({
clientId,
id,
treatmentType,
teeth,
toothSelectionGroups,
comment,
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;
// If the user changed details while this save was in flight (e.g. removed a
// detail), do not clobber local state with the stale response.
if (serializeDetails(localNow) === sentSnapshot) {
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : (mapped[0]?.clientId ?? '');
});
setSavedSnapshot(serializeDetails(mapped));
} else {
const merged = mergeServerIdsIntoDetails(localNow, mapped);
detailsRef.current = merged;
setDetails(merged);
setActiveDetailId((prev) => {
const stillExists = merged.some((d) => d.clientId === prev);
return stillExists ? prev : (merged[0]?.clientId ?? '');
});
// Keep dirty so the queued autosave persists the newer local state.
}
return response.data;
},
[selectedAppointment, selectedStandalone, t],
);
const refreshHistory = useCallback(async (patientId: string, options?: { silentLabCases?: boolean }) => {
const requestId = ++historyRequestRef.current;
try {
const response = await treatmentsApi.listPatientHistory(patientId, 50);
if (requestId !== historyRequestRef.current) return;
setHistory(response.data);
void refreshPatientLabCases(patientId, { silent: options?.silentLabCases ?? false });
} catch (error: unknown) {
if (requestId !== historyRequestRef.current) return;
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
}
}, [refreshPatientLabCases, showError, t, tErrors]);
const runDraftSave = useCallback(async () => {
if (!hasLiveContext || saveInFlightRef.current) {
if (saveInFlightRef.current) saveQueuedRef.current = true;
return;
}
if (
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current) ||
!areDetailsPersistable(detailsRef.current)
) {
return;
}
saveInFlightRef.current = true;
setSaveStatus('saving');
try {
await persistDraft();
setSaveStatus('saved');
if (historyPatientId) {
await refreshHistory(historyPatientId);
}
} catch (error: unknown) {
setSaveStatus('error');
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
throw error;
} finally {
saveInFlightRef.current = false;
if (saveQueuedRef.current) {
saveQueuedRef.current = false;
if (isDetailsDirty(detailsRef.current, savedSnapshotRef.current)) {
void runDraftSave();
}
}
}
}, [hasLiveContext, persistDraft, showError, t, historyPatientId, refreshHistory]);
const flushDraftSave = useCallback(async (): Promise<boolean> => {
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
autosaveTimerRef.current = null;
}
if (workspaceModeRef.current !== 'live' || !hasLiveContext || !canEditTreatmentForDay) {
return true;
}
while (saveInFlightRef.current) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
if (!isDetailsDirty(detailsRef.current, savedSnapshotRef.current)) {
return true;
}
try {
await runDraftSave();
return true;
} catch {
return window.confirm(t('confirmDiscard'));
}
}, [hasLiveContext, canEditTreatmentForDay, runDraftSave, t]);
useEffect(() => {
if (draftHydratingRef.current || !canEditTreatmentForDay || !hasLiveContext) {
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, hasLiveContext, runDraftSave]);
const resetToLiveContext = useCallback(() => {
setWorkspaceMode('live');
setSelectedPreviewId(null);
}, []);
const onPickAppointment = useCallback(
(id: string) => {
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
draftHydratingRef.current = true;
resetToLiveContext();
setSearchedPatient(null);
setSelectionLocked(true);
setSelectedAppointmentId(id);
setSelectedStandaloneId(null);
})();
},
[flushDraftSave, resetToLiveContext],
);
const onPickStripItem = useCallback(
(item: DayStripItem) => {
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
draftHydratingRef.current = true;
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 }) => {
if (creatingStandalone) return;
const ok = await flushDraftSave();
if (!ok) return;
setCreatingStandalone(true);
try {
const created = await treatmentsApi.createStandalone({
...opts,
treatmentAt: selectedDay.toISOString(),
});
resetToLiveContext();
setSearchedPatient(null);
setNewTreatmentPickerOpen(false);
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;
draftHydratingRef.current = true;
hydrateFromTreatment(created.data, { seedBlankIfEmpty: true });
draftHydratingRef.current = false;
if (created.data.patientId && !created.data.patient?.isWalkIn) {
await refreshHistory(created.data.patientId);
}
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorCreateTreatment')));
} finally {
setCreatingStandalone(false);
}
},
[
creatingStandalone,
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;
}
const wasSelected = selectedStandaloneId === item.id;
if (wasSelected) {
draftHydratingRef.current = true;
}
try {
while (saveInFlightRef.current) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
// Chip-delete / cleared type can look empty in the strip before autosave
// finishes. Persist [] first so DELETE /treatments/:id (empty-only) succeeds.
await treatmentsApi.saveDraftByTreatment(item.id, { details: [] });
await treatmentsApi.deleteStandalone(item.id);
setStandaloneTreatments((prev) => prev.filter((row) => row.id !== item.id));
if (wasSelected) {
resetToLiveContext();
setSelectedStandaloneId(null);
setSelectedAppointmentId(null);
}
} catch (error: unknown) {
if (wasSelected) {
draftHydratingRef.current = false;
}
showError(getUserFacingError(error, tErrors, t('errorDeleteTreatment')));
}
},
[
canEdit,
resetToLiveContext,
selectedStandaloneId,
showError,
t,
tErrors,
],
);
const canAccessOrganizations = canAccessDashboardRoute(currentOrganization, '/organizations');
const onSelectDay = useCallback(
(day: Date) => {
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
const patientIdToRefresh = historyPatientId;
resetToLiveContext();
setSearchedPatient(null);
setNewTreatmentPickerOpen(false);
setSelectionLocked(false);
setSelectedAppointmentId(null);
setSelectedStandaloneId(null);
setSelectedDay(startOfLocalDay(day));
if (patientIdToRefresh) {
await refreshHistory(patientIdToRefresh);
}
})();
},
[flushDraftSave, resetToLiveContext, historyPatientId, refreshHistory],
);
const handleSelectPreviewTreatment = useCallback((treatment: PastTreatment) => {
setSelectedPreviewId(treatment.id);
}, []);
const exitBrowse = useCallback(() => {
setSelectedPreviewId(null);
}, []);
const loadTreatmentIntoWorkspace = useCallback(
async (
treatment: PastTreatment,
focusDetailClientId?: string,
options?: { scrollToLabPanel?: boolean },
) => {
const ok = workspaceModeRef.current === 'live' ? await flushDraftSave() : true;
if (!ok) return false;
let treatmentToLoad = treatment;
try {
const draftResponse = treatment.appointmentId
? await treatmentsApi.getDraft(treatment.appointmentId)
: await treatmentsApi.getDraftByTreatment(treatment.id);
if (draftResponse.data) {
treatmentToLoad = draftResponse.data;
}
} catch {
// Fall back to the history snapshot when draft cannot be loaded.
}
const isHistorical = isTreatmentDayHistorical(treatmentToLoad.treatmentAt, todayStart);
setWorkspaceMode(isHistorical ? 'historical' : 'live');
setSelectedPreviewId(null);
const nextDay = startOfLocalDay(new Date(treatmentToLoad.treatmentAt));
setSelectedDay((prev) => (compareLocalDayStart(prev, nextDay) === 0 ? prev : nextDay));
setSelectionLocked(true);
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;
if (focusDetailClientId && options?.scrollToLabPanel !== false) {
pendingEntryStepRef.current = 'lab';
}
hydrateFromTreatment(treatmentToLoad);
draftHydratingRef.current = false;
if (focusDetailClientId) {
if (options?.scrollToLabPanel !== false) {
pendingEntryStepRef.current = 'lab';
}
setActiveDetailId(focusDetailClientId);
const mappedLabCases = withoutEmptyLabCaseDrafts(
(treatmentToLoad.labCases ?? []).map(mapLabCaseDraftFromApi),
);
const linked = mappedLabCases.find(
(lc) => !lc.sentAt && lc.detailClientId === focusDetailClientId,
);
if (linked) {
setActiveLabCaseId(linked.clientId);
}
if (options?.scrollToLabPanel !== false) {
setEntryStep('lab');
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
}
}
return true;
},
[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,
firstName: patient.firstName,
lastName: patient.lastName,
});
try {
const response = await treatmentsApi.listPatientHistory(patient.id, 1);
const latest = response.data[0];
if (latest) {
const ok = await loadTreatmentIntoWorkspace(latest);
if (!ok) {
setSearchedPatient(null);
}
return;
}
await refreshHistory(patient.id);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
setSearchedPatient(null);
} finally {
setPatientSearchBusy(false);
}
})();
},
[
selectedAppointment?.patientId,
selectedStandalone?.patientId,
searchedPatient?.id,
hasLiveContext,
loadTreatmentIntoWorkspace,
refreshHistory,
showError,
t,
tErrors,
],
);
const handleLoadIntoWorkspace = useCallback(() => {
if (!previewTreatment) return;
void loadTreatmentIntoWorkspace(previewTreatment);
}, [loadTreatmentIntoWorkspace, previewTreatment]);
const handleGoToLabDispatch = useCallback(
(item: LabDispatchAttentionItem) => {
if (item.isCurrentDraft) {
exitBrowse();
pendingEntryStepRef.current = 'lab';
setActiveDetailId(item.detailClientId);
const linked = labCaseDrafts.find(
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
);
if (linked) {
setActiveLabCaseId(linked.clientId);
}
setEntryStep('lab');
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
return;
}
const treatment =
history.find((entry) => entry.id === item.treatmentId) ??
historyPanelItems.find((entry) => entry.id === item.treatmentId);
if (!treatment) return;
void loadTreatmentIntoWorkspace(treatment, item.detailClientId);
},
[exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace],
);
const handleSelectPatientLabCase = useCallback(
(item: PatientLabCaseSummary) => {
void (async () => {
setSelectedRailLabCaseId(item.labCaseId);
// Ensure a patient context is established before we potentially clear the last unread update,
// so the rail section doesn't briefly unmount/collapse.
if (item.patientId && item.patientId !== activePatientId) {
setSearchedPatient({
id: item.patientId,
firstName: item.patientFirstName,
lastName: item.patientLastName,
});
}
try {
await notificationsApi.markCaseRead(item.labCaseId);
notifyTabBadgesChanged();
handleLabCaseMarkedRead(item.labCaseId);
} catch {
// Non-blocking — workspace navigation still proceeds.
}
let treatment =
history.find((entry) => entry.id === item.treatmentId) ??
historyPanelItems.find((entry) => entry.id === item.treatmentId) ??
(selectedAppointment?.id === item.appointmentId ? currentDraftPreview : null);
if (!treatment && item.patientId) {
try {
const response = await treatmentsApi.listPatientHistory(item.patientId, 50);
treatment = response.data.find((entry) => entry.id === item.treatmentId) ?? null;
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
return;
}
}
if (!treatment?.appointmentId) return;
if (
selectedAppointmentId === treatment.appointmentId &&
workspaceMode === 'live' &&
!isBrowsing
) {
pendingEntryStepRef.current = 'lab';
setActiveDetailId(item.detailClientId);
const matchingDraft = labCaseDrafts.find((lc) => lc.id === item.labCaseId);
if (matchingDraft) {
setActiveLabCaseId(matchingDraft.clientId);
}
setEntryStep('lab');
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
return;
}
await loadTreatmentIntoWorkspace(treatment, item.detailClientId, {
scrollToLabPanel: true,
});
})();
},
[
activePatientId,
currentDraftPreview,
handleLabCaseMarkedRead,
history,
historyPanelItems,
isBrowsing,
labCaseDrafts,
loadTreatmentIntoWorkspace,
selectedAppointment?.id,
selectedAppointmentId,
showError,
t,
tErrors,
workspaceMode,
],
);
useEffect(() => {
const labCaseId = pendingLabCaseIdRef.current;
if (!labCaseId) return;
const match =
unreadLabCases.find((item) => item.labCaseId === labCaseId) ??
patientLabCases.find((item) => item.labCaseId === labCaseId);
if (!match) return;
pendingLabCaseIdRef.current = null;
void handleSelectPatientLabCase(match);
}, [unreadLabCases, patientLabCases, handleSelectPatientLabCase]);
useEffect(() => {
const match = patientLabCases.find((item) => item.detailClientId === activeDetailId);
if (match) {
setSelectedRailLabCaseId(match.labCaseId);
}
}, [activeDetailId, patientLabCases]);
const uploadForDetail = useCallback(
async (
detailClientId: string,
files: FileList | File[],
onProgress?: (percent: number) => void,
) => {
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;
if (!list.length) return;
setUploadBusyDetailId(detailClientId);
try {
const uploaded = selectedAppointment
? await treatmentsApi.uploadDetailAttachments(
selectedAppointment.id,
detailClientId,
list,
onProgress,
)
: await treatmentsApi.uploadDetailAttachmentsByTreatment(
selectedStandalone!.id,
detailClientId,
list,
onProgress,
);
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(getUserFacingError(error, tErrors, t('errorUpload')));
} finally {
setUploadBusyDetailId(null);
}
},
[canEditTreatmentForDay, isDetailLocked, hasLiveContext, selectedAppointment, selectedStandalone, showSuccess, showError, t, tErrors],
);
const persistLabCases = useCallback(
async (savedTreatment: PastTreatment, draftsOverride?: LabCaseDraft[]) => {
if (!selectedAppointment && !selectedStandalone) throw new Error('No visit 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,
selectionGroupId: tp.selectionGroupId ?? '',
};
})
.filter(
(
row,
): row is {
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId: string;
} => row !== null,
),
attachmentIds: lc.attachmentIds,
dueDate: lc.dueDate ?? null,
};
})
.filter((row): row is NonNullable<typeof row> => row !== null);
if (payload.length === 0) {
return savedTreatment;
}
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) => {
if (prev && mapped.some((lc) => lc.clientId === prev)) return prev;
return mapped[0]?.clientId ?? null;
});
return response.data;
},
[labCaseDrafts, selectedAppointment, selectedStandalone],
);
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 &&
hasLiveContext &&
canEditTreatmentForDay
) {
void (async () => {
try {
const saved = await persistDraft({ force: true });
await persistLabCases(saved, cleaned);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
}
})();
}
},
[
activeDetailId,
canEditTreatmentForDay,
labCaseDrafts,
persistDraft,
persistLabCases,
selectedAppointment,
showError,
t,
tErrors,
],
);
const handleRemoveDetail = useCallback(
(detailClientId: string) => {
if (!canEditTreatmentForDay) return;
const idx = details.findIndex((d) => d.clientId === detailClientId);
if (idx < 0) return;
const target = details[idx];
if (!target || isDetailLocked(target)) return;
if (!window.confirm(t('confirmRemoveDetail'))) return;
const nextDetails = details.filter((d) => d.clientId !== detailClientId);
const nextActive =
activeDetailId === detailClientId
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ?? '')
: activeDetailId;
// Sync ref before any persist triggered by lab-case cleanup (same tick).
detailsRef.current = nextDetails;
setDetails(nextDetails);
setActiveDetailId(nextActive);
if (nextDetails.length === 0) setEntryStep('treatment');
if (labCaseDrafts.some((lc) => lc.detailClientId === detailClientId)) {
handleLabCasesChange(
withoutEmptyLabCaseDrafts(
labCaseDrafts.filter((lc) => lc.detailClientId !== detailClientId),
),
);
}
},
[
activeDetailId,
canEditTreatmentForDay,
details,
handleLabCasesChange,
isDetailLocked,
labCaseDrafts,
setEntryStep,
t,
],
);
const handleAddLabCase = useCallback(async () => {
if (!canEditTreatmentForDay || !hasLiveContext) 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);
if (
activeDetail &&
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)
) {
showError(t('labShipmentBlockedBody'));
return;
}
const shouldIncludeActive = Boolean(
activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes),
);
const orphan = cleaned.find((lc) => !lc.sentAt && !lc.detailClientId);
if (orphan && shouldIncludeActive) {
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(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
}
return;
}
const next: LabCaseDraft = {
...newLabCaseDraft(),
detailClientId: shouldIncludeActive ? activeDetailId : null,
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
};
const updatedLabCases = [...cleaned, next];
setLabCaseDrafts(updatedLabCases);
setActiveLabCaseId(next.clientId);
try {
const saved = await persistDraft({ force: true });
await persistLabCases(saved, updatedLabCases);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
}
}, [
activeDetailId,
canEditTreatmentForDay,
details,
labCaseDrafts,
labDependentCodes,
persistDraft,
persistLabCases,
selectedAppointment,
showError,
t,
tErrors,
]);
// Auto-open shipment draft when entering Lab (no manual "Add lab shipment" click).
useEffect(() => {
if (entryStep !== 'lab') return;
if (!canEditTreatmentForDay || !hasLiveContext) return;
const activeDetail = details.find((d) => d.clientId === activeDetailId);
if (!activeDetail || !isDetailReadyForLabDispatch(activeDetail, labDependentCodes)) return;
if (isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)) return;
// Already shipped (or locked) — do not create another draft (avoids post-send flicker).
if (isDetailLocked(activeDetail)) return;
if (labCaseDrafts.some((lc) => lc.detailClientId === activeDetailId && lc.sentAt)) return;
const hasDraft = labCaseDrafts.some((lc) => lc.detailClientId === activeDetailId);
if (hasDraft) return;
void handleAddLabCase();
}, [
activeDetailId,
canEditTreatmentForDay,
details,
entryStep,
handleAddLabCase,
isDetailLocked,
labCaseDrafts,
labDependentCodes,
selectedAppointment,
]);
const handleSendLabCase = useCallback(
async (labCase: LabCaseDraft, comment?: string) => {
if (!canEditTreatmentForDay || !hasLiveContext) 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 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;
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : (mapped[0]?.clientId ?? '');
});
setSavedSnapshot(serializeDetails(mapped));
} else {
const sentDetailClientId = labCase.detailClientId;
setDetails((prev) => {
const next = 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,
};
});
detailsRef.current = next;
return next;
});
}
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,
taskProgress: response.data.taskProgress ?? lc.taskProgress,
}
: lc,
),
);
setRecentOrganizationIds((prev) => {
const orgId = labCase.destinationOrganizationId!;
rememberRecentLab(currentOrganization?.id, orgId);
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, MAX_RECENT_LABS);
});
showSuccess(t('successCaseSent'));
notifyTabBadgesChanged();
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')));
} finally {
setSendBusyId(null);
}
},
[
canEditTreatmentForDay,
selectedAppointment,
persistDraft,
persistLabCases,
refreshHistory,
showSuccess,
showError,
t,
tErrors,
currentOrganization?.id,
],
);
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>
{!canEdit ? (
<p className="text-sm text-text-secondary">{t('subtitleReadOnly')}</p>
) : null}
</header>
<AppointmentsStrip
stripHidden={stripHidden}
onToggleStripHidden={() => setStripHidden((s) => !s)}
selectedDay={selectedDay}
onSelectDay={onSelectDay}
items={dayStripItems}
selectedItemId={selectedStripItemId}
onSelectItem={onPickStripItem}
onDeleteUnscheduled={canEdit ? deleteStandaloneTreatment : undefined}
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="treatment-layout-grid 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]">
<div className="surface-card p-3 space-y-3">
<PatientSearchCombobox
search={patientSearch}
onSearchChange={setPatientSearch}
patients={patientSearchResults}
loading={patientSearchLoading || patientSearchBusy}
onSelectPatient={handleSelectSearchedPatient}
placeholder={tPatients('searchPlaceholder')}
emptyResultsMessage={tPatients('noResults')}
/>
{canEdit && !isViewingPastDay ? (
<div className="space-y-2">
<Button
type="button"
size="lg"
fullWidth
aria-expanded={newTreatmentPickerOpen}
onClick={() => setNewTreatmentPickerOpen((open) => !open)}
>
{t('newTreatment')}
</Button>
{newTreatmentPickerOpen ? (
<NewTreatmentPatientPicker
creating={creatingStandalone}
onSelectWalkIn={() => createStandaloneTreatment({ walkIn: true })}
onSelectPatient={(patient) =>
createStandaloneTreatment({ patientId: patient.id })
}
onCancel={() => setNewTreatmentPickerOpen(false)}
/>
) : null}
</div>
) : null}
{activePatient ? (
<div className="space-y-0.5 border-t border-border/60 pt-3">
<p className="text-[10px] uppercase tracking-wide text-text-muted">{t('selectedPatient')}</p>
<p className="text-base font-semibold text-text-primary">{activePatientName}</p>
{activePatient.purpose ? (
<p className="text-[11px] text-text-secondary">
{t('purposeLabel')}{' '}
<span className="text-text-primary">
{treatmentTypeLabelFromCatalog(activePatient.purpose, treatmentCatalog)}
</span>
</p>
) : null}
</div>
) : (
<p className="text-sm text-text-muted border-t border-border/60 pt-3">
{apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')}
</p>
)}
</div>
{isBrowsing && previewTreatment ? (
<>
<div className="rounded-[var(--radius-md)] border border-primary/40 bg-primary/5 px-3 py-2 space-y-2">
<p className="text-xs text-text-primary">
{t('browseBanner', {
date: formatAppDate(previewTreatment.treatmentAt, locale, APP_DATE.withWeekday),
})}
</p>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="primary" size="sm" onClick={handleLoadIntoWorkspace}>
{t('loadIntoWorkspace')}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={exitBrowse}>
{t('backToCurrentDraft')}
</Button>
</div>
</div>
<TreatmentRailSection title={t('previewBrowsingTitle')} defaultExpanded>
<div className="pt-2">
<TreatmentPreviewCard
treatment={previewTreatment}
heading=""
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
orgs={orgs}
embedded
/>
</div>
</TreatmentRailSection>
</>
) : null}
{labAttentionItems.length > 0 ? (
<TreatmentRailSection
title={t('labAttentionTitle')}
subtitle={t('labAttentionSubtitle')}
count={labAttentionItems.length}
variant="attention"
>
<LabDispatchAttentionPanel
items={labAttentionItems}
treatmentCatalog={treatmentCatalog}
labDependentCodes={labDependentCodes}
orgs={orgs}
onGoToDispatch={handleGoToLabDispatch}
compact
/>
</TreatmentRailSection>
) : null}
{activePatient ? (
<TreatmentRailSection
title={t('historyPatientScope', {
patientName: activePatientName ?? '',
})}
subtitle={t('historySubtitle')}
count={historyPanelItems.length}
>
<PastTreatmentsPanel
items={historyPanelItems}
currentAppointmentId={selectedAppointmentId}
treatmentCatalog={treatmentCatalog}
labDependentCodes={labDependentCodes}
orgs={orgs}
loading={historyLoading}
selectedPreviewId={selectedPreviewId}
onSelectTreatment={handleSelectPreviewTreatment}
compact
/>
</TreatmentRailSection>
) : null}
{showLabShipmentsSection ? (
<TreatmentRailSection
title={t('labShipmentsTitle')}
subtitle={labShipmentsSubtitle}
count={displayedLabCases.length}
>
<TreatmentLabCasesPanel
scope={labCasesScope}
onScopeChange={setLabCasesScope}
items={displayedLabCases}
loading={labCasesListLoading}
locale={locale}
prosthesisCatalog={prosthesisCatalog}
unreadUpdatesCount={unreadUpdatesCount}
otherPatientsUnreadCount={otherPatientsUnreadCount}
canShowPatientScope={Boolean(activePatient)}
selectedLabCaseId={selectedRailLabCaseId ?? activeSentLabCaseId}
onSelect={handleSelectPatientLabCase}
compact
/>
</TreatmentRailSection>
) : null}
</div>
<div className="space-y-3 min-w-0 w-full">
<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={(initialType) => {
const next = newDetail(initialType);
setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId);
setEntryStep('treatment');
}}
onRemoveDetail={handleRemoveDetail}
onUploadFiles={(files, onProgress) =>
uploadForDetail(activeDetailId, files, onProgress)
}
onRemoveAttachment={(attachmentId) => {
setDetails((prev) =>
prev.map((d) =>
d.clientId === activeDetailId
? {
...d,
attachmentMetas: d.attachmentMetas.filter((a) => a.id !== attachmentId),
}
: d,
),
);
setLabCaseDrafts((prev) =>
prev.map((lc) =>
lc.detailClientId === activeDetailId && !lc.sentAt
? {
...lc,
attachmentIds: lc.attachmentIds.filter((id) => id !== attachmentId),
}
: lc,
),
);
}}
showChrome
showFields={entryStep === 'treatment'}
chartLocked={
entryStep === 'treatment' && !activeTypeSelected && !showWholeTreatmentPlan
}
chartLockMessage={t('selectTypeBeforeTeeth')}
chart={
entryStep === 'treatment' ? (
<FdiToothChart
className="w-full"
selected={chartSelectedTeeth}
linkedEdges={chartLinkedEdges}
connectedTeeth={showWholeTreatmentPlan ? undefined : connectedSelectedTeeth}
toothColors={chartToothColors}
readOnly={showWholeTreatmentPlan}
headerControl={
<button
type="button"
onClick={() => setShowWholeTreatmentPlan((open) => !open)}
aria-pressed={showWholeTreatmentPlan}
className={`
rounded-[var(--radius-md)] border px-2.5 py-1 text-[11px] leading-none transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${
showWholeTreatmentPlan
? 'border-primary bg-primary-soft font-medium text-text-primary'
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
}
`}
>
{t('toothChartWholePlan')}
</button>
}
onToggle={(fdi, event) => {
if (
!canEditTreatmentForDay ||
activeLocked ||
showWholeTreatmentPlan ||
!activeDetailId ||
!activeTypeSelected
) {
return;
}
const detail = details.find((d) => d.clientId === activeDetailId);
if (!detail) return;
const currentGroups =
detail.toothSelectionGroups.length > 0
? detail.toothSelectionGroups
: groupsFromFlatTeeth(detail.teeth);
let nextGroups: ReturnType<typeof toggleToothInGroups> | null = null;
if (event.shiftKey) {
const anchor = rangeAnchorRef.current;
if (!anchor || anchor === fdi) {
rangeAnchorRef.current = fdi;
return;
}
nextGroups = applyShiftRange(currentGroups, anchor, fdi);
rangeAnchorRef.current = fdi;
if (!nextGroups) return;
} else {
nextGroups = toggleToothInGroups(currentGroups, fdi);
rangeAnchorRef.current = fdi;
}
setDetails((prev) =>
prev.map((d) =>
d.clientId !== activeDetailId
? d
: {
...d,
toothSelectionGroups: nextGroups!,
teeth: deriveTeethFromGroups(nextGroups!),
},
),
);
setLabCaseDrafts((prev) =>
prev.map((lc) => {
if (lc.detailClientId !== activeDetailId) return lc;
return {
...lc,
toothProsthesis: pruneToothProsthesisForGroups(
lc.toothProsthesis,
activeDetailId,
nextGroups!,
),
};
}),
);
}}
onToggleLink={(a, b) => {
if (
!canEditTreatmentForDay ||
activeLocked ||
showWholeTreatmentPlan ||
!activeDetailId ||
!activeTypeSelected
) {
return;
}
const detail = details.find((d) => d.clientId === activeDetailId);
if (!detail) return;
const currentGroups =
detail.toothSelectionGroups.length > 0
? detail.toothSelectionGroups
: groupsFromFlatTeeth(detail.teeth);
const edgeLinked = linkedEdgesFromGroups(currentGroups).has(toothEdgeKey(a, b));
const nextGroups = edgeLinked
? unlinkAdjacentTeeth(currentGroups, a, b)
: linkAdjacentTeeth(currentGroups, a, b);
if (!nextGroups) return;
setDetails((prev) =>
prev.map((d) =>
d.clientId !== activeDetailId
? d
: {
...d,
toothSelectionGroups: nextGroups,
teeth: deriveTeethFromGroups(nextGroups),
},
),
);
setLabCaseDrafts((prev) =>
prev.map((lc) => {
if (lc.detailClientId !== activeDetailId) return lc;
return {
...lc,
toothProsthesis: pruneToothProsthesisForGroups(
lc.toothProsthesis,
activeDetailId,
nextGroups,
),
};
}),
);
}}
disabled={
!canEditTreatmentForDay ||
activeLocked ||
!activeTypeSelected
}
/>
) : undefined
}
stepper={
showLabWizardStep ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<WizardStepper
className="min-w-0 flex-1"
aria-label={t('entryWizardLabel')}
steps={[
{ id: 'treatment', label: t('entryStepTreatment') },
{ id: 'lab', label: t('entryStepLab') },
]}
currentStepId={entryStep}
onStepChange={(stepId) => {
const next = stepId as EntryStep;
if (next === 'lab' && canEditTreatmentForDay) {
void persistDraft({ force: true })
.then(() => setEntryStep('lab'))
.catch((error: unknown) => {
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
});
return;
}
setEntryStep(next);
}}
/>
<div className="flex flex-wrap gap-2 sm:justify-end shrink-0">
<Button
type="button"
variant="ghost"
disabled={entryStep === 'treatment'}
onClick={() => setEntryStep('treatment')}
>
{t('entryStepBack')}
</Button>
<Button
type="button"
variant="primary"
disabled={entryStep === 'lab'}
onClick={async () => {
if (canEditTreatmentForDay) {
try {
await persistDraft({ force: true });
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
return;
}
}
setEntryStep('lab');
}}
>
{t('entryStepNext')}
</Button>
</div>
</div>
) : undefined
}
/>
{entryStep === 'lab' ? (
<div ref={labPanelRef} className="space-y-3">
{showLabDispatchPanel ? (
<LabCasesDispatchPanel
details={details}
activeDetailId={activeDetailId}
labCases={labCaseDrafts}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
labCaseSummary={activeLabCaseSummary}
locale={locale}
onLabCaseSummaryChange={handleLabCaseSummaryChange}
onLabCaseMarkedRead={handleLabCaseMarkedRead}
onLabCaseActivityChange={() => {
if (historyPatientId) {
void refreshPatientLabCases(historyPatientId, { silent: true });
}
}}
activeLabCaseId={activeLabCaseId}
onLabCasesChange={handleLabCasesChange}
disabled={!canEditTreatmentForDay}
canEdit={canEdit}
orgs={orgs}
organizationSearch={organizationSearch}
onOrganizationSearchChange={setOrganizationSearch}
recentOrganizationIds={recentOrganizationIds}
sendBusyId={sendBusyId}
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
onCommentError={showError}
canInviteLab={canAccessOrganizations}
onInviteLab={() => router.push('/organizations?action=invite-lab')}
/>
) : showLabShipmentBlocked ? null : (
<p className="text-sm text-text-muted surface-card p-4">
{t('entryStepLabUnavailable')}
</p>
)}
</div>
) : null}
</div>
</div>
</div>
);
}