Review findings on this branch. The lab-case save could be posted against a detail the server has never seen. persistDraft returns a *preview* treatment instead of saving when any detail lacks a treatment type — the blank one the workspace opens with is enough — and a preview's detail id falls back to the client id. Recording straight after opening a visit and confirming a result with a lab or due date would send that id and fail the whole save. It now checks what came back rather than the precondition, so it holds for every early return persistDraft has. stop() optional-chained into a no-op when the recorder was already gone, leaving the bar recording forever with a live timer and only Cancel as a way out. Three "this browser cannot record" paths reported VOICE_MIC_DENIED — no MediaRecorder at all, no container the API accepts, and a recorder that throws after permission was already granted. Telling clinicians their microphone was denied sends them hunting for a permission nothing asked for; they now report VOICE_UNSUPPORTED_FORMAT. The voice route's large-body match stripped every trailing slash while Express ignores exactly one, so '/api/voice/extract//' bought a 10 MB buffer for a request that then 404s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2819 lines
102 KiB
TypeScript
2819 lines
102 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 { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice';
|
|
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
|
|
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
|
|
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
|
|
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
|
|
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 { voiceApi } from '@/lib/api/voice';
|
|
import { useVoiceCapture } from '@/lib/voice/useVoiceCapture';
|
|
import { VoiceReviewSheet } from '@/components/ui/treatment/VoiceReviewSheet';
|
|
import type {
|
|
VoiceApplySelection,
|
|
VoiceAvailability,
|
|
VoiceExtractionResult,
|
|
} from '@/types/voice';
|
|
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 { loadLabDispatchDefaults, rememberLastLab } 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[]>([]);
|
|
/**
|
|
* Lab cases created by confirming a voice result.
|
|
*
|
|
* Kept here rather than as a field on LabCaseDraft on purpose: a draft field is dropped
|
|
* by mapLabCaseDraftFromApi on the first server round-trip, which is exactly the window
|
|
* where the dispatch panel's remembered-prosthesis default would fire.
|
|
*/
|
|
const [voiceConfirmedLabCaseIds, setVoiceConfirmedLabCaseIds] = useState<ReadonlySet<string>>(
|
|
() => new Set<string>(),
|
|
);
|
|
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 [voiceAvailability, setVoiceAvailability] = useState<VoiceAvailability | null>(null);
|
|
const [voiceResult, setVoiceResult] = useState<VoiceExtractionResult | null>(null);
|
|
|
|
|
|
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 voice = useVoiceCapture({
|
|
// The locale the clinician is actually reading and speaking in. Sent explicitly so
|
|
// the server's ASR hint, catalog labels and week start match what the microphone was
|
|
// offered for — req.user.language can drift from the URL locale.
|
|
locale,
|
|
maxMs: voiceAvailability?.maxRecordingMs ?? null,
|
|
onExtracted: setVoiceResult,
|
|
onError: (error) => showError(getUserFacingError(error, tErrors, t('voiceFailed'))),
|
|
});
|
|
|
|
/** Absence is the unavailable state — the Add button then renders unsplit. */
|
|
const voiceForEditor =
|
|
voiceAvailability?.enabled && voiceAvailability.locales.includes(locale) ? voice : undefined;
|
|
|
|
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(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))]
|
|
: [];
|
|
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');
|
|
}, [treatmentCatalog]);
|
|
|
|
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;
|
|
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, voiceResponse] =
|
|
await Promise.all([
|
|
treatmentsApi.listLinkedOrganizations(),
|
|
treatmentCatalogApi.list(),
|
|
prosthesisCatalogApi.list(),
|
|
// Voice availability comes from the API, not a NEXT_PUBLIC_* var: those are
|
|
// baked in at build time, so enabling a locale would need a frontend rebuild.
|
|
// A failure here must not take the whole treatment tab down with it.
|
|
voiceApi.availability().catch(() => null),
|
|
]);
|
|
if (cancelled) return;
|
|
setVoiceAvailability(voiceResponse?.data ?? null);
|
|
setOrgs(orgsResponse.data);
|
|
setTreatmentCatalog(catalogResponse.data);
|
|
setProsthesisCatalog(prosthesisResponse.data);
|
|
setLabDependentCodes(
|
|
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
|
|
);
|
|
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
|
|
if (lastLabId && orgsResponse.data.some((o) => o.id === lastLabId && o.active)) {
|
|
setRecentOrganizationIds((prev) =>
|
|
prev.includes(lastLabId) ? prev : [lastLabId, ...prev].slice(0, 10),
|
|
);
|
|
}
|
|
} 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,
|
|
],
|
|
);
|
|
|
|
/**
|
|
* Voice entry.
|
|
*
|
|
* Confirm always appends a NEW detail — it never edits an existing one, and never
|
|
* touches onAddDetail. Nothing is created until this runs, so cancelling or a failed
|
|
* recording leaves the chip strip untouched.
|
|
*/
|
|
const applyVoiceResult = useCallback(
|
|
(result: VoiceExtractionResult, selection: VoiceApplySelection) => {
|
|
const detail = newDetail(
|
|
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
|
|
);
|
|
|
|
// Ticked rows land on top of the seeded defaults, so unticking the type row leaves
|
|
// the appointment-purpose default rather than a blank.
|
|
if (selection.treatmentType && result.treatmentType) {
|
|
detail.treatmentType = result.treatmentType;
|
|
}
|
|
if (selection.teeth) {
|
|
detail.teeth = [...result.teeth];
|
|
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
|
|
...group,
|
|
teeth: [...group.teeth],
|
|
}));
|
|
}
|
|
if (selection.comment && result.comment) {
|
|
detail.comment = result.comment;
|
|
}
|
|
|
|
const nextDetails = [...detailsRef.current, detail];
|
|
setDetails(nextDetails);
|
|
// persistDraft reads detailsRef, and setDetails has not rendered yet. The codebase
|
|
// already writes this ref imperatively after a save for the same reason.
|
|
detailsRef.current = nextDetails;
|
|
setActiveDetailId(detail.clientId);
|
|
setEntryStep('treatment');
|
|
|
|
// Lab-side rows ride on a lab case draft keyed by the detail's *client* id, so a
|
|
// brand-new unsaved detail can still carry one; it is persisted after the detail is.
|
|
const wantsLabDraft =
|
|
(selection.prosthesis && result.prosthesis) ||
|
|
(selection.lab && result.labId) ||
|
|
(selection.dueDate && result.dueDate);
|
|
|
|
if (wantsLabDraft) {
|
|
const draft = newLabCaseDraft();
|
|
draft.detailClientId = detail.clientId;
|
|
if (selection.lab && result.labId) {
|
|
draft.destinationOrganizationId = result.labId;
|
|
}
|
|
if (selection.dueDate && result.dueDate) {
|
|
draft.dueDate = result.dueDate;
|
|
}
|
|
if (selection.prosthesis && result.prosthesis) {
|
|
// byTooth keys are plain strings; the group's teeth are FdiToothId.
|
|
const groupOf = (tooth: string) =>
|
|
result.toothSelectionGroups.find((group) =>
|
|
(group.teeth as readonly string[]).includes(tooth),
|
|
)?.groupId ?? '';
|
|
// Only teeth that actually landed on the detail. Unticking "teeth" while
|
|
// leaving "prosthesis" ticked would otherwise attach prosthesis rows for teeth
|
|
// the treatment does not contain — nothing downstream filters them, and they
|
|
// would reach task generation as work for teeth nobody is treating.
|
|
const detailTeeth = new Set<string>(detail.teeth);
|
|
draft.toothProsthesis = Object.entries(result.prosthesis.byTooth)
|
|
.filter(([tooth]) => detailTeeth.has(tooth))
|
|
.map(([tooth, prosthesisTypeCode]) => ({
|
|
detailClientId: detail.clientId,
|
|
tooth,
|
|
prosthesisTypeCode,
|
|
selectionGroupId: groupOf(tooth),
|
|
}));
|
|
}
|
|
const updatedLabCases = [...labCaseDrafts, draft];
|
|
setLabCaseDrafts(updatedLabCases);
|
|
// The sheet already showed the clinician exactly what this case would contain, so
|
|
// the dispatch panel must not top it up with a remembered default afterwards.
|
|
setVoiceConfirmedLabCaseIds((prev) => new Set(prev).add(draft.clientId));
|
|
|
|
// Every other path that creates a lab draft persists it immediately, and the
|
|
// autosave effect only watches `details`. Left in state alone, the destination
|
|
// lab, the due date and the whole prosthesis map vanish on the next reload —
|
|
// silently, because the detail itself does survive.
|
|
void (async () => {
|
|
try {
|
|
const saved = await persistDraft({ force: true });
|
|
// persistDraft returns a *preview* treatment rather than saving when the
|
|
// details are not persistable — one blank detail, the kind the workspace opens
|
|
// with, is enough. A preview's detail id falls back to the client id, so
|
|
// posting lab cases against it would send the server an id it has never seen
|
|
// and fail the whole save. Check what came back, not the precondition, so this
|
|
// holds for every early return persistDraft has.
|
|
const savedDetail = saved.details.find((d) => d.clientId === detail.clientId);
|
|
if (!savedDetail?.id || savedDetail.id === detail.clientId) return;
|
|
await persistLabCases(saved, updatedLabCases);
|
|
} catch (error: unknown) {
|
|
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
|
|
}
|
|
})();
|
|
}
|
|
|
|
setVoiceResult(null);
|
|
},
|
|
[
|
|
labCaseDrafts,
|
|
persistDraft,
|
|
persistLabCases,
|
|
selectedAppointment?.purpose,
|
|
showError,
|
|
t,
|
|
tErrors,
|
|
treatmentCatalog,
|
|
],
|
|
);
|
|
|
|
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 lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
|
|
const lastLabStillActive = lastLabId
|
|
? orgs.some((o) => o.id === lastLabId && o.active)
|
|
: false;
|
|
const next: LabCaseDraft = {
|
|
...newLabCaseDraft(),
|
|
detailClientId: shouldIncludeActive ? activeDetailId : null,
|
|
destinationOrganizationId: lastLabStillActive ? lastLabId! : 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,
|
|
currentOrganization?.id,
|
|
details,
|
|
labCaseDrafts,
|
|
labDependentCodes,
|
|
orgs,
|
|
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!;
|
|
rememberLastLab(currentOrganization?.id, orgId);
|
|
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
|
|
});
|
|
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={() => {
|
|
const next = newDetail(
|
|
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
|
|
);
|
|
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'}
|
|
voice={voiceForEditor}
|
|
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">
|
|
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
|
|
{showLabDispatchPanel ? (
|
|
<LabCasesDispatchPanel
|
|
details={details}
|
|
activeDetailId={activeDetailId}
|
|
labCases={labCaseDrafts}
|
|
labDependentCodes={labDependentCodes}
|
|
treatmentCatalog={treatmentCatalog}
|
|
clinicOrganizationId={currentOrganization?.id}
|
|
labCaseSummary={activeLabCaseSummary}
|
|
locale={locale}
|
|
onLabCaseSummaryChange={handleLabCaseSummaryChange}
|
|
onLabCaseMarkedRead={handleLabCaseMarkedRead}
|
|
onLabCaseActivityChange={() => {
|
|
if (historyPatientId) {
|
|
void refreshPatientLabCases(historyPatientId, { silent: true });
|
|
}
|
|
}}
|
|
activeLabCaseId={activeLabCaseId}
|
|
previewConfirmedCaseIds={voiceConfirmedLabCaseIds}
|
|
onLabCasesChange={handleLabCasesChange}
|
|
disabled={!canEditTreatmentForDay}
|
|
canEdit={canEdit}
|
|
orgs={orgs}
|
|
organizationSearch={organizationSearch}
|
|
onOrganizationSearchChange={setOrganizationSearch}
|
|
recentOrganizationIds={recentOrganizationIds}
|
|
onRecentOrganizationPick={(orgId) => {
|
|
setLabCaseDrafts((prev) => {
|
|
const targetId =
|
|
activeLabCaseId ??
|
|
prev.find((lc) => !lc.sentAt && lc.detailClientId === activeDetailId)
|
|
?.clientId;
|
|
if (!targetId) return prev;
|
|
return prev.map((lc) =>
|
|
lc.clientId === targetId && !lc.sentAt
|
|
? { ...lc, destinationOrganizationId: orgId }
|
|
: lc,
|
|
);
|
|
});
|
|
}}
|
|
sendBusyId={sendBusyId}
|
|
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
|
|
onCommentError={showError}
|
|
canInviteLab={canAccessOrganizations}
|
|
onInviteLab={() => router.push('/organizations?action=invite-lab')}
|
|
/>
|
|
) : (
|
|
<p className="text-sm text-text-muted surface-card p-4">
|
|
{t('entryStepLabUnavailable')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{voiceResult ? (
|
|
<VoiceReviewSheet
|
|
result={voiceResult}
|
|
treatmentCatalog={treatmentCatalog}
|
|
prosthesisCatalog={prosthesisCatalog}
|
|
labs={orgs}
|
|
onApply={(selection, applied) => applyVoiceResult(applied, selection)}
|
|
onDiscard={() => setVoiceResult(null)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|