2026-05-07 03:40:29 +03:30
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
2026-07-14 01:27:11 +03:30
|
|
|
import { useLocale, useTranslations } from 'next-intl';
|
2026-07-11 03:12:56 +03:30
|
|
|
import { useRouter } from '@/i18n/navigation';
|
2026-07-13 01:03:26 +03:30
|
|
|
import { Button } from '@/components/ui/shared/Button';
|
|
|
|
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
2026-07-17 00:39:32 +03:30
|
|
|
import { WizardStepper } from '@/components/ui/shared/WizardStepper';
|
2026-07-13 22:53:23 +03:30
|
|
|
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
|
|
|
|
import {
|
|
|
|
|
TreatmentLabCasesPanel,
|
|
|
|
|
type TreatmentLabCasesScope,
|
|
|
|
|
} from '@/components/ui/treatment/TreatmentLabCasesPanel';
|
|
|
|
|
import { TreatmentRailSection } from '@/components/ui/treatment/TreatmentRailSection';
|
2026-05-07 03:46:18 +03:30
|
|
|
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
|
|
|
|
|
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
2026-06-28 17:14:02 +03:30
|
|
|
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
|
2026-07-13 01:03:26 +03:30
|
|
|
import { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice';
|
|
|
|
|
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
|
2026-05-07 03:46:18 +03:30
|
|
|
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
|
2026-06-28 17:14:02 +03:30
|
|
|
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
|
2026-05-19 23:43:43 +03:30
|
|
|
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
|
2026-07-12 21:08:29 +03:30
|
|
|
import { treatmentTypeLabelFromCatalog, treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
|
2026-05-07 03:40:29 +03:30
|
|
|
import {
|
2026-05-19 22:29:29 +03:30
|
|
|
addCalendarDays,
|
|
|
|
|
compareLocalDayStart,
|
|
|
|
|
isSameLocalCalendarDay,
|
|
|
|
|
startOfLocalDay,
|
|
|
|
|
} from '@/components/appointments/appointmentTime';
|
|
|
|
|
import { appointmentsApi } from '@/lib/api/appointments';
|
2026-06-28 17:14:02 +03:30
|
|
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
2026-07-13 22:53:23 +03:30
|
|
|
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
2026-05-19 22:29:29 +03:30
|
|
|
import { treatmentsApi } from '@/lib/api/treatments';
|
2026-07-13 22:53:23 +03:30
|
|
|
import { notificationsApi } from '@/lib/api/notifications';
|
2026-05-18 14:08:07 +03:30
|
|
|
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
|
2026-07-13 01:03:26 +03:30
|
|
|
import {
|
|
|
|
|
areDetailsPersistable,
|
|
|
|
|
defaultTreatmentTypeForAppointment,
|
|
|
|
|
isDetailReadyForLabDispatch,
|
|
|
|
|
isEmptyDraftDetail,
|
|
|
|
|
isLabDependentDetailMissingTeeth,
|
|
|
|
|
} from '@/components/treatment/treatmentDetailRules';
|
2026-07-17 00:39:32 +03:30
|
|
|
import {
|
|
|
|
|
applyShiftRange,
|
|
|
|
|
connectedTeethSet,
|
|
|
|
|
deriveTeethFromGroups,
|
|
|
|
|
groupsFromFlatTeeth,
|
|
|
|
|
pruneToothProsthesisForGroups,
|
|
|
|
|
toggleToothInGroups,
|
|
|
|
|
} from '@/components/treatment/toothSelectionGroups';
|
2026-07-13 01:03:26 +03:30
|
|
|
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
|
|
|
|
|
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
|
|
|
|
|
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
|
|
|
|
|
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
2026-07-13 22:53:23 +03:30
|
|
|
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
|
|
|
|
|
import { tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
|
2026-07-13 17:44:44 +03:30
|
|
|
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
2026-07-14 01:27:11 +03:30
|
|
|
import { APP_DATE, formatAppDate } from '@/lib/i18n/format';
|
2026-07-13 22:53:23 +03:30
|
|
|
import { useAuth } from '@/lib/hooks/useAuth';
|
2026-07-12 18:27:38 +03:30
|
|
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
2026-05-19 23:43:43 +03:30
|
|
|
import { useToast } from '@/lib/hooks/useToast';
|
2026-07-13 22:53:23 +03:30
|
|
|
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
|
2026-05-07 03:40:29 +03:30
|
|
|
import type { Organization } from '@/types/organization';
|
2026-07-13 22:53:23 +03:30
|
|
|
import type { Patient } from '@/types/patient';
|
2026-05-19 22:29:29 +03:30
|
|
|
import type { AppointmentRecord } from '@/types/appointment';
|
2026-07-13 22:53:23 +03:30
|
|
|
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
2026-05-07 03:40:29 +03:30
|
|
|
import type {
|
|
|
|
|
FdiToothId,
|
2026-06-28 17:14:02 +03:30
|
|
|
LabCaseDraft,
|
2026-05-07 03:40:29 +03:30
|
|
|
LinkedOrganizationOption,
|
2026-06-28 17:14:02 +03:30
|
|
|
PastLabCase,
|
2026-05-07 03:40:29 +03:30
|
|
|
PastTreatment,
|
2026-05-19 22:29:29 +03:30
|
|
|
PastTreatmentCase,
|
2026-05-07 03:40:29 +03:30
|
|
|
TreatmentAppointment,
|
2026-06-28 17:14:02 +03:30
|
|
|
TreatmentDetailDraft,
|
2026-05-07 03:40:29 +03:30
|
|
|
} from '@/types/treatment';
|
2026-07-13 22:53:23 +03:30
|
|
|
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
2026-05-07 03:40:29 +03:30
|
|
|
|
2026-06-28 21:45:33 +03:30
|
|
|
type WorkspaceMode = 'live' | 'historical';
|
2026-07-17 00:39:32 +03:30
|
|
|
type EntryStep = 'teeth' | 'content' | 'lab';
|
|
|
|
|
|
|
|
|
|
const ENTRY_STEPS: EntryStep[] = ['teeth', 'content', 'lab'];
|
2026-06-28 21:45:33 +03:30
|
|
|
|
|
|
|
|
function isTreatmentDayHistorical(treatmentAt: string, todayStart: Date): boolean {
|
|
|
|
|
return compareLocalDayStart(new Date(treatmentAt), todayStart) < 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 15:26:36 +03:30
|
|
|
function withoutEmptyLabCaseDrafts(drafts: LabCaseDraft[]): LabCaseDraft[] {
|
|
|
|
|
return drafts.filter((lc) => lc.sentAt || Boolean(lc.detailClientId));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 21:45:33 +03:30
|
|
|
function labCaseDraftsToPast(
|
|
|
|
|
labCaseDrafts: LabCaseDraft[],
|
|
|
|
|
details: TreatmentDetailDraft[],
|
|
|
|
|
): PastLabCase[] {
|
2026-07-10 15:26:36 +03:30
|
|
|
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,
|
2026-07-17 00:39:32 +03:30
|
|
|
toothSelectionGroups: linkedDetail.toothSelectionGroups,
|
2026-07-10 15:26:36 +03:30
|
|
|
}
|
|
|
|
|
: null,
|
|
|
|
|
sends: lc.sends ?? [],
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-06-28 21:45:33 +03:30
|
|
|
}
|
|
|
|
|
|
2026-07-07 15:31:09 +03:30
|
|
|
function enrichDetailsWithLabSendState(
|
|
|
|
|
details: TreatmentDetailDraft[],
|
|
|
|
|
labCaseDrafts: LabCaseDraft[],
|
|
|
|
|
): TreatmentDetailDraft[] {
|
|
|
|
|
return details.map((detail) => {
|
|
|
|
|
const sentLabCase = labCaseDrafts.find(
|
2026-07-10 15:26:36 +03:30
|
|
|
(lc) => lc.sentAt && lc.detailClientId === detail.clientId,
|
2026-07-07 15:31:09 +03:30
|
|
|
);
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 21:45:33 +03:30
|
|
|
function buildWorkspaceSnapshot(
|
|
|
|
|
appointment: TreatmentAppointment,
|
|
|
|
|
details: TreatmentDetailDraft[],
|
|
|
|
|
labCaseDrafts: LabCaseDraft[],
|
|
|
|
|
title: string,
|
|
|
|
|
id?: string,
|
|
|
|
|
): PastTreatment {
|
2026-07-07 15:31:09 +03:30
|
|
|
const detailsForPreview = enrichDetailsWithLabSendState(details, labCaseDrafts);
|
2026-06-28 21:45:33 +03:30
|
|
|
return {
|
2026-07-07 15:31:09 +03:30
|
|
|
...detailsToPreviewTreatment(detailsForPreview, {
|
2026-06-28 21:45:33 +03:30
|
|
|
id: id ?? `preview-${appointment.id}`,
|
|
|
|
|
title,
|
|
|
|
|
patientId: appointment.patientId,
|
|
|
|
|
treatmentAt: appointment.startAt,
|
|
|
|
|
}),
|
|
|
|
|
appointmentId: appointment.id,
|
|
|
|
|
labCases: labCaseDraftsToPast(labCaseDrafts, details),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 01:03:26 +03:30
|
|
|
function newDetail(defaultTreatmentType?: string): TreatmentDetailDraft {
|
2026-05-07 03:40:29 +03:30
|
|
|
return {
|
|
|
|
|
clientId:
|
|
|
|
|
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
|
|
|
? crypto.randomUUID()
|
2026-06-28 17:14:02 +03:30
|
|
|
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
2026-07-13 01:03:26 +03:30
|
|
|
treatmentType: defaultTreatmentType ?? '',
|
2026-05-07 03:40:29 +03:30
|
|
|
teeth: [],
|
2026-07-17 00:39:32 +03:30
|
|
|
toothSelectionGroups: [],
|
2026-05-07 03:40:29 +03:30
|
|
|
comment: '',
|
|
|
|
|
attachmentMetas: [],
|
|
|
|
|
sendToOrganizationIds: [],
|
|
|
|
|
sentAt: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
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,
|
2026-07-10 15:26:36 +03:30
|
|
|
detailClientId: null,
|
2026-07-06 20:40:19 +03:30
|
|
|
toothProsthesis: [],
|
2026-07-07 18:43:10 +03:30
|
|
|
attachmentIds: [],
|
2026-06-28 17:14:02 +03:30
|
|
|
sentAt: null,
|
|
|
|
|
sends: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:29:29 +03:30
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
|
2026-07-17 00:39:32 +03:30
|
|
|
const teeth = d.teeth;
|
|
|
|
|
const toothSelectionGroups = groupsFromFlatTeeth(teeth, d.toothSelectionGroups ?? null);
|
2026-05-19 22:29:29 +03:30
|
|
|
return {
|
2026-06-28 15:34:56 +03:30
|
|
|
clientId: d.clientId,
|
|
|
|
|
id: d.id,
|
|
|
|
|
treatmentType: d.treatmentType,
|
2026-07-17 00:39:32 +03:30
|
|
|
teeth,
|
|
|
|
|
toothSelectionGroups,
|
2026-06-28 15:34:56 +03:30
|
|
|
comment: d.notes ?? '',
|
|
|
|
|
attachmentMetas: d.attachmentMetas ?? [],
|
|
|
|
|
labCaseId: d.labCaseId ?? null,
|
2026-07-13 01:03:26 +03:30
|
|
|
taskProgress: d.taskProgress ?? null,
|
2026-06-28 15:34:56 +03:30
|
|
|
sendToOrganizationIds: d.destinationOrganizationId ? [d.destinationOrganizationId] : [],
|
|
|
|
|
sends: d.sends ?? [],
|
|
|
|
|
sentAt: d.sentAt ?? null,
|
2026-05-19 22:29:29 +03:30
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
|
|
|
|
|
return {
|
|
|
|
|
clientId: lc.clientId,
|
|
|
|
|
id: lc.id,
|
|
|
|
|
destinationOrganizationId: lc.destinationOrganizationId,
|
2026-07-10 15:26:36 +03:30
|
|
|
detailClientId: lc.detail?.clientId ?? null,
|
2026-07-06 20:40:19 +03:30
|
|
|
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
|
2026-07-10 15:26:36 +03:30
|
|
|
detailClientId: lc.detail?.clientId ?? tp.treatmentDetailId,
|
2026-07-06 20:40:19 +03:30
|
|
|
tooth: tp.tooth,
|
|
|
|
|
prosthesisTypeCode: tp.prosthesisTypeCode,
|
2026-07-17 00:39:32 +03:30
|
|
|
selectionGroupId: tp.selectionGroupId ?? '',
|
2026-07-06 20:40:19 +03:30
|
|
|
})),
|
2026-07-07 18:43:10 +03:30
|
|
|
attachmentIds: (lc.attachments ?? []).map((a) => a.id),
|
2026-06-28 17:14:02 +03:30
|
|
|
sentAt: lc.sentAt ?? null,
|
|
|
|
|
sends: lc.sends ?? [],
|
2026-07-13 16:30:36 +03:30
|
|
|
dueDate: lc.dueDate ?? null,
|
|
|
|
|
taskProgress: lc.taskProgress ?? null,
|
2026-06-28 17:14:02 +03:30
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function serializeDetails(details: TreatmentDetailDraft[]) {
|
2026-05-19 22:29:29 +03:30
|
|
|
return JSON.stringify(
|
2026-06-28 17:14:02 +03:30
|
|
|
details.map((d) => ({
|
|
|
|
|
clientId: d.clientId,
|
|
|
|
|
id: d.id,
|
|
|
|
|
treatmentType: d.treatmentType,
|
|
|
|
|
teeth: d.teeth,
|
2026-07-17 00:39:32 +03:30
|
|
|
toothSelectionGroups: d.toothSelectionGroups,
|
2026-06-28 17:14:02 +03:30
|
|
|
comment: d.comment,
|
|
|
|
|
attachmentMetas: d.attachmentMetas,
|
2026-05-19 22:29:29 +03:30
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 18:08:29 +03:30
|
|
|
function isDetailsDirty(
|
|
|
|
|
details: TreatmentDetailDraft[],
|
|
|
|
|
savedSnapshot: string | null,
|
|
|
|
|
): boolean {
|
|
|
|
|
if (savedSnapshot === null) {
|
2026-07-13 01:03:26 +03:30
|
|
|
return details.some((d) => !isEmptyDraftDetail(d)) || details.length > 1;
|
2026-06-28 18:08:29 +03:30
|
|
|
}
|
|
|
|
|
return serializeDetails(details) !== savedSnapshot;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 00:02:40 +03:30
|
|
|
/** 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,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
function detailsToPreviewTreatment(
|
|
|
|
|
details: TreatmentDetailDraft[],
|
2026-06-28 21:45:33 +03:30
|
|
|
meta: { title: string; patientId: string; treatmentAt: string; id?: string },
|
2026-05-19 23:43:43 +03:30
|
|
|
): PastTreatment {
|
|
|
|
|
return {
|
|
|
|
|
id: meta.id ?? 'current-draft',
|
|
|
|
|
patientId: meta.patientId,
|
|
|
|
|
title: meta.title,
|
|
|
|
|
treatmentAt: meta.treatmentAt,
|
2026-06-28 17:14:02 +03:30
|
|
|
details: details.map((d, idx) => ({
|
|
|
|
|
id: d.id ?? d.clientId ?? `draft-${idx + 1}`,
|
|
|
|
|
clientId: d.clientId,
|
|
|
|
|
treatmentType: d.treatmentType,
|
|
|
|
|
teeth: d.teeth,
|
2026-07-17 00:39:32 +03:30
|
|
|
toothSelectionGroups: d.toothSelectionGroups,
|
2026-06-28 17:14:02 +03:30
|
|
|
notes: d.comment || null,
|
|
|
|
|
attachmentMetas: d.attachmentMetas,
|
|
|
|
|
labCaseId: d.labCaseId ?? null,
|
2026-07-13 01:03:26 +03:30
|
|
|
taskProgress: d.taskProgress ?? null,
|
2026-06-28 17:14:02 +03:30
|
|
|
destinationOrganizationId: d.sendToOrganizationIds[0] ?? null,
|
|
|
|
|
sends: d.sends ?? [],
|
|
|
|
|
sentAt: d.sentAt ?? null,
|
2026-05-19 23:43:43 +03:30
|
|
|
})),
|
2026-06-28 15:34:56 +03:30
|
|
|
labCases: [],
|
2026-05-19 23:43:43 +03:30
|
|
|
documents: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 03:40:29 +03:30
|
|
|
interface TreatmentWorkspaceProps {
|
|
|
|
|
userId: string;
|
|
|
|
|
currentOrganization: Organization | null;
|
2026-07-11 03:12:56 +03:30
|
|
|
initialAppointmentId?: string | null;
|
2026-05-07 03:40:29 +03:30
|
|
|
}
|
|
|
|
|
|
2026-07-11 03:12:56 +03:30
|
|
|
export function TreatmentWorkspace({
|
|
|
|
|
userId,
|
|
|
|
|
currentOrganization,
|
|
|
|
|
initialAppointmentId = null,
|
|
|
|
|
}: TreatmentWorkspaceProps) {
|
2026-07-14 01:27:11 +03:30
|
|
|
const locale = useLocale();
|
2026-06-20 14:51:43 +03:30
|
|
|
const t = useTranslations('treatment');
|
2026-07-12 18:27:38 +03:30
|
|
|
const tErrors = useTranslations('errors');
|
2026-07-13 22:53:23 +03:30
|
|
|
const tPatients = useTranslations('patients');
|
2026-07-11 03:12:56 +03:30
|
|
|
const router = useRouter();
|
2026-07-13 22:53:23 +03:30
|
|
|
const { user } = useAuth();
|
2026-05-19 23:43:43 +03:30
|
|
|
const { showError, showSuccess, messages: toastMessages } = useToast();
|
2026-05-19 22:29:29 +03:30
|
|
|
const canView = canViewTreatment(currentOrganization);
|
2026-05-07 03:40:29 +03:30
|
|
|
const canEdit = canEditTreatment(currentOrganization);
|
2026-07-13 17:44:44 +03:30
|
|
|
useMarkTabReadOnVisit();
|
2026-07-13 22:53:23 +03:30
|
|
|
const tabBadgeCounts = useTabBadgeCounts();
|
|
|
|
|
const initialLabCasesScopeSetRef = useRef(false);
|
2026-05-07 03:40:29 +03:30
|
|
|
|
|
|
|
|
const [stripHidden, setStripHidden] = useState(false);
|
2026-05-18 12:50:25 +03:30
|
|
|
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
|
2026-05-07 03:40:29 +03:30
|
|
|
|
|
|
|
|
const [selectedDay, setSelectedDay] = useState(() => startOfLocalDay(new Date()));
|
|
|
|
|
const [appointments, setAppointments] = useState<TreatmentAppointment[]>([]);
|
|
|
|
|
const [apptsLoading, setApptsLoading] = useState(false);
|
|
|
|
|
const [selectionLocked, setSelectionLocked] = useState(false);
|
|
|
|
|
const [selectedAppointmentId, setSelectedAppointmentId] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const [history, setHistory] = useState<PastTreatment[]>([]);
|
|
|
|
|
const [historyLoading, setHistoryLoading] = useState(false);
|
2026-06-28 21:45:33 +03:30
|
|
|
const [historyPatientId, setHistoryPatientId] = useState<string | null>(null);
|
2026-07-13 22:53:23 +03:30
|
|
|
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 {
|
|
|
|
|
search: patientSearch,
|
|
|
|
|
setSearch: setPatientSearch,
|
|
|
|
|
patients: patientSearchResults,
|
|
|
|
|
loading: patientSearchLoading,
|
|
|
|
|
} = usePatientSearchQuery(canView);
|
2026-05-07 03:40:29 +03:30
|
|
|
|
|
|
|
|
const [orgs, setOrgs] = useState<LinkedOrganizationOption[]>([]);
|
2026-06-28 17:14:02 +03:30
|
|
|
const [labDependentCodes, setLabDependentCodes] = useState<Set<string>>(new Set());
|
2026-07-06 20:40:19 +03:30
|
|
|
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
2026-07-13 22:53:23 +03:30
|
|
|
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
2026-07-07 13:13:04 +03:30
|
|
|
const treatmentDropdownCatalog = useMemo(
|
|
|
|
|
() => treatmentCatalog.filter((entry) => entry.availableInTreatment),
|
|
|
|
|
[treatmentCatalog],
|
|
|
|
|
);
|
2026-05-07 03:40:29 +03:30
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
const [details, setDetails] = useState<TreatmentDetailDraft[]>(() => [newDetail()]);
|
|
|
|
|
const [labCaseDrafts, setLabCaseDrafts] = useState<LabCaseDraft[]>([]);
|
|
|
|
|
const [activeDetailId, setActiveDetailId] = useState<string>(() => details[0].clientId);
|
|
|
|
|
const [activeLabCaseId, setActiveLabCaseId] = useState<string | null>(null);
|
2026-05-19 22:29:29 +03:30
|
|
|
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
|
2026-06-28 18:08:29 +03:30
|
|
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'dirty' | 'saving' | 'saved' | 'error'>('idle');
|
2026-06-28 21:45:33 +03:30
|
|
|
const [selectedPreviewId, setSelectedPreviewId] = useState<string | null>(null);
|
|
|
|
|
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('live');
|
2026-05-07 03:40:29 +03:30
|
|
|
|
|
|
|
|
const selectionLockedRef = useRef(selectionLocked);
|
|
|
|
|
selectionLockedRef.current = selectionLocked;
|
|
|
|
|
|
2026-06-28 18:08:29 +03:30
|
|
|
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);
|
2026-06-28 21:45:33 +03:30
|
|
|
const workspaceModeRef = useRef(workspaceMode);
|
|
|
|
|
workspaceModeRef.current = workspaceMode;
|
|
|
|
|
const labCaseDraftsRef = useRef(labCaseDrafts);
|
|
|
|
|
labCaseDraftsRef.current = labCaseDrafts;
|
|
|
|
|
const skipNextGetDraftRef = useRef(false);
|
2026-07-11 03:12:56 +03:30
|
|
|
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
|
2026-07-13 01:03:26 +03:30
|
|
|
const labPanelRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const historyRequestRef = useRef(0);
|
2026-07-18 00:02:40 +03:30
|
|
|
/** When set, activeDetailId effect opens this wizard step instead of resetting to teeth. */
|
|
|
|
|
const pendingEntryStepRef = useRef<EntryStep | null>(null);
|
2026-07-11 03:12:56 +03:30
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
pendingAppointmentIdRef.current = initialAppointmentId;
|
|
|
|
|
if (initialAppointmentId) {
|
|
|
|
|
setSelectedDay(startOfLocalDay(new Date()));
|
|
|
|
|
setSelectionLocked(false);
|
|
|
|
|
}
|
|
|
|
|
}, [initialAppointmentId]);
|
2026-06-28 18:08:29 +03:30
|
|
|
|
2026-05-07 03:40:29 +03:30
|
|
|
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
|
2026-06-28 17:14:02 +03:30
|
|
|
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
|
2026-05-07 14:20:50 +03:30
|
|
|
const [organizationSearch, setOrganizationSearch] = useState('');
|
|
|
|
|
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
|
2026-07-07 18:43:10 +03:30
|
|
|
const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
|
2026-07-17 00:39:32 +03:30
|
|
|
const [entryStep, setEntryStep] = useState<EntryStep>('teeth');
|
2026-05-19 23:43:43 +03:30
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
const isDetailLocked = useCallback(
|
|
|
|
|
(detail: TreatmentDetailDraft) =>
|
2026-07-10 15:26:36 +03:30
|
|
|
labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientId === detail.clientId),
|
2026-06-28 17:14:02 +03:30
|
|
|
[labCaseDrafts],
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-13 17:44:44 +03:30
|
|
|
const activeSentLabCaseId = useMemo(() => {
|
|
|
|
|
const match = labCaseDrafts.find(
|
|
|
|
|
(lc) => lc.detailClientId === activeDetailId && lc.sentAt && lc.id,
|
|
|
|
|
);
|
|
|
|
|
return match?.id ?? null;
|
|
|
|
|
}, [labCaseDrafts, activeDetailId]);
|
|
|
|
|
|
2026-06-28 18:08:29 +03:30
|
|
|
const isDirty = useMemo(
|
|
|
|
|
() => isDetailsDirty(details, savedSnapshot),
|
|
|
|
|
[details, savedSnapshot],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const AUTOSAVE_DEBOUNCE_MS = 600;
|
2026-05-19 22:29:29 +03:30
|
|
|
|
2026-05-07 03:40:29 +03:30
|
|
|
const selectedAppointment = useMemo(
|
|
|
|
|
() => appointments.find((a) => a.id === selectedAppointmentId) ?? null,
|
|
|
|
|
[appointments, selectedAppointmentId],
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-13 22:53:23 +03:30
|
|
|
const activePatient = useMemo(() => {
|
|
|
|
|
if (selectedAppointment) {
|
|
|
|
|
return {
|
|
|
|
|
id: selectedAppointment.patientId,
|
|
|
|
|
firstName: selectedAppointment.patientFirstName,
|
|
|
|
|
lastName: selectedAppointment.patientLastName,
|
|
|
|
|
purpose: selectedAppointment.purpose,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (searchedPatient) {
|
|
|
|
|
return {
|
|
|
|
|
id: searchedPatient.id,
|
|
|
|
|
firstName: searchedPatient.firstName,
|
|
|
|
|
lastName: searchedPatient.lastName,
|
|
|
|
|
purpose: undefined as string | undefined,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}, [selectedAppointment, searchedPatient]);
|
|
|
|
|
|
|
|
|
|
const activePatientId = activePatient?.id ?? null;
|
|
|
|
|
const activePatientName = activePatient
|
|
|
|
|
? `${activePatient.firstName} ${activePatient.lastName}`
|
|
|
|
|
: 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 (selectedAppointment && searchedPatient?.id === selectedAppointment.patientId) {
|
|
|
|
|
setSearchedPatient(null);
|
|
|
|
|
}
|
|
|
|
|
}, [selectedAppointment, searchedPatient?.id]);
|
|
|
|
|
|
2026-05-18 12:50:25 +03:30
|
|
|
const isViewingPastDay = useMemo(
|
|
|
|
|
() => compareLocalDayStart(selectedDay, todayStart) < 0,
|
|
|
|
|
[selectedDay, todayStart],
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-28 21:45:33 +03:30
|
|
|
const canEditTreatmentForDay =
|
|
|
|
|
canEdit &&
|
|
|
|
|
Boolean(selectedAppointment) &&
|
|
|
|
|
!isViewingPastDay &&
|
|
|
|
|
workspaceMode === 'live';
|
|
|
|
|
|
2026-07-13 01:03:26 +03:30
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-17 00:39:32 +03:30
|
|
|
/** Lab wizard step only for prosthesis (lab-dependent) treatment types on the active detail. */
|
|
|
|
|
const showLabWizardStep = useMemo(
|
|
|
|
|
() => Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType)),
|
|
|
|
|
[activeDetail, labDependentCodes],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const visibleEntrySteps = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
showLabWizardStep
|
|
|
|
|
? ENTRY_STEPS
|
|
|
|
|
: (ENTRY_STEPS.filter((step) => step !== 'lab') as EntryStep[]),
|
|
|
|
|
[showLabWizardStep],
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-13 01:03:26 +03:30
|
|
|
const showLabShipmentBlocked = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
Boolean(
|
|
|
|
|
activeDetail && isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes),
|
|
|
|
|
),
|
|
|
|
|
[activeDetail, labDependentCodes],
|
|
|
|
|
);
|
2026-06-28 21:45:33 +03:30
|
|
|
|
|
|
|
|
const currentDraftPreview = useMemo<PastTreatment | null>(() => {
|
|
|
|
|
if (!selectedAppointment) return null;
|
|
|
|
|
return buildWorkspaceSnapshot(
|
|
|
|
|
selectedAppointment,
|
|
|
|
|
details,
|
|
|
|
|
labCaseDrafts,
|
|
|
|
|
t('treatmentPlanTitle', {
|
|
|
|
|
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
|
|
|
|
|
}),
|
|
|
|
|
'current-draft',
|
|
|
|
|
);
|
|
|
|
|
}, [details, labCaseDrafts, selectedAppointment, t]);
|
|
|
|
|
|
|
|
|
|
const previewTreatment = useMemo(() => {
|
|
|
|
|
if (!selectedPreviewId) return currentDraftPreview;
|
2026-07-14 15:09:10 +03:30
|
|
|
const fromHistory =
|
2026-07-13 01:03:26 +03:30
|
|
|
history.find((item) => item.id === selectedPreviewId) ??
|
2026-07-14 15:09:10 +03:30
|
|
|
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]);
|
2026-06-28 21:45:33 +03:30
|
|
|
|
2026-07-13 01:03:26 +03:30
|
|
|
const isBrowsing = selectedPreviewId !== null;
|
|
|
|
|
|
|
|
|
|
const labAttentionItems = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
collectLabDispatchAttention(
|
|
|
|
|
labDependentCodes,
|
|
|
|
|
currentDraftPreview,
|
|
|
|
|
history,
|
|
|
|
|
selectedAppointmentId,
|
|
|
|
|
),
|
|
|
|
|
[labDependentCodes, currentDraftPreview, history, selectedAppointmentId],
|
|
|
|
|
);
|
2026-06-28 21:45:33 +03:30
|
|
|
|
|
|
|
|
const hydrateFromTreatment = useCallback((treatment: PastTreatment) => {
|
|
|
|
|
const mapped = treatment.details.map(mapDetailFromApi);
|
|
|
|
|
setDetails(mapped);
|
|
|
|
|
setActiveDetailId((prev) => {
|
|
|
|
|
const stillExists = mapped.some((d) => d.clientId === prev);
|
|
|
|
|
return stillExists ? prev : mapped[0]?.clientId ?? prev;
|
|
|
|
|
});
|
|
|
|
|
setSavedSnapshot(serializeDetails(mapped));
|
2026-07-10 15:26:36 +03:30
|
|
|
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
|
|
|
|
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
|
|
|
|
|
);
|
2026-06-28 21:45:33 +03:30
|
|
|
setLabCaseDrafts(mappedLabCases);
|
|
|
|
|
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
|
|
|
|
|
setOrganizationSearch('');
|
|
|
|
|
setSaveStatus('idle');
|
|
|
|
|
}, []);
|
2026-05-18 12:50:25 +03:30
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
2026-07-17 00:39:32 +03:30
|
|
|
const connectedSelectedTeeth = useMemo(
|
|
|
|
|
() => connectedTeethSet(activeDetail?.toothSelectionGroups ?? []),
|
|
|
|
|
[activeDetail?.toothSelectionGroups],
|
|
|
|
|
);
|
|
|
|
|
const rangeAnchorRef = useRef<FdiToothId | null>(null);
|
2026-05-19 22:29:29 +03:30
|
|
|
|
2026-07-07 18:43:10 +03:30
|
|
|
const wholePlanTeethSet = useMemo(() => {
|
|
|
|
|
const set = new Set<FdiToothId>();
|
|
|
|
|
for (const detail of details) {
|
|
|
|
|
for (const tooth of detail.teeth) set.add(tooth);
|
|
|
|
|
}
|
|
|
|
|
return set;
|
|
|
|
|
}, [details]);
|
|
|
|
|
|
|
|
|
|
const wholePlanToothColors = useMemo(() => {
|
|
|
|
|
const colors: Partial<Record<FdiToothId, string>> = {};
|
|
|
|
|
for (let i = 0; i < details.length; i++) {
|
|
|
|
|
const detail = details[i];
|
|
|
|
|
const catalogIndex = treatmentCatalog.findIndex((e) => e.code === detail.treatmentType);
|
|
|
|
|
const color = treatmentTypeColor(detail.treatmentType, catalogIndex >= 0 ? catalogIndex : i);
|
|
|
|
|
for (const tooth of detail.teeth) {
|
|
|
|
|
if (!(tooth in colors)) colors[tooth] = color;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return colors;
|
|
|
|
|
}, [details, treatmentCatalog]);
|
|
|
|
|
|
|
|
|
|
const chartSelectedTeeth = showWholeTreatmentPlan ? wholePlanTeethSet : selectedTeethSet;
|
|
|
|
|
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
|
|
|
|
|
|
|
|
|
|
// Reset whole-plan overview when switching details.
|
2026-07-18 00:02:40 +03:30
|
|
|
// Prefer pendingEntryStepRef (e.g. Lab shipments → Lab step) over defaulting to teeth.
|
2026-07-07 18:43:10 +03:30
|
|
|
useEffect(() => {
|
|
|
|
|
setShowWholeTreatmentPlan(false);
|
2026-07-17 00:39:32 +03:30
|
|
|
rangeAnchorRef.current = null;
|
2026-07-18 00:02:40 +03:30
|
|
|
const pending = pendingEntryStepRef.current;
|
|
|
|
|
pendingEntryStepRef.current = null;
|
|
|
|
|
setEntryStep(pending ?? 'teeth');
|
2026-07-07 18:43:10 +03:30
|
|
|
}, [activeDetailId]);
|
|
|
|
|
|
2026-07-17 00:39:32 +03:30
|
|
|
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (entryStep === 'lab' && !showLabWizardStep) {
|
|
|
|
|
setEntryStep('content');
|
|
|
|
|
}
|
|
|
|
|
}, [entryStep, showLabWizardStep]);
|
|
|
|
|
|
2026-07-07 17:14:31 +03:30
|
|
|
// Sync active lab shipment when the selected treatment detail changes.
|
|
|
|
|
useEffect(() => {
|
2026-07-10 15:26:36 +03:30
|
|
|
const match = labCaseDrafts.find((lc) => lc.detailClientId === activeDetailId);
|
2026-07-07 17:14:31 +03:30
|
|
|
setActiveLabCaseId(match?.clientId ?? null);
|
|
|
|
|
}, [activeDetailId, labCaseDrafts]);
|
|
|
|
|
|
2026-05-07 03:40:29 +03:30
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
setApptsLoading(true);
|
|
|
|
|
void (async () => {
|
|
|
|
|
try {
|
2026-05-19 22:29:29 +03:30
|
|
|
const dayStart = startOfLocalDay(selectedDay);
|
|
|
|
|
const dayEnd = addCalendarDays(dayStart, 1);
|
|
|
|
|
const response = await appointmentsApi.list({
|
|
|
|
|
from: dayStart.toISOString(),
|
|
|
|
|
to: dayEnd.toISOString(),
|
|
|
|
|
});
|
2026-05-07 03:40:29 +03:30
|
|
|
if (cancelled) return;
|
2026-05-19 22:29:29 +03:30
|
|
|
const list = response.data
|
|
|
|
|
.filter((a) => a.providerUserId === userId)
|
|
|
|
|
.map(mapAppointment);
|
2026-05-07 03:40:29 +03:30
|
|
|
setAppointments(list);
|
|
|
|
|
if (!selectionLockedRef.current) {
|
2026-07-11 03:12:56 +03:30
|
|
|
const pendingId = pendingAppointmentIdRef.current;
|
|
|
|
|
if (pendingId && list.some((appointment) => appointment.id === pendingId)) {
|
|
|
|
|
setSelectedAppointmentId(pendingId);
|
|
|
|
|
setSelectionLocked(true);
|
|
|
|
|
pendingAppointmentIdRef.current = null;
|
|
|
|
|
router.replace('/treatment', { scroll: false });
|
|
|
|
|
} else {
|
|
|
|
|
pendingAppointmentIdRef.current = null;
|
|
|
|
|
setSelectedAppointmentId(pickAutoAppointment(list, selectedDay));
|
|
|
|
|
}
|
2026-05-07 03:40:29 +03:30
|
|
|
}
|
2026-05-19 22:29:29 +03:30
|
|
|
} catch (error: unknown) {
|
|
|
|
|
if (!cancelled) {
|
2026-07-12 18:27:38 +03:30
|
|
|
showError(getUserFacingError(error, tErrors, t('errorLoadAppointments')));
|
2026-05-19 22:29:29 +03:30
|
|
|
}
|
2026-05-07 03:40:29 +03:30
|
|
|
} finally {
|
|
|
|
|
if (!cancelled) setApptsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
2026-07-11 03:12:56 +03:30
|
|
|
}, [userId, selectedDay, showError, t, router]);
|
2026-05-07 03:40:29 +03:30
|
|
|
|
|
|
|
|
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 () => {
|
2026-05-19 22:29:29 +03:30
|
|
|
try {
|
2026-07-13 22:53:23 +03:30
|
|
|
const [orgsResponse, catalogResponse, prosthesisResponse] = await Promise.all([
|
2026-06-28 17:14:02 +03:30
|
|
|
treatmentsApi.listLinkedOrganizations(),
|
|
|
|
|
treatmentCatalogApi.list(),
|
2026-07-13 22:53:23 +03:30
|
|
|
prosthesisCatalogApi.list(),
|
2026-06-28 17:14:02 +03:30
|
|
|
]);
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setOrgs(orgsResponse.data);
|
2026-07-06 20:40:19 +03:30
|
|
|
setTreatmentCatalog(catalogResponse.data);
|
2026-07-13 22:53:23 +03:30
|
|
|
setProsthesisCatalog(prosthesisResponse.data);
|
2026-06-28 17:14:02 +03:30
|
|
|
setLabDependentCodes(
|
|
|
|
|
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
|
|
|
|
|
);
|
2026-05-19 22:29:29 +03:30
|
|
|
} catch (error: unknown) {
|
|
|
|
|
if (!cancelled) {
|
2026-07-12 18:27:38 +03:30
|
|
|
showError(getUserFacingError(error, tErrors, t('errorLoadOrgs')));
|
2026-05-19 22:29:29 +03:30
|
|
|
}
|
|
|
|
|
}
|
2026-05-07 03:40:29 +03:30
|
|
|
})();
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
2026-06-20 14:51:43 +03:30
|
|
|
}, [showError, t]);
|
2026-05-07 03:40:29 +03:30
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-07-13 22:53:23 +03:30
|
|
|
if (!activePatientId) {
|
2026-07-08 02:53:47 +03:30
|
|
|
setHistoryPatientId(null);
|
|
|
|
|
setHistory([]);
|
|
|
|
|
setHistoryLoading(false);
|
2026-07-13 22:53:23 +03:30
|
|
|
setPatientLabCases([]);
|
|
|
|
|
setPatientLabCasesLoading(false);
|
|
|
|
|
setSelectedRailLabCaseId(null);
|
2026-07-08 02:53:47 +03:30
|
|
|
return;
|
2026-05-07 03:40:29 +03:30
|
|
|
}
|
2026-07-13 01:03:26 +03:30
|
|
|
setHistoryPatientId((prev) => {
|
2026-07-13 22:53:23 +03:30
|
|
|
if (prev !== activePatientId) {
|
2026-07-13 01:03:26 +03:30
|
|
|
setHistory([]);
|
|
|
|
|
setHistoryLoading(true);
|
|
|
|
|
}
|
2026-07-13 22:53:23 +03:30
|
|
|
return activePatientId;
|
2026-07-13 01:03:26 +03:30
|
|
|
});
|
2026-07-13 22:53:23 +03:30
|
|
|
}, [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));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
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]);
|
2026-06-28 21:45:33 +03:30
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!historyPatientId) return;
|
2026-07-13 01:03:26 +03:30
|
|
|
const requestId = ++historyRequestRef.current;
|
2026-05-07 03:40:29 +03:30
|
|
|
setHistoryLoading(true);
|
|
|
|
|
void (async () => {
|
2026-05-19 22:29:29 +03:30
|
|
|
try {
|
2026-07-13 01:03:26 +03:30
|
|
|
const response = await treatmentsApi.listPatientHistory(historyPatientId, 50);
|
|
|
|
|
if (requestId !== historyRequestRef.current) return;
|
|
|
|
|
setHistory(response.data);
|
2026-07-13 22:53:23 +03:30
|
|
|
void refreshPatientLabCases(historyPatientId);
|
2026-05-19 22:29:29 +03:30
|
|
|
} catch (error: unknown) {
|
2026-07-13 01:03:26 +03:30
|
|
|
if (requestId !== historyRequestRef.current) return;
|
|
|
|
|
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
|
2026-05-19 22:29:29 +03:30
|
|
|
} finally {
|
2026-07-13 01:03:26 +03:30
|
|
|
if (requestId === historyRequestRef.current) {
|
|
|
|
|
setHistoryLoading(false);
|
|
|
|
|
}
|
2026-05-07 03:40:29 +03:30
|
|
|
}
|
|
|
|
|
})();
|
2026-07-13 22:53:23 +03:30
|
|
|
}, [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]);
|
2026-05-07 03:40:29 +03:30
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-19 23:43:43 +03:30
|
|
|
const appointmentId = selectedAppointment?.id;
|
2026-06-28 21:45:33 +03:30
|
|
|
if (!appointmentId || workspaceMode !== 'live') return;
|
|
|
|
|
|
|
|
|
|
if (skipNextGetDraftRef.current) {
|
|
|
|
|
skipNextGetDraftRef.current = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-19 23:43:43 +03:30
|
|
|
|
2026-05-19 22:29:29 +03:30
|
|
|
let cancelled = false;
|
2026-06-28 18:08:29 +03:30
|
|
|
draftHydratingRef.current = true;
|
|
|
|
|
if (autosaveTimerRef.current) {
|
|
|
|
|
clearTimeout(autosaveTimerRef.current);
|
|
|
|
|
autosaveTimerRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 22:29:29 +03:30
|
|
|
void (async () => {
|
|
|
|
|
try {
|
2026-05-19 23:43:43 +03:30
|
|
|
const response = await treatmentsApi.getDraft(appointmentId);
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
if (response.data?.details?.length) {
|
|
|
|
|
const mapped = response.data.details.map(mapDetailFromApi);
|
2026-06-28 17:14:02 +03:30
|
|
|
setDetails(mapped);
|
|
|
|
|
setActiveDetailId((prev) => {
|
|
|
|
|
const stillExists = mapped.some((d) => d.clientId === prev);
|
2026-05-19 23:43:43 +03:30
|
|
|
return stillExists ? prev : mapped[0].clientId;
|
|
|
|
|
});
|
2026-06-28 17:14:02 +03:30
|
|
|
setSavedSnapshot(serializeDetails(mapped));
|
2026-05-19 23:43:43 +03:30
|
|
|
} else {
|
2026-07-08 02:53:47 +03:30
|
|
|
const first = newDetail(
|
|
|
|
|
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
|
|
|
|
|
);
|
2026-06-28 17:14:02 +03:30
|
|
|
setDetails([first]);
|
|
|
|
|
setActiveDetailId(first.clientId);
|
|
|
|
|
setSavedSnapshot(serializeDetails([first]));
|
2026-05-19 23:43:43 +03:30
|
|
|
}
|
2026-06-28 17:14:02 +03:30
|
|
|
|
2026-07-10 15:26:36 +03:30
|
|
|
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
|
|
|
|
(response.data?.labCases ?? []).map(mapLabCaseDraftFromApi),
|
|
|
|
|
);
|
2026-06-28 17:14:02 +03:30
|
|
|
setLabCaseDrafts(mappedLabCases);
|
|
|
|
|
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
|
2026-05-19 23:43:43 +03:30
|
|
|
setOrganizationSearch('');
|
2026-06-28 18:08:29 +03:30
|
|
|
setSaveStatus('idle');
|
2026-05-19 22:29:29 +03:30
|
|
|
} catch (error: unknown) {
|
|
|
|
|
if (!cancelled) {
|
2026-07-12 18:27:38 +03:30
|
|
|
showError(getUserFacingError(error, tErrors, t('errorLoadDraft')));
|
2026-05-19 22:29:29 +03:30
|
|
|
}
|
2026-06-28 18:08:29 +03:30
|
|
|
} finally {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
draftHydratingRef.current = false;
|
|
|
|
|
}
|
2026-05-19 22:29:29 +03:30
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
2026-06-28 18:08:29 +03:30
|
|
|
draftHydratingRef.current = false;
|
2026-05-19 22:29:29 +03:30
|
|
|
};
|
2026-07-08 02:53:47 +03:30
|
|
|
}, [selectedAppointment?.id, selectedAppointment?.purpose, workspaceMode, treatmentCatalog, showError, t]);
|
2026-05-07 03:40:29 +03:30
|
|
|
|
2026-06-28 18:08:29 +03:30
|
|
|
const persistDraft = useCallback(
|
|
|
|
|
async (options?: { force?: boolean }) => {
|
|
|
|
|
if (!selectedAppointment) throw new Error('No appointment selected');
|
|
|
|
|
|
|
|
|
|
const currentDetails = detailsRef.current;
|
|
|
|
|
const dirty = isDetailsDirty(currentDetails, savedSnapshotRef.current);
|
|
|
|
|
|
|
|
|
|
if (!options?.force && !dirty) {
|
|
|
|
|
return detailsToPreviewTreatment(currentDetails, {
|
2026-06-28 21:45:33 +03:30
|
|
|
title: t('treatmentPlanTitle', {
|
2026-06-28 18:08:29 +03:30
|
|
|
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
|
|
|
|
|
}),
|
|
|
|
|
patientId: selectedAppointment.patientId,
|
|
|
|
|
treatmentAt: selectedAppointment.startAt,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 01:03:26 +03:30
|
|
|
if (!areDetailsPersistable(currentDetails)) {
|
|
|
|
|
return detailsToPreviewTreatment(currentDetails, {
|
|
|
|
|
title: t('treatmentPlanTitle', {
|
|
|
|
|
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
|
|
|
|
|
}),
|
|
|
|
|
patientId: selectedAppointment.patientId,
|
|
|
|
|
treatmentAt: selectedAppointment.startAt,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 18:08:29 +03:30
|
|
|
const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
|
2026-07-17 00:39:32 +03:30
|
|
|
details: currentDetails.map(
|
|
|
|
|
({ clientId, id, treatmentType, teeth, toothSelectionGroups, comment, attachmentMetas }) => ({
|
|
|
|
|
clientId,
|
|
|
|
|
id,
|
|
|
|
|
treatmentType,
|
|
|
|
|
teeth,
|
|
|
|
|
toothSelectionGroups,
|
|
|
|
|
comment,
|
|
|
|
|
attachmentIds: attachmentMetas.map((a) => a.id),
|
|
|
|
|
}),
|
|
|
|
|
),
|
2026-06-28 18:08:29 +03:30
|
|
|
});
|
|
|
|
|
const mapped = response.data.details.map(mapDetailFromApi);
|
2026-07-18 00:02:40 +03:30
|
|
|
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 ?? prev;
|
|
|
|
|
});
|
|
|
|
|
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 ?? prev;
|
|
|
|
|
});
|
|
|
|
|
// Keep dirty so the queued autosave persists the newer local state.
|
|
|
|
|
}
|
2026-06-28 18:08:29 +03:30
|
|
|
return response.data;
|
|
|
|
|
},
|
|
|
|
|
[selectedAppointment, t],
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-18 00:02:40 +03:30
|
|
|
const refreshHistory = useCallback(async (patientId: string, options?: { silentLabCases?: boolean }) => {
|
2026-07-13 01:03:26 +03:30
|
|
|
const requestId = ++historyRequestRef.current;
|
|
|
|
|
try {
|
|
|
|
|
const response = await treatmentsApi.listPatientHistory(patientId, 50);
|
|
|
|
|
if (requestId !== historyRequestRef.current) return;
|
|
|
|
|
setHistory(response.data);
|
2026-07-18 00:02:40 +03:30
|
|
|
void refreshPatientLabCases(patientId, { silent: options?.silentLabCases ?? false });
|
2026-07-13 01:03:26 +03:30
|
|
|
} catch (error: unknown) {
|
|
|
|
|
if (requestId !== historyRequestRef.current) return;
|
|
|
|
|
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
|
|
|
|
|
}
|
2026-07-13 22:53:23 +03:30
|
|
|
}, [refreshPatientLabCases, showError, t, tErrors]);
|
2026-07-13 01:03:26 +03:30
|
|
|
|
2026-06-28 18:08:29 +03:30
|
|
|
const runDraftSave = useCallback(async () => {
|
|
|
|
|
if (!selectedAppointment || saveInFlightRef.current) {
|
|
|
|
|
if (saveInFlightRef.current) saveQueuedRef.current = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
2026-07-13 01:03:26 +03:30
|
|
|
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current) ||
|
|
|
|
|
!areDetailsPersistable(detailsRef.current)
|
2026-06-28 18:08:29 +03:30
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
saveInFlightRef.current = true;
|
|
|
|
|
setSaveStatus('saving');
|
|
|
|
|
try {
|
|
|
|
|
await persistDraft();
|
|
|
|
|
setSaveStatus('saved');
|
2026-07-13 01:03:26 +03:30
|
|
|
if (historyPatientId) {
|
|
|
|
|
await refreshHistory(historyPatientId);
|
|
|
|
|
}
|
2026-06-28 18:08:29 +03:30
|
|
|
} catch (error: unknown) {
|
|
|
|
|
setSaveStatus('error');
|
2026-07-12 18:27:38 +03:30
|
|
|
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
|
2026-06-28 18:08:29 +03:30
|
|
|
throw error;
|
|
|
|
|
} finally {
|
|
|
|
|
saveInFlightRef.current = false;
|
|
|
|
|
if (saveQueuedRef.current) {
|
|
|
|
|
saveQueuedRef.current = false;
|
|
|
|
|
if (isDetailsDirty(detailsRef.current, savedSnapshotRef.current)) {
|
|
|
|
|
void runDraftSave();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-13 01:03:26 +03:30
|
|
|
}, [selectedAppointment, persistDraft, showError, t, historyPatientId, refreshHistory]);
|
2026-06-28 21:45:33 +03:30
|
|
|
|
2026-06-28 18:08:29 +03:30
|
|
|
const flushDraftSave = useCallback(async (): Promise<boolean> => {
|
|
|
|
|
if (autosaveTimerRef.current) {
|
|
|
|
|
clearTimeout(autosaveTimerRef.current);
|
|
|
|
|
autosaveTimerRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 21:45:33 +03:30
|
|
|
if (workspaceModeRef.current !== 'live' || !selectedAppointment || !canEditTreatmentForDay) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-06-28 18:08:29 +03:30
|
|
|
|
|
|
|
|
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'));
|
|
|
|
|
}
|
2026-07-13 01:03:26 +03:30
|
|
|
}, [selectedAppointment, canEditTreatmentForDay, runDraftSave, t]);
|
2026-06-28 18:08:29 +03:30
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (draftHydratingRef.current || !canEditTreatmentForDay || !selectedAppointment?.id) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isDirty) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setSaveStatus('dirty');
|
|
|
|
|
|
|
|
|
|
if (autosaveTimerRef.current) clearTimeout(autosaveTimerRef.current);
|
|
|
|
|
autosaveTimerRef.current = setTimeout(() => {
|
|
|
|
|
autosaveTimerRef.current = null;
|
|
|
|
|
void runDraftSave();
|
|
|
|
|
}, AUTOSAVE_DEBOUNCE_MS);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
if (autosaveTimerRef.current) {
|
|
|
|
|
clearTimeout(autosaveTimerRef.current);
|
|
|
|
|
autosaveTimerRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, [details, isDirty, canEditTreatmentForDay, selectedAppointment?.id, runDraftSave]);
|
2026-05-19 22:29:29 +03:30
|
|
|
|
2026-06-28 21:45:33 +03:30
|
|
|
const resetToLiveContext = useCallback(() => {
|
|
|
|
|
setWorkspaceMode('live');
|
|
|
|
|
setSelectedPreviewId(null);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-19 22:29:29 +03:30
|
|
|
const onPickAppointment = useCallback(
|
|
|
|
|
(id: string) => {
|
2026-06-28 18:08:29 +03:30
|
|
|
void (async () => {
|
|
|
|
|
const ok = await flushDraftSave();
|
|
|
|
|
if (!ok) return;
|
2026-06-28 21:45:33 +03:30
|
|
|
resetToLiveContext();
|
2026-07-13 22:53:23 +03:30
|
|
|
setSearchedPatient(null);
|
2026-06-28 18:08:29 +03:30
|
|
|
setSelectionLocked(true);
|
|
|
|
|
setSelectedAppointmentId(id);
|
|
|
|
|
})();
|
2026-05-19 22:29:29 +03:30
|
|
|
},
|
2026-06-28 21:45:33 +03:30
|
|
|
[flushDraftSave, resetToLiveContext],
|
2026-05-19 22:29:29 +03:30
|
|
|
);
|
|
|
|
|
|
2026-07-13 01:03:26 +03:30
|
|
|
const canAccessOrganizations = canAccessDashboardRoute(currentOrganization, '/organizations');
|
|
|
|
|
|
2026-05-19 22:29:29 +03:30
|
|
|
const onSelectDay = useCallback(
|
|
|
|
|
(day: Date) => {
|
2026-06-28 18:08:29 +03:30
|
|
|
void (async () => {
|
|
|
|
|
const ok = await flushDraftSave();
|
|
|
|
|
if (!ok) return;
|
2026-06-28 21:45:33 +03:30
|
|
|
const patientIdToRefresh = historyPatientId;
|
|
|
|
|
resetToLiveContext();
|
2026-07-13 22:53:23 +03:30
|
|
|
setSearchedPatient(null);
|
2026-07-13 01:03:26 +03:30
|
|
|
setSelectionLocked(false);
|
|
|
|
|
setSelectedDay(startOfLocalDay(day));
|
2026-06-28 21:45:33 +03:30
|
|
|
if (patientIdToRefresh) {
|
|
|
|
|
await refreshHistory(patientIdToRefresh);
|
|
|
|
|
}
|
2026-06-28 18:08:29 +03:30
|
|
|
})();
|
2026-05-19 22:29:29 +03:30
|
|
|
},
|
2026-06-28 21:45:33 +03:30
|
|
|
[flushDraftSave, resetToLiveContext, historyPatientId, refreshHistory],
|
2026-05-19 22:29:29 +03:30
|
|
|
);
|
2026-05-07 03:40:29 +03:30
|
|
|
|
2026-06-28 21:45:33 +03:30
|
|
|
const handleSelectPreviewTreatment = useCallback((treatment: PastTreatment) => {
|
|
|
|
|
setSelectedPreviewId(treatment.id);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-13 01:03:26 +03:30
|
|
|
const exitBrowse = useCallback(() => {
|
|
|
|
|
setSelectedPreviewId(null);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const loadTreatmentIntoWorkspace = useCallback(
|
2026-07-13 22:53:23 +03:30
|
|
|
async (
|
|
|
|
|
treatment: PastTreatment,
|
|
|
|
|
focusDetailClientId?: string,
|
|
|
|
|
options?: { scrollToLabPanel?: boolean },
|
|
|
|
|
) => {
|
2026-07-13 01:03:26 +03:30
|
|
|
if (!treatment.appointmentId) {
|
2026-06-28 21:45:33 +03:30
|
|
|
showError(t('errorNoAppointmentForTreatment'));
|
2026-07-13 01:03:26 +03:30
|
|
|
return false;
|
2026-06-28 21:45:33 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ok = workspaceModeRef.current === 'live' ? await flushDraftSave() : true;
|
2026-07-13 01:03:26 +03:30
|
|
|
if (!ok) return false;
|
2026-06-28 21:45:33 +03:30
|
|
|
|
2026-07-14 15:09:10 +03:30
|
|
|
let treatmentToLoad = treatment;
|
|
|
|
|
try {
|
|
|
|
|
const draftResponse = await treatmentsApi.getDraft(treatment.appointmentId);
|
|
|
|
|
if (draftResponse.data) {
|
|
|
|
|
treatmentToLoad = draftResponse.data;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Fall back to the history snapshot when draft cannot be loaded.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isHistorical = isTreatmentDayHistorical(treatmentToLoad.treatmentAt, todayStart);
|
2026-06-28 21:45:33 +03:30
|
|
|
setWorkspaceMode(isHistorical ? 'historical' : 'live');
|
2026-07-13 01:03:26 +03:30
|
|
|
setSelectedPreviewId(null);
|
2026-07-14 15:09:10 +03:30
|
|
|
const nextDay = startOfLocalDay(new Date(treatmentToLoad.treatmentAt));
|
2026-07-13 22:53:23 +03:30
|
|
|
setSelectedDay((prev) => (compareLocalDayStart(prev, nextDay) === 0 ? prev : nextDay));
|
2026-06-28 21:45:33 +03:30
|
|
|
setSelectionLocked(true);
|
2026-07-14 15:09:10 +03:30
|
|
|
setSelectedAppointmentId(treatmentToLoad.appointmentId ?? treatment.appointmentId);
|
2026-06-28 21:45:33 +03:30
|
|
|
|
|
|
|
|
skipNextGetDraftRef.current = true;
|
|
|
|
|
draftHydratingRef.current = true;
|
2026-07-18 00:02:40 +03:30
|
|
|
if (focusDetailClientId && options?.scrollToLabPanel !== false) {
|
|
|
|
|
pendingEntryStepRef.current = 'lab';
|
|
|
|
|
}
|
2026-07-14 15:09:10 +03:30
|
|
|
hydrateFromTreatment(treatmentToLoad);
|
2026-06-28 21:45:33 +03:30
|
|
|
draftHydratingRef.current = false;
|
2026-07-13 01:03:26 +03:30
|
|
|
|
|
|
|
|
if (focusDetailClientId) {
|
2026-07-18 00:02:40 +03:30
|
|
|
if (options?.scrollToLabPanel !== false) {
|
|
|
|
|
pendingEntryStepRef.current = 'lab';
|
|
|
|
|
}
|
2026-07-13 01:03:26 +03:30
|
|
|
setActiveDetailId(focusDetailClientId);
|
|
|
|
|
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
2026-07-14 15:09:10 +03:30
|
|
|
(treatmentToLoad.labCases ?? []).map(mapLabCaseDraftFromApi),
|
2026-07-13 01:03:26 +03:30
|
|
|
);
|
|
|
|
|
const linked = mappedLabCases.find(
|
|
|
|
|
(lc) => !lc.sentAt && lc.detailClientId === focusDetailClientId,
|
|
|
|
|
);
|
|
|
|
|
if (linked) {
|
|
|
|
|
setActiveLabCaseId(linked.clientId);
|
|
|
|
|
}
|
2026-07-13 22:53:23 +03:30
|
|
|
if (options?.scrollToLabPanel !== false) {
|
2026-07-17 00:39:32 +03:30
|
|
|
setEntryStep('lab');
|
2026-07-13 22:53:23 +03:30
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
scrollWithinMainScrollContainer(labPanelRef.current);
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-07-13 01:03:26 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
[flushDraftSave, hydrateFromTreatment, showError, t, todayStart],
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-13 22:53:23 +03:30
|
|
|
const handleSelectSearchedPatient = useCallback(
|
|
|
|
|
(patient: Patient) => {
|
|
|
|
|
void (async () => {
|
|
|
|
|
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) {
|
|
|
|
|
showError(t('errorNoTreatmentForPatient'));
|
|
|
|
|
setSearchedPatient(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const ok = await loadTreatmentIntoWorkspace(latest);
|
|
|
|
|
if (!ok) {
|
|
|
|
|
setSearchedPatient(null);
|
|
|
|
|
}
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
showError(getUserFacingError(error, tErrors, t('errorNoTreatmentForPatient')));
|
|
|
|
|
setSearchedPatient(null);
|
|
|
|
|
} finally {
|
|
|
|
|
setPatientSearchBusy(false);
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
},
|
|
|
|
|
[loadTreatmentIntoWorkspace, showError, t, tErrors],
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-13 01:03:26 +03:30
|
|
|
const handleLoadIntoWorkspace = useCallback(() => {
|
|
|
|
|
if (!previewTreatment) return;
|
|
|
|
|
void loadTreatmentIntoWorkspace(previewTreatment);
|
|
|
|
|
}, [loadTreatmentIntoWorkspace, previewTreatment]);
|
|
|
|
|
|
|
|
|
|
const handleGoToLabDispatch = useCallback(
|
|
|
|
|
(item: LabDispatchAttentionItem) => {
|
|
|
|
|
if (item.isCurrentDraft) {
|
|
|
|
|
exitBrowse();
|
2026-07-18 00:02:40 +03:30
|
|
|
pendingEntryStepRef.current = 'lab';
|
2026-07-13 01:03:26 +03:30
|
|
|
setActiveDetailId(item.detailClientId);
|
|
|
|
|
const linked = labCaseDrafts.find(
|
|
|
|
|
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
|
|
|
|
|
);
|
|
|
|
|
if (linked) {
|
|
|
|
|
setActiveLabCaseId(linked.clientId);
|
|
|
|
|
}
|
2026-07-17 00:39:32 +03:30
|
|
|
setEntryStep('lab');
|
2026-07-13 01:03:26 +03:30
|
|
|
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],
|
|
|
|
|
);
|
2026-06-28 21:45:33 +03:30
|
|
|
|
2026-07-13 22:53:23 +03:30
|
|
|
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)),
|
|
|
|
|
);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
) {
|
2026-07-18 00:02:40 +03:30
|
|
|
pendingEntryStepRef.current = 'lab';
|
2026-07-13 22:53:23 +03:30
|
|
|
setActiveDetailId(item.detailClientId);
|
2026-07-18 00:02:40 +03:30
|
|
|
const matchingDraft = labCaseDrafts.find((lc) => lc.id === item.labCaseId);
|
|
|
|
|
if (matchingDraft) {
|
|
|
|
|
setActiveLabCaseId(matchingDraft.clientId);
|
|
|
|
|
}
|
|
|
|
|
setEntryStep('lab');
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
scrollWithinMainScrollContainer(labPanelRef.current);
|
|
|
|
|
});
|
2026-07-13 22:53:23 +03:30
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await loadTreatmentIntoWorkspace(treatment, item.detailClientId, {
|
2026-07-18 00:02:40 +03:30
|
|
|
scrollToLabPanel: true,
|
2026-07-13 22:53:23 +03:30
|
|
|
});
|
|
|
|
|
})();
|
|
|
|
|
},
|
|
|
|
|
[
|
|
|
|
|
activePatientId,
|
|
|
|
|
currentDraftPreview,
|
|
|
|
|
handleLabCaseMarkedRead,
|
|
|
|
|
history,
|
|
|
|
|
historyPanelItems,
|
|
|
|
|
isBrowsing,
|
2026-07-18 00:02:40 +03:30
|
|
|
labCaseDrafts,
|
2026-07-13 22:53:23 +03:30
|
|
|
loadTreatmentIntoWorkspace,
|
|
|
|
|
selectedAppointment?.id,
|
|
|
|
|
selectedAppointmentId,
|
|
|
|
|
showError,
|
|
|
|
|
t,
|
|
|
|
|
tErrors,
|
|
|
|
|
workspaceMode,
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const match = patientLabCases.find((item) => item.detailClientId === activeDetailId);
|
|
|
|
|
if (match) {
|
|
|
|
|
setSelectedRailLabCaseId(match.labCaseId);
|
|
|
|
|
}
|
|
|
|
|
}, [activeDetailId, patientLabCases]);
|
|
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
const uploadForDetail = useCallback(
|
|
|
|
|
async (detailClientId: string, files: FileList | File[]) => {
|
2026-05-19 23:43:43 +03:30
|
|
|
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
2026-07-16 22:45:37 +03:30
|
|
|
const target = detailsRef.current.find((d) => d.clientId === detailClientId);
|
|
|
|
|
if (target && isDetailLocked(target)) return;
|
2026-05-19 23:43:43 +03:30
|
|
|
const list = files instanceof FileList ? Array.from(files) : files;
|
|
|
|
|
if (!list.length) return;
|
|
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
setUploadBusyDetailId(detailClientId);
|
2026-05-19 22:29:29 +03:30
|
|
|
try {
|
|
|
|
|
const uploaded = await treatmentsApi.uploadCaseAttachments(
|
|
|
|
|
selectedAppointment.id,
|
2026-06-28 17:14:02 +03:30
|
|
|
detailClientId,
|
2026-05-19 23:43:43 +03:30
|
|
|
list,
|
2026-05-19 22:29:29 +03:30
|
|
|
);
|
2026-06-28 17:14:02 +03:30
|
|
|
setDetails((prev) =>
|
|
|
|
|
prev.map((d) =>
|
|
|
|
|
d.clientId === detailClientId
|
|
|
|
|
? { ...d, attachmentMetas: [...d.attachmentMetas, ...uploaded.data] }
|
|
|
|
|
: d,
|
2026-05-19 23:43:43 +03:30
|
|
|
),
|
|
|
|
|
);
|
2026-06-20 14:51:43 +03:30
|
|
|
showSuccess(t('successFilesUploaded', { count: uploaded.data.length }));
|
2026-05-19 22:29:29 +03:30
|
|
|
} catch (error: unknown) {
|
2026-07-12 18:27:38 +03:30
|
|
|
showError(getUserFacingError(error, tErrors, t('errorUpload')));
|
2026-05-19 22:29:29 +03:30
|
|
|
} finally {
|
2026-06-28 17:14:02 +03:30
|
|
|
setUploadBusyDetailId(null);
|
2026-05-19 22:29:29 +03:30
|
|
|
}
|
2026-05-07 03:40:29 +03:30
|
|
|
},
|
2026-07-16 22:45:37 +03:30
|
|
|
[canEditTreatmentForDay, isDetailLocked, selectedAppointment, showSuccess, showError, t, tErrors],
|
2026-05-07 03:40:29 +03:30
|
|
|
);
|
|
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
const persistLabCases = useCallback(
|
2026-07-07 17:14:31 +03:30
|
|
|
async (savedTreatment: PastTreatment, draftsOverride?: LabCaseDraft[]) => {
|
2026-06-28 17:14:02 +03:30
|
|
|
if (!selectedAppointment) throw new Error('No appointment selected');
|
|
|
|
|
|
2026-07-07 17:14:31 +03:30
|
|
|
const drafts = draftsOverride ?? labCaseDrafts;
|
2026-06-28 17:14:02 +03:30
|
|
|
const detailIdByClientId = new Map(
|
|
|
|
|
savedTreatment.details.map((d) => [d.clientId, d.id]),
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-10 15:26:36 +03:30
|
|
|
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,
|
2026-07-17 00:39:32 +03:30
|
|
|
selectionGroupId: tp.selectionGroupId ?? '',
|
2026-07-10 15:26:36 +03:30
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.filter(
|
2026-07-17 00:39:32 +03:30
|
|
|
(
|
|
|
|
|
row,
|
|
|
|
|
): row is {
|
|
|
|
|
treatmentDetailId: string;
|
|
|
|
|
tooth: string;
|
|
|
|
|
prosthesisTypeCode: string;
|
|
|
|
|
selectionGroupId: string;
|
|
|
|
|
} => row !== null,
|
2026-07-10 15:26:36 +03:30
|
|
|
),
|
|
|
|
|
attachmentIds: lc.attachmentIds,
|
2026-07-13 16:30:36 +03:30
|
|
|
dueDate: lc.dueDate ?? null,
|
2026-07-10 15:26:36 +03:30
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.filter((row): row is NonNullable<typeof row> => row !== null);
|
2026-06-28 17:14:02 +03:30
|
|
|
|
|
|
|
|
if (payload.length === 0) {
|
|
|
|
|
return savedTreatment;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const response = await treatmentsApi.saveLabCases(selectedAppointment.id, {
|
|
|
|
|
labCases: payload,
|
|
|
|
|
});
|
2026-07-10 15:26:36 +03:30
|
|
|
const mapped = withoutEmptyLabCaseDrafts(response.data.labCases.map(mapLabCaseDraftFromApi));
|
2026-06-28 17:14:02 +03:30
|
|
|
setLabCaseDrafts(mapped);
|
|
|
|
|
setActiveLabCaseId((prev) => {
|
|
|
|
|
if (prev && mapped.some((lc) => lc.clientId === prev)) return prev;
|
|
|
|
|
return mapped[0]?.clientId ?? null;
|
|
|
|
|
});
|
|
|
|
|
return response.data;
|
|
|
|
|
},
|
|
|
|
|
[labCaseDrafts, selectedAppointment],
|
|
|
|
|
);
|
2026-05-19 22:29:29 +03:30
|
|
|
|
2026-07-10 15:26:36 +03:30
|
|
|
const handleLabCasesChange = useCallback(
|
|
|
|
|
(next: LabCaseDraft[]) => {
|
|
|
|
|
const prevCleaned = withoutEmptyLabCaseDrafts(labCaseDrafts);
|
|
|
|
|
const cleaned = withoutEmptyLabCaseDrafts(next);
|
|
|
|
|
setLabCaseDrafts(cleaned);
|
|
|
|
|
|
|
|
|
|
if (!cleaned.some((lc) => lc.detailClientId === activeDetailId)) {
|
|
|
|
|
setActiveLabCaseId((prev) =>
|
|
|
|
|
prev && cleaned.some((lc) => lc.clientId === prev) ? prev : null,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const removedPersistedDraft = prevCleaned.some(
|
|
|
|
|
(lc) => lc.id && !cleaned.some((row) => row.clientId === lc.clientId),
|
|
|
|
|
);
|
|
|
|
|
if (
|
|
|
|
|
removedPersistedDraft &&
|
|
|
|
|
selectedAppointment &&
|
|
|
|
|
canEditTreatmentForDay
|
|
|
|
|
) {
|
|
|
|
|
void (async () => {
|
|
|
|
|
try {
|
|
|
|
|
const saved = await persistDraft({ force: true });
|
|
|
|
|
await persistLabCases(saved, cleaned);
|
|
|
|
|
} catch (error: unknown) {
|
2026-07-12 18:27:38 +03:30
|
|
|
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
|
2026-07-10 15:26:36 +03:30
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[
|
|
|
|
|
activeDetailId,
|
|
|
|
|
canEditTreatmentForDay,
|
|
|
|
|
labCaseDrafts,
|
|
|
|
|
persistDraft,
|
|
|
|
|
persistLabCases,
|
|
|
|
|
selectedAppointment,
|
|
|
|
|
showError,
|
|
|
|
|
t,
|
2026-07-16 22:45:37 +03:30
|
|
|
tErrors,
|
2026-07-10 15:26:36 +03:30
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-17 00:39:32 +03:30
|
|
|
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) || details.length <= 1) return;
|
|
|
|
|
if (!window.confirm(t('confirmRemoveDetail'))) return;
|
2026-07-16 22:45:37 +03:30
|
|
|
|
2026-07-17 00:39:32 +03:30
|
|
|
const nextDetails = details.filter((d) => d.clientId !== detailClientId);
|
|
|
|
|
const nextActive =
|
|
|
|
|
activeDetailId === detailClientId
|
|
|
|
|
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ??
|
|
|
|
|
nextDetails[0]?.clientId)
|
|
|
|
|
: activeDetailId;
|
2026-07-18 00:02:40 +03:30
|
|
|
|
|
|
|
|
// Sync ref before any persist triggered by lab-case cleanup (same tick).
|
|
|
|
|
detailsRef.current = nextDetails;
|
2026-07-17 00:39:32 +03:30
|
|
|
setDetails(nextDetails);
|
|
|
|
|
if (nextActive) setActiveDetailId(nextActive);
|
2026-07-16 22:45:37 +03:30
|
|
|
|
2026-07-17 00:39:32 +03:30
|
|
|
if (labCaseDrafts.some((lc) => lc.detailClientId === detailClientId)) {
|
|
|
|
|
handleLabCasesChange(
|
|
|
|
|
withoutEmptyLabCaseDrafts(
|
|
|
|
|
labCaseDrafts.filter((lc) => lc.detailClientId !== detailClientId),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[
|
|
|
|
|
activeDetailId,
|
|
|
|
|
canEditTreatmentForDay,
|
|
|
|
|
details,
|
|
|
|
|
handleLabCasesChange,
|
|
|
|
|
isDetailLocked,
|
|
|
|
|
labCaseDrafts,
|
|
|
|
|
t,
|
|
|
|
|
],
|
|
|
|
|
);
|
2026-07-16 22:45:37 +03:30
|
|
|
|
2026-07-07 17:14:31 +03:30
|
|
|
const handleAddLabCase = useCallback(async () => {
|
|
|
|
|
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
|
|
|
|
|
2026-07-10 15:26:36 +03:30
|
|
|
const cleaned = withoutEmptyLabCaseDrafts(labCaseDrafts);
|
|
|
|
|
const existing = cleaned.find(
|
|
|
|
|
(lc) => !lc.sentAt && lc.detailClientId === activeDetailId,
|
|
|
|
|
);
|
|
|
|
|
if (existing) {
|
|
|
|
|
setActiveLabCaseId(existing.clientId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 17:14:31 +03:30
|
|
|
const activeDetail = details.find((d) => d.clientId === activeDetailId);
|
2026-07-13 01:03:26 +03:30
|
|
|
if (
|
|
|
|
|
activeDetail &&
|
|
|
|
|
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)
|
|
|
|
|
) {
|
|
|
|
|
showError(t('labShipmentBlockedBody'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const shouldIncludeActive = Boolean(
|
|
|
|
|
activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes),
|
|
|
|
|
);
|
2026-07-10 15:26:36 +03:30
|
|
|
|
|
|
|
|
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) {
|
2026-07-12 18:27:38 +03:30
|
|
|
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
|
2026-07-10 15:26:36 +03:30
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 17:14:31 +03:30
|
|
|
const next: LabCaseDraft = {
|
|
|
|
|
...newLabCaseDraft(),
|
2026-07-10 15:26:36 +03:30
|
|
|
detailClientId: shouldIncludeActive ? activeDetailId : null,
|
2026-07-07 17:14:31 +03:30
|
|
|
};
|
2026-07-10 15:26:36 +03:30
|
|
|
const updatedLabCases = [...cleaned, next];
|
2026-07-07 17:14:31 +03:30
|
|
|
setLabCaseDrafts(updatedLabCases);
|
|
|
|
|
setActiveLabCaseId(next.clientId);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const saved = await persistDraft({ force: true });
|
|
|
|
|
await persistLabCases(saved, updatedLabCases);
|
|
|
|
|
} catch (error: unknown) {
|
2026-07-12 18:27:38 +03:30
|
|
|
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
|
2026-07-07 17:14:31 +03:30
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
activeDetailId,
|
|
|
|
|
canEditTreatmentForDay,
|
|
|
|
|
details,
|
|
|
|
|
labCaseDrafts,
|
|
|
|
|
labDependentCodes,
|
|
|
|
|
persistDraft,
|
|
|
|
|
persistLabCases,
|
|
|
|
|
selectedAppointment,
|
|
|
|
|
showError,
|
|
|
|
|
t,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-07-17 12:30:43 +03:30
|
|
|
// Auto-open shipment draft when entering Lab (no manual "Add lab shipment" click).
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (entryStep !== 'lab') return;
|
|
|
|
|
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
|
|
|
|
const activeDetail = details.find((d) => d.clientId === activeDetailId);
|
|
|
|
|
if (!activeDetail || !isDetailReadyForLabDispatch(activeDetail, labDependentCodes)) return;
|
|
|
|
|
if (isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)) return;
|
2026-07-18 00:02:40 +03:30
|
|
|
// 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;
|
2026-07-17 12:30:43 +03:30
|
|
|
const hasDraft = labCaseDrafts.some((lc) => lc.detailClientId === activeDetailId);
|
|
|
|
|
if (hasDraft) return;
|
|
|
|
|
void handleAddLabCase();
|
|
|
|
|
}, [
|
|
|
|
|
activeDetailId,
|
|
|
|
|
canEditTreatmentForDay,
|
|
|
|
|
details,
|
|
|
|
|
entryStep,
|
|
|
|
|
handleAddLabCase,
|
2026-07-18 00:02:40 +03:30
|
|
|
isDetailLocked,
|
2026-07-17 12:30:43 +03:30
|
|
|
labCaseDrafts,
|
|
|
|
|
labDependentCodes,
|
|
|
|
|
selectedAppointment,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
const handleSendLabCase = useCallback(
|
2026-07-08 02:53:47 +03:30
|
|
|
async (labCase: LabCaseDraft, comment?: string) => {
|
2026-05-19 22:29:29 +03:30
|
|
|
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
2026-06-28 17:14:02 +03:30
|
|
|
if (!labCase.destinationOrganizationId) {
|
2026-06-20 14:51:43 +03:30
|
|
|
showError(t('errorChooseOrg'));
|
2026-05-07 03:40:29 +03:30
|
|
|
return;
|
|
|
|
|
}
|
2026-07-10 15:26:36 +03:30
|
|
|
if (!labCase.detailClientId) {
|
2026-06-28 17:14:02 +03:30
|
|
|
showError(t('errorLabCaseNeedsDetails'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setSendBusyId(labCase.clientId);
|
2026-05-07 03:40:29 +03:30
|
|
|
try {
|
2026-06-28 18:08:29 +03:30
|
|
|
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 });
|
2026-06-28 17:14:02 +03:30
|
|
|
const afterLabCases = await persistLabCases(saved);
|
2026-06-28 15:34:56 +03:30
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
const refreshedLabCase = afterLabCases.labCases.find(
|
|
|
|
|
(lc) => lc.clientId === labCase.clientId || lc.id === labCase.id,
|
2026-06-28 15:34:56 +03:30
|
|
|
);
|
2026-06-28 17:14:02 +03:30
|
|
|
if (!refreshedLabCase?.id) throw new Error(t('errorSendCase'));
|
2026-06-28 15:34:56 +03:30
|
|
|
|
2026-07-08 02:53:47 +03:30
|
|
|
const trimmedComment = comment?.trim();
|
|
|
|
|
if (trimmedComment) {
|
|
|
|
|
await treatmentsApi.addLabCaseComment(refreshedLabCase.id, { body: trimmedComment });
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
|
2026-06-28 15:34:56 +03:30
|
|
|
|
2026-07-13 01:03:26 +03:30
|
|
|
const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id);
|
|
|
|
|
if (draftResponse.data?.details?.length) {
|
|
|
|
|
const mapped = draftResponse.data.details.map(mapDetailFromApi);
|
2026-07-18 00:02:40 +03:30
|
|
|
detailsRef.current = mapped;
|
2026-07-13 01:03:26 +03:30
|
|
|
setDetails(mapped);
|
2026-07-18 00:02:40 +03:30
|
|
|
setActiveDetailId((prev) => {
|
|
|
|
|
const stillExists = mapped.some((d) => d.clientId === prev);
|
|
|
|
|
return stillExists ? prev : mapped[0]?.clientId ?? prev;
|
|
|
|
|
});
|
2026-07-13 01:03:26 +03:30
|
|
|
setSavedSnapshot(serializeDetails(mapped));
|
|
|
|
|
} else {
|
|
|
|
|
const sentDetailClientId = labCase.detailClientId;
|
2026-07-18 00:02:40 +03:30
|
|
|
setDetails((prev) => {
|
|
|
|
|
const next = prev.map((detail) => {
|
2026-07-13 01:03:26 +03:30
|
|
|
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,
|
|
|
|
|
};
|
2026-07-18 00:02:40 +03:30
|
|
|
});
|
|
|
|
|
detailsRef.current = next;
|
|
|
|
|
return next;
|
|
|
|
|
});
|
2026-07-13 01:03:26 +03:30
|
|
|
}
|
2026-07-07 15:31:09 +03:30
|
|
|
|
2026-06-28 17:14:02 +03:30
|
|
|
setLabCaseDrafts((prev) =>
|
|
|
|
|
prev.map((lc) =>
|
|
|
|
|
lc.clientId === labCase.clientId
|
2026-05-19 22:29:29 +03:30
|
|
|
? {
|
2026-06-28 17:14:02 +03:30
|
|
|
...lc,
|
|
|
|
|
id: response.data.id,
|
2026-05-19 22:29:29 +03:30
|
|
|
sentAt: response.data.sentAt,
|
2026-06-28 17:14:02 +03:30
|
|
|
destinationOrganizationId: response.data.destinationOrganizationId,
|
2026-05-19 23:43:43 +03:30
|
|
|
sends: response.data.sends,
|
2026-07-18 00:02:40 +03:30
|
|
|
taskProgress: response.data.taskProgress ?? lc.taskProgress,
|
2026-05-19 22:29:29 +03:30
|
|
|
}
|
2026-06-28 17:14:02 +03:30
|
|
|
: lc,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-07 14:20:50 +03:30
|
|
|
setRecentOrganizationIds((prev) => {
|
2026-06-28 17:14:02 +03:30
|
|
|
const orgId = labCase.destinationOrganizationId!;
|
|
|
|
|
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
|
2026-05-07 14:20:50 +03:30
|
|
|
});
|
2026-06-20 14:51:43 +03:30
|
|
|
showSuccess(t('successCaseSent'));
|
2026-07-13 17:44:44 +03:30
|
|
|
notifyTabBadgesChanged();
|
2026-07-13 22:53:23 +03:30
|
|
|
if (selectedAppointment.patientId) {
|
2026-07-18 00:02:40 +03:30
|
|
|
// Silent: avoid rail/history loading flicker while staying on Lab step.
|
|
|
|
|
void refreshHistory(selectedAppointment.patientId, { silentLabCases: true });
|
2026-07-13 22:53:23 +03:30
|
|
|
}
|
2026-05-19 22:29:29 +03:30
|
|
|
} catch (error: unknown) {
|
2026-07-12 18:27:38 +03:30
|
|
|
showError(getUserFacingError(error, tErrors, t('errorSendCase')));
|
2026-05-07 03:40:29 +03:30
|
|
|
} finally {
|
|
|
|
|
setSendBusyId(null);
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-06-28 17:14:02 +03:30
|
|
|
[
|
|
|
|
|
canEditTreatmentForDay,
|
|
|
|
|
selectedAppointment,
|
|
|
|
|
persistDraft,
|
|
|
|
|
persistLabCases,
|
2026-07-14 15:09:10 +03:30
|
|
|
refreshHistory,
|
2026-06-28 17:14:02 +03:30
|
|
|
showSuccess,
|
|
|
|
|
showError,
|
|
|
|
|
t,
|
2026-07-13 22:53:23 +03:30
|
|
|
tErrors,
|
2026-06-28 17:14:02 +03:30
|
|
|
],
|
2026-05-07 03:40:29 +03:30
|
|
|
);
|
|
|
|
|
|
2026-05-19 22:29:29 +03:30
|
|
|
if (!canView) {
|
2026-05-07 03:40:29 +03:30
|
|
|
return (
|
|
|
|
|
<div className="surface-card p-6 max-w-xl">
|
2026-06-20 14:51:43 +03:30
|
|
|
<h2 className="text-lg font-semibold text-text-primary">{t('noPermissionTitle')}</h2>
|
2026-06-28 17:14:02 +03:30
|
|
|
<p className="text-sm text-text-secondary mt-2">{t('noPermissionBody')}</p>
|
2026-05-07 03:40:29 +03:30
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-19 23:43:43 +03:30
|
|
|
<div className="space-y-4">
|
2026-05-07 03:40:29 +03:30
|
|
|
<header className="space-y-1">
|
2026-07-11 17:10:02 +03:30
|
|
|
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
2026-05-07 03:40:29 +03:30
|
|
|
<p className="text-sm text-text-secondary">
|
2026-06-28 17:14:02 +03:30
|
|
|
{canEdit ? t('subtitleEditPhase4') : t('subtitleReadOnly')}
|
2026-05-07 03:40:29 +03:30
|
|
|
</p>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<AppointmentsStrip
|
|
|
|
|
stripHidden={stripHidden}
|
|
|
|
|
onToggleStripHidden={() => setStripHidden((s) => !s)}
|
|
|
|
|
selectedDay={selectedDay}
|
2026-05-19 22:29:29 +03:30
|
|
|
onSelectDay={onSelectDay}
|
2026-05-07 03:40:29 +03:30
|
|
|
appointments={appointments}
|
|
|
|
|
selectedAppointmentId={selectedAppointmentId}
|
|
|
|
|
onSelectAppointment={onPickAppointment}
|
2026-07-06 20:40:19 +03:30
|
|
|
treatmentCatalog={treatmentCatalog}
|
2026-05-07 03:40:29 +03:30
|
|
|
loading={apptsLoading}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-06-28 21:45:33 +03:30
|
|
|
{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' && (
|
2026-05-18 12:50:25 +03:30
|
|
|
<p className="text-sm text-text-secondary rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/50 px-3 py-2">
|
2026-06-20 14:51:43 +03:30
|
|
|
{t('pastDayNotice')}
|
2026-05-18 12:50:25 +03:30
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-14 01:27:11 +03:30
|
|
|
<div className="treatment-layout-grid grid grid-cols-1 xl:grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-4 items-start">
|
2026-05-19 23:43:43 +03:30
|
|
|
<div className="space-y-3 min-w-0 xl:max-w-[380px]">
|
2026-07-13 22:53:23 +03:30
|
|
|
<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')}
|
|
|
|
|
/>
|
2026-05-07 03:40:29 +03:30
|
|
|
|
2026-07-13 22:53:23 +03:30
|
|
|
{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}
|
2026-07-13 01:03:26 +03:30
|
|
|
</div>
|
2026-07-13 22:53:23 +03:30
|
|
|
) : (
|
|
|
|
|
<p className="text-sm text-text-muted border-t border-border/60 pt-3">
|
|
|
|
|
{apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{workspaceMode === 'live' && !isBrowsing && selectedAppointment ? (
|
|
|
|
|
<TreatmentPreviewCard
|
|
|
|
|
treatment={currentDraftPreview}
|
|
|
|
|
heading={t('previewCurrentDraft')}
|
|
|
|
|
labDependentCodes={labDependentCodes}
|
|
|
|
|
treatmentCatalog={treatmentCatalog}
|
|
|
|
|
orgs={orgs}
|
|
|
|
|
/>
|
2026-07-13 01:03:26 +03:30
|
|
|
) : null}
|
|
|
|
|
|
2026-07-13 22:53:23 +03:30
|
|
|
{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', {
|
2026-07-14 01:27:11 +03:30
|
|
|
date: formatAppDate(previewTreatment.treatmentAt, locale, APP_DATE.withWeekday),
|
2026-07-13 22:53:23 +03:30
|
|
|
})}
|
|
|
|
|
</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}
|
2026-05-19 23:43:43 +03:30
|
|
|
|
2026-07-13 22:53:23 +03:30
|
|
|
{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}
|
2026-05-07 03:40:29 +03:30
|
|
|
</div>
|
|
|
|
|
|
2026-05-19 23:43:43 +03:30
|
|
|
<div className="space-y-3 min-w-0 w-full">
|
2026-06-28 17:14:02 +03:30
|
|
|
<TreatmentDetailsEditor
|
|
|
|
|
details={details}
|
|
|
|
|
activeDetailId={activeDetailId}
|
|
|
|
|
onActiveDetailChange={setActiveDetailId}
|
|
|
|
|
onDetailsChange={setDetails}
|
|
|
|
|
isDetailLocked={isDetailLocked}
|
2026-06-28 21:45:33 +03:30
|
|
|
labDependentCodes={labDependentCodes}
|
2026-07-07 13:13:04 +03:30
|
|
|
treatmentCatalog={treatmentDropdownCatalog}
|
2026-05-19 23:43:43 +03:30
|
|
|
disabled={!canEditTreatmentForDay}
|
|
|
|
|
canEdit={canEdit}
|
2026-06-28 18:08:29 +03:30
|
|
|
saveStatus={saveStatus}
|
2026-06-28 17:14:02 +03:30
|
|
|
uploadBusy={uploadBusyDetailId === activeDetailId}
|
|
|
|
|
onAddDetail={() => {
|
2026-07-08 02:53:47 +03:30
|
|
|
const next = newDetail(
|
|
|
|
|
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
|
|
|
|
|
);
|
2026-06-28 17:14:02 +03:30
|
|
|
setDetails((prev) => [...prev, next]);
|
|
|
|
|
setActiveDetailId(next.clientId);
|
2026-07-17 00:39:32 +03:30
|
|
|
setEntryStep('teeth');
|
2026-06-28 17:14:02 +03:30
|
|
|
}}
|
2026-07-17 00:39:32 +03:30
|
|
|
onRemoveDetail={handleRemoveDetail}
|
2026-06-28 17:14:02 +03:30
|
|
|
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
|
2026-07-17 00:39:32 +03:30
|
|
|
showChrome
|
|
|
|
|
showFields={false}
|
2026-06-28 17:14:02 +03:30
|
|
|
/>
|
|
|
|
|
|
2026-07-17 00:39:32 +03:30
|
|
|
<div className="surface-card px-3 py-2 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={visibleEntrySteps.map((step) => ({
|
|
|
|
|
id: step,
|
|
|
|
|
label:
|
|
|
|
|
step === 'teeth'
|
|
|
|
|
? t('entryStepTeeth')
|
|
|
|
|
: step === 'content'
|
|
|
|
|
? t('entryStepContent')
|
|
|
|
|
: t('entryStepLab'),
|
|
|
|
|
}))}
|
|
|
|
|
currentStepId={entryStep}
|
|
|
|
|
onStepChange={(stepId) => setEntryStep(stepId as EntryStep)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-wrap gap-2 sm:justify-end shrink-0">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
disabled={entryStep === 'teeth'}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const idx = visibleEntrySteps.indexOf(entryStep);
|
|
|
|
|
if (idx > 0) setEntryStep(visibleEntrySteps[idx - 1]);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{t('entryStepBack')}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="primary"
|
|
|
|
|
disabled={
|
|
|
|
|
entryStep === 'lab' || (entryStep === 'content' && !showLabWizardStep)
|
|
|
|
|
}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (entryStep === 'teeth') setEntryStep('content');
|
|
|
|
|
else if (entryStep === 'content' && showLabWizardStep) setEntryStep('lab');
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{t('entryStepNext')}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-07-13 01:03:26 +03:30
|
|
|
</div>
|
2026-07-17 00:39:32 +03:30
|
|
|
|
|
|
|
|
{entryStep === 'teeth' ? (
|
|
|
|
|
<FdiToothChart
|
|
|
|
|
selected={chartSelectedTeeth}
|
|
|
|
|
connectedTeeth={showWholeTreatmentPlan ? undefined : connectedSelectedTeeth}
|
|
|
|
|
toothColors={chartToothColors}
|
|
|
|
|
readOnly={showWholeTreatmentPlan}
|
|
|
|
|
headerControl={
|
|
|
|
|
details.length > 1 ? (
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={showWholeTreatmentPlan}
|
|
|
|
|
onChange={setShowWholeTreatmentPlan}
|
|
|
|
|
label={t('toothChartWholePlan')}
|
|
|
|
|
className="text-[11px] [&_span:last-child]:text-[11px] [&_span:last-child]:text-text-muted"
|
|
|
|
|
/>
|
|
|
|
|
) : undefined
|
|
|
|
|
}
|
|
|
|
|
onToggle={(fdi, event) => {
|
|
|
|
|
if (
|
|
|
|
|
!canEditTreatmentForDay ||
|
|
|
|
|
isDetailLocked(activeDetail) ||
|
|
|
|
|
showWholeTreatmentPlan ||
|
|
|
|
|
!activeDetailId
|
|
|
|
|
) {
|
|
|
|
|
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;
|
|
|
|
|
|
2026-07-18 00:02:40 +03:30
|
|
|
if (event.shiftKey) {
|
2026-07-17 00:39:32 +03:30
|
|
|
const anchor = rangeAnchorRef.current;
|
2026-07-18 00:02:40 +03:30
|
|
|
// Need a prior click as range start; shift alone on one tooth does nothing.
|
2026-07-17 00:39:32 +03:30
|
|
|
if (!anchor || anchor === fdi) {
|
|
|
|
|
rangeAnchorRef.current = fdi;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-18 00:02:40 +03:30
|
|
|
nextGroups = applyShiftRange(currentGroups, anchor, fdi);
|
2026-07-17 00:39:32 +03:30
|
|
|
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!,
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}}
|
|
|
|
|
disabled={!canEditTreatmentForDay || isDetailLocked(activeDetail)}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{entryStep === 'content' ? (
|
|
|
|
|
<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('teeth');
|
|
|
|
|
}}
|
|
|
|
|
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
|
|
|
|
|
showChrome={false}
|
|
|
|
|
showFields
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{entryStep === 'lab' ? (
|
|
|
|
|
<div ref={labPanelRef}>
|
|
|
|
|
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
|
|
|
|
|
{showLabDispatchPanel ? (
|
|
|
|
|
<LabCasesDispatchPanel
|
|
|
|
|
details={details}
|
|
|
|
|
activeDetailId={activeDetailId}
|
|
|
|
|
labCases={labCaseDrafts}
|
|
|
|
|
labDependentCodes={labDependentCodes}
|
|
|
|
|
treatmentCatalog={treatmentCatalog}
|
|
|
|
|
labCaseSummary={activeLabCaseSummary}
|
|
|
|
|
locale={locale}
|
|
|
|
|
onLabCaseSummaryChange={handleLabCaseSummaryChange}
|
|
|
|
|
onLabCaseMarkedRead={handleLabCaseMarkedRead}
|
|
|
|
|
onLabCaseActivityChange={() => {
|
|
|
|
|
if (historyPatientId) {
|
|
|
|
|
void refreshPatientLabCases(historyPatientId, { silent: true });
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
activeLabCaseId={activeLabCaseId}
|
|
|
|
|
onLabCasesChange={handleLabCasesChange}
|
|
|
|
|
disabled={!canEditTreatmentForDay}
|
|
|
|
|
canEdit={canEdit}
|
|
|
|
|
orgs={orgs}
|
|
|
|
|
organizationSearch={organizationSearch}
|
|
|
|
|
onOrganizationSearchChange={setOrganizationSearch}
|
|
|
|
|
recentOrganizationIds={recentOrganizationIds}
|
|
|
|
|
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}
|
2026-07-17 10:58:04 +03:30
|
|
|
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
|
2026-07-17 00:39:32 +03:30
|
|
|
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}
|
2026-05-07 03:40:29 +03:30
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|