improvement: treatments UI/UX updated again to minimize clicking and scrolling.
This commit is contained in:
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
import { WizardStepper } from '@/components/ui/shared/WizardStepper';
|
||||
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
||||
import {
|
||||
@@ -39,8 +38,10 @@ import {
|
||||
areDetailsPersistable,
|
||||
defaultTreatmentTypeForAppointment,
|
||||
isDetailReadyForLabDispatch,
|
||||
isDetailTypeSelected,
|
||||
isEmptyDraftDetail,
|
||||
isLabDependentDetailMissingTeeth,
|
||||
areUnscheduledDetailsStripDeletable,
|
||||
} from '@/components/treatment/treatmentDetailRules';
|
||||
import {
|
||||
applyShiftRange,
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
} from '@/components/treatment/toothSelectionGroups';
|
||||
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
|
||||
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
|
||||
import { loadLabDispatchDefaults, rememberLastLab } from '@/components/treatment/labDispatchDefaults';
|
||||
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
|
||||
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
||||
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
|
||||
@@ -77,15 +79,14 @@ import type {
|
||||
PastLabCase,
|
||||
PastTreatment,
|
||||
PastTreatmentCase,
|
||||
PastTreatmentDetail,
|
||||
TreatmentAppointment,
|
||||
TreatmentDetailDraft,
|
||||
} from '@/types/treatment';
|
||||
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
||||
|
||||
type WorkspaceMode = 'live' | 'historical';
|
||||
type EntryStep = 'teeth' | 'content' | 'lab';
|
||||
|
||||
const ENTRY_STEPS: EntryStep[] = ['teeth', 'content', 'lab'];
|
||||
type EntryStep = 'treatment' | 'lab';
|
||||
|
||||
function isTreatmentDayHistorical(treatmentAt: string, todayStart: Date): boolean {
|
||||
return compareLocalDayStart(new Date(treatmentAt), todayStart) < 0;
|
||||
@@ -228,6 +229,22 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function draftToPastDetail(d: TreatmentDetailDraft): PastTreatmentDetail {
|
||||
return {
|
||||
id: d.id ?? d.clientId,
|
||||
clientId: d.clientId,
|
||||
treatmentType: d.treatmentType,
|
||||
teeth: d.teeth,
|
||||
toothSelectionGroups: d.toothSelectionGroups,
|
||||
notes: d.comment,
|
||||
attachmentMetas: d.attachmentMetas,
|
||||
labCaseId: d.labCaseId ?? null,
|
||||
taskProgress: d.taskProgress ?? null,
|
||||
sends: d.sends ?? [],
|
||||
sentAt: d.sentAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
|
||||
return {
|
||||
clientId: lc.clientId,
|
||||
@@ -419,7 +436,7 @@ export function TreatmentWorkspace({
|
||||
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
|
||||
const labPanelRef = useRef<HTMLDivElement>(null);
|
||||
const historyRequestRef = useRef(0);
|
||||
/** When set, activeDetailId effect opens this wizard step instead of resetting to teeth. */
|
||||
/** When set, activeDetailId effect opens this step instead of resetting to treatment. */
|
||||
const pendingEntryStepRef = useRef<EntryStep | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -439,7 +456,7 @@ export function TreatmentWorkspace({
|
||||
const [organizationSearch, setOrganizationSearch] = useState('');
|
||||
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
|
||||
const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
|
||||
const [entryStep, setEntryStep] = useState<EntryStep>('teeth');
|
||||
const [entryStep, setEntryStep] = useState<EntryStep>('treatment');
|
||||
|
||||
const isDetailLocked = useCallback(
|
||||
(detail: TreatmentDetailDraft) =>
|
||||
@@ -560,20 +577,12 @@ export function TreatmentWorkspace({
|
||||
[details, labDependentCodes],
|
||||
);
|
||||
|
||||
/** Lab wizard step only for prosthesis (lab-dependent) treatment types on the active detail. */
|
||||
/** Lab send sheet only for prosthesis (lab-dependent) types on the active detail. */
|
||||
const showLabWizardStep = useMemo(
|
||||
() => Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType)),
|
||||
[activeDetail, labDependentCodes],
|
||||
);
|
||||
|
||||
const visibleEntrySteps = useMemo(
|
||||
() =>
|
||||
showLabWizardStep
|
||||
? ENTRY_STEPS
|
||||
: (ENTRY_STEPS.filter((step) => step !== 'lab') as EntryStep[]),
|
||||
[showLabWizardStep],
|
||||
);
|
||||
|
||||
const showLabShipmentBlocked = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
@@ -582,6 +591,30 @@ export function TreatmentWorkspace({
|
||||
[activeDetail, labDependentCodes],
|
||||
);
|
||||
|
||||
const activeTypeSelected = Boolean(activeDetail && isDetailTypeSelected(activeDetail));
|
||||
const activeLocked = Boolean(activeDetail && isDetailLocked(activeDetail));
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedStandaloneId || draftHydratingRef.current) return;
|
||||
const nextDetails = details.map(draftToPastDetail);
|
||||
setStandaloneTreatments((prev) => {
|
||||
const current = prev.find((row) => row.id === selectedStandaloneId);
|
||||
if (!current) return prev;
|
||||
const prevColor = current.details.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
|
||||
const nextColor = nextDetails.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
|
||||
if (
|
||||
prevColor === nextColor &&
|
||||
areUnscheduledDetailsStripDeletable(current.details) ===
|
||||
areUnscheduledDetailsStripDeletable(nextDetails)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return prev.map((row) =>
|
||||
row.id === selectedStandaloneId ? { ...row, details: nextDetails } : row,
|
||||
);
|
||||
});
|
||||
}, [selectedStandaloneId, details]);
|
||||
|
||||
const dayStripItems = useMemo<DayStripItem[]>(() => {
|
||||
const timed: DayStripItem[] = appointments.map((a) => ({
|
||||
kind: 'appointment',
|
||||
@@ -596,7 +629,11 @@ export function TreatmentWorkspace({
|
||||
}));
|
||||
const unscheduled: DayStripItem[] = standaloneTreatments.map((tr) => {
|
||||
const isWalkIn = Boolean(tr.patient?.isWalkIn);
|
||||
const colorCode = tr.details[0]?.treatmentType || 'visit';
|
||||
const sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current
|
||||
? details
|
||||
: tr.details;
|
||||
const typedDetail = sourceDetails.find((d) => Boolean(d.treatmentType?.trim()));
|
||||
const colorCode = typedDetail?.treatmentType?.trim() ?? '';
|
||||
return {
|
||||
kind: 'unscheduled' as const,
|
||||
id: tr.id,
|
||||
@@ -607,11 +644,19 @@ export function TreatmentWorkspace({
|
||||
colorCode,
|
||||
timeLabel: null,
|
||||
subtitle: t('noAppointment'),
|
||||
canDelete: tr.details.length === 0,
|
||||
canDelete: areUnscheduledDetailsStripDeletable(sourceDetails),
|
||||
};
|
||||
});
|
||||
return [...timed, ...unscheduled];
|
||||
}, [appointments, standaloneTreatments, locale, treatmentCatalog, t]);
|
||||
}, [
|
||||
appointments,
|
||||
standaloneTreatments,
|
||||
selectedStandaloneId,
|
||||
details,
|
||||
locale,
|
||||
treatmentCatalog,
|
||||
t,
|
||||
]);
|
||||
|
||||
const selectedStripItemId = selectedAppointmentId ?? selectedStandaloneId;
|
||||
|
||||
@@ -681,16 +726,21 @@ export function TreatmentWorkspace({
|
||||
[labDependentCodes, currentDraftPreview, history, selectedAppointmentId],
|
||||
);
|
||||
|
||||
const hydrateFromTreatment = useCallback((treatment: PastTreatment) => {
|
||||
const hydrateFromTreatment = useCallback((
|
||||
treatment: PastTreatment,
|
||||
options?: { seedBlankIfEmpty?: boolean },
|
||||
) => {
|
||||
const mapped = treatment.details.map(mapDetailFromApi);
|
||||
const nextDetails =
|
||||
mapped.length > 0
|
||||
? mapped
|
||||
: [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))];
|
||||
: options?.seedBlankIfEmpty
|
||||
? [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))]
|
||||
: [];
|
||||
setDetails(nextDetails);
|
||||
setActiveDetailId((prev) => {
|
||||
const stillExists = nextDetails.some((d) => d.clientId === prev);
|
||||
return stillExists ? prev : nextDetails[0].clientId;
|
||||
return stillExists ? prev : (nextDetails[0]?.clientId ?? '');
|
||||
});
|
||||
setSavedSnapshot(serializeDetails(nextDetails));
|
||||
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
||||
@@ -734,23 +784,34 @@ export function TreatmentWorkspace({
|
||||
return colors;
|
||||
}, [details, treatmentCatalog]);
|
||||
|
||||
const wholePlanLinkedEdges = useMemo(() => {
|
||||
const edges = new Set<string>();
|
||||
for (const detail of details) {
|
||||
for (const key of linkedEdgesFromGroups(detail.toothSelectionGroups ?? [])) {
|
||||
edges.add(key);
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}, [details]);
|
||||
|
||||
const chartSelectedTeeth = showWholeTreatmentPlan ? wholePlanTeethSet : selectedTeethSet;
|
||||
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
|
||||
const chartLinkedEdges = showWholeTreatmentPlan ? wholePlanLinkedEdges : linkedToothEdges;
|
||||
|
||||
// Reset whole-plan overview when switching details.
|
||||
// Prefer pendingEntryStepRef (e.g. Lab shipments → Lab step) over defaulting to teeth.
|
||||
// Prefer pendingEntryStepRef (e.g. Lab shipments → Lab step) over defaulting to treatment.
|
||||
useEffect(() => {
|
||||
setShowWholeTreatmentPlan(false);
|
||||
rangeAnchorRef.current = null;
|
||||
const pending = pendingEntryStepRef.current;
|
||||
pendingEntryStepRef.current = null;
|
||||
setEntryStep(pending ?? 'teeth');
|
||||
setEntryStep(pending ?? 'treatment');
|
||||
}, [activeDetailId]);
|
||||
|
||||
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
|
||||
useEffect(() => {
|
||||
if (entryStep === 'lab' && !showLabWizardStep) {
|
||||
setEntryStep('content');
|
||||
setEntryStep('treatment');
|
||||
}
|
||||
}, [entryStep, showLabWizardStep]);
|
||||
|
||||
@@ -849,6 +910,12 @@ export function TreatmentWorkspace({
|
||||
setLabDependentCodes(
|
||||
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
|
||||
);
|
||||
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
|
||||
if (lastLabId && orgsResponse.data.some((o) => o.id === lastLabId && o.active)) {
|
||||
setRecentOrganizationIds((prev) =>
|
||||
prev.includes(lastLabId) ? prev : [lastLabId, ...prev].slice(0, 10),
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
showError(getUserFacingError(error, tErrors, t('errorLoadOrgs')));
|
||||
@@ -858,7 +925,7 @@ export function TreatmentWorkspace({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [showError, t]);
|
||||
}, [showError, t, currentOrganization?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePatientId) {
|
||||
@@ -915,6 +982,19 @@ export function TreatmentWorkspace({
|
||||
setUnreadLabCases((prev) => prev.filter((item) => item.labCaseId !== labCaseId));
|
||||
}, []);
|
||||
|
||||
const activeLabCaseSummary = useMemo(() => {
|
||||
if (activeSentLabCaseId) {
|
||||
return patientLabCases.find((item) => item.labCaseId === activeSentLabCaseId) ?? null;
|
||||
}
|
||||
return patientLabCases.find((item) => item.detailClientId === activeDetailId) ?? null;
|
||||
}, [patientLabCases, activeSentLabCaseId, activeDetailId]);
|
||||
|
||||
const handleLabCaseSummaryChange = useCallback((summary: PatientLabCaseSummary) => {
|
||||
setPatientLabCases((prev) =>
|
||||
prev.map((item) => (item.labCaseId === summary.labCaseId ? summary : item)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshUnreadLabCases();
|
||||
}, [refreshUnreadLabCases]);
|
||||
@@ -1003,6 +1083,10 @@ export function TreatmentWorkspace({
|
||||
return stillExists ? prev : mapped[0].clientId;
|
||||
});
|
||||
setSavedSnapshot(serializeDetails(mapped));
|
||||
} else if (response.data) {
|
||||
setDetails([]);
|
||||
setActiveDetailId('');
|
||||
setSavedSnapshot(serializeDetails([]));
|
||||
} else {
|
||||
const first = newDetail(
|
||||
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
|
||||
@@ -1101,7 +1185,7 @@ export function TreatmentWorkspace({
|
||||
setDetails(mapped);
|
||||
setActiveDetailId((prev) => {
|
||||
const stillExists = mapped.some((d) => d.clientId === prev);
|
||||
return stillExists ? prev : mapped[0]?.clientId ?? prev;
|
||||
return stillExists ? prev : (mapped[0]?.clientId ?? '');
|
||||
});
|
||||
setSavedSnapshot(serializeDetails(mapped));
|
||||
} else {
|
||||
@@ -1110,7 +1194,7 @@ export function TreatmentWorkspace({
|
||||
setDetails(merged);
|
||||
setActiveDetailId((prev) => {
|
||||
const stillExists = merged.some((d) => d.clientId === prev);
|
||||
return stillExists ? prev : merged[0]?.clientId ?? prev;
|
||||
return stillExists ? prev : (merged[0]?.clientId ?? '');
|
||||
});
|
||||
// Keep dirty so the queued autosave persists the newer local state.
|
||||
}
|
||||
@@ -1229,6 +1313,7 @@ export function TreatmentWorkspace({
|
||||
void (async () => {
|
||||
const ok = await flushDraftSave();
|
||||
if (!ok) return;
|
||||
draftHydratingRef.current = true;
|
||||
resetToLiveContext();
|
||||
setSearchedPatient(null);
|
||||
setSelectionLocked(true);
|
||||
@@ -1244,6 +1329,7 @@ export function TreatmentWorkspace({
|
||||
void (async () => {
|
||||
const ok = await flushDraftSave();
|
||||
if (!ok) return;
|
||||
draftHydratingRef.current = true;
|
||||
resetToLiveContext();
|
||||
setSearchedPatient(null);
|
||||
setSelectionLocked(true);
|
||||
@@ -1281,7 +1367,7 @@ export function TreatmentWorkspace({
|
||||
);
|
||||
skipNextGetDraftRef.current = true;
|
||||
draftHydratingRef.current = true;
|
||||
hydrateFromTreatment(created.data);
|
||||
hydrateFromTreatment(created.data, { seedBlankIfEmpty: true });
|
||||
draftHydratingRef.current = false;
|
||||
if (created.data.patientId && !created.data.patient?.isWalkIn) {
|
||||
await refreshHistory(created.data.patientId);
|
||||
@@ -1313,15 +1399,28 @@ export function TreatmentWorkspace({
|
||||
clearTimeout(autosaveTimerRef.current);
|
||||
autosaveTimerRef.current = null;
|
||||
}
|
||||
const wasSelected = selectedStandaloneId === item.id;
|
||||
if (wasSelected) {
|
||||
draftHydratingRef.current = true;
|
||||
}
|
||||
try {
|
||||
while (saveInFlightRef.current) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
// Chip-delete / cleared type can look empty in the strip before autosave
|
||||
// finishes. Persist [] first so DELETE /treatments/:id (empty-only) succeeds.
|
||||
await treatmentsApi.saveDraftByTreatment(item.id, { details: [] });
|
||||
await treatmentsApi.deleteStandalone(item.id);
|
||||
setStandaloneTreatments((prev) => prev.filter((row) => row.id !== item.id));
|
||||
if (selectedStandaloneId === item.id) {
|
||||
if (wasSelected) {
|
||||
resetToLiveContext();
|
||||
setSelectedStandaloneId(null);
|
||||
setSelectedAppointmentId(null);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (wasSelected) {
|
||||
draftHydratingRef.current = false;
|
||||
}
|
||||
showError(getUserFacingError(error, tErrors, t('errorDeleteTreatment')));
|
||||
}
|
||||
},
|
||||
@@ -1522,19 +1621,6 @@ export function TreatmentWorkspace({
|
||||
[exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace],
|
||||
);
|
||||
|
||||
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 () => {
|
||||
@@ -1635,7 +1721,11 @@ export function TreatmentWorkspace({
|
||||
}, [activeDetailId, patientLabCases]);
|
||||
|
||||
const uploadForDetail = useCallback(
|
||||
async (detailClientId: string, files: FileList | File[]) => {
|
||||
async (
|
||||
detailClientId: string,
|
||||
files: FileList | File[],
|
||||
onProgress?: (percent: number) => void,
|
||||
) => {
|
||||
if (!canEditTreatmentForDay || !hasLiveContext) return;
|
||||
const target = detailsRef.current.find((d) => d.clientId === detailClientId);
|
||||
if (target && isDetailLocked(target)) return;
|
||||
@@ -1645,15 +1735,17 @@ export function TreatmentWorkspace({
|
||||
setUploadBusyDetailId(detailClientId);
|
||||
try {
|
||||
const uploaded = selectedAppointment
|
||||
? await treatmentsApi.uploadCaseAttachments(
|
||||
? await treatmentsApi.uploadDetailAttachments(
|
||||
selectedAppointment.id,
|
||||
detailClientId,
|
||||
list,
|
||||
onProgress,
|
||||
)
|
||||
: await treatmentsApi.uploadDetailAttachmentsByTreatment(
|
||||
selectedStandalone!.id,
|
||||
detailClientId,
|
||||
list,
|
||||
onProgress,
|
||||
);
|
||||
setDetails((prev) =>
|
||||
prev.map((d) =>
|
||||
@@ -1790,20 +1882,20 @@ export function TreatmentWorkspace({
|
||||
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 (!target || isDetailLocked(target)) return;
|
||||
if (!window.confirm(t('confirmRemoveDetail'))) return;
|
||||
|
||||
const nextDetails = details.filter((d) => d.clientId !== detailClientId);
|
||||
const nextActive =
|
||||
activeDetailId === detailClientId
|
||||
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ??
|
||||
nextDetails[0]?.clientId)
|
||||
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ?? '')
|
||||
: activeDetailId;
|
||||
|
||||
// Sync ref before any persist triggered by lab-case cleanup (same tick).
|
||||
detailsRef.current = nextDetails;
|
||||
setDetails(nextDetails);
|
||||
if (nextActive) setActiveDetailId(nextActive);
|
||||
setActiveDetailId(nextActive);
|
||||
if (nextDetails.length === 0) setEntryStep('treatment');
|
||||
|
||||
if (labCaseDrafts.some((lc) => lc.detailClientId === detailClientId)) {
|
||||
handleLabCasesChange(
|
||||
@@ -1820,6 +1912,7 @@ export function TreatmentWorkspace({
|
||||
handleLabCasesChange,
|
||||
isDetailLocked,
|
||||
labCaseDrafts,
|
||||
setEntryStep,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -1866,9 +1959,15 @@ export function TreatmentWorkspace({
|
||||
return;
|
||||
}
|
||||
|
||||
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
|
||||
const lastLabStillActive = lastLabId
|
||||
? orgs.some((o) => o.id === lastLabId && o.active)
|
||||
: false;
|
||||
const next: LabCaseDraft = {
|
||||
...newLabCaseDraft(),
|
||||
detailClientId: shouldIncludeActive ? activeDetailId : null,
|
||||
destinationOrganizationId: lastLabStillActive ? lastLabId! : null,
|
||||
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
|
||||
};
|
||||
const updatedLabCases = [...cleaned, next];
|
||||
setLabCaseDrafts(updatedLabCases);
|
||||
@@ -1883,14 +1982,17 @@ export function TreatmentWorkspace({
|
||||
}, [
|
||||
activeDetailId,
|
||||
canEditTreatmentForDay,
|
||||
currentOrganization?.id,
|
||||
details,
|
||||
labCaseDrafts,
|
||||
labDependentCodes,
|
||||
orgs,
|
||||
persistDraft,
|
||||
persistLabCases,
|
||||
selectedAppointment,
|
||||
showError,
|
||||
t,
|
||||
tErrors,
|
||||
]);
|
||||
|
||||
// Auto-open shipment draft when entering Lab (no manual "Add lab shipment" click).
|
||||
@@ -1964,7 +2066,7 @@ export function TreatmentWorkspace({
|
||||
setDetails(mapped);
|
||||
setActiveDetailId((prev) => {
|
||||
const stillExists = mapped.some((d) => d.clientId === prev);
|
||||
return stillExists ? prev : mapped[0]?.clientId ?? prev;
|
||||
return stillExists ? prev : (mapped[0]?.clientId ?? '');
|
||||
});
|
||||
setSavedSnapshot(serializeDetails(mapped));
|
||||
} else {
|
||||
@@ -2004,6 +2106,7 @@ export function TreatmentWorkspace({
|
||||
|
||||
setRecentOrganizationIds((prev) => {
|
||||
const orgId = labCase.destinationOrganizationId!;
|
||||
rememberLastLab(currentOrganization?.id, orgId);
|
||||
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
|
||||
});
|
||||
showSuccess(t('successCaseSent'));
|
||||
@@ -2030,6 +2133,7 @@ export function TreatmentWorkspace({
|
||||
showError,
|
||||
t,
|
||||
tErrors,
|
||||
currentOrganization?.id,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2046,9 +2150,9 @@ export function TreatmentWorkspace({
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{canEdit ? t('subtitleEditPhase4') : t('subtitleReadOnly')}
|
||||
</p>
|
||||
{!canEdit ? (
|
||||
<p className="text-sm text-text-secondary">{t('subtitleReadOnly')}</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<AppointmentsStrip
|
||||
@@ -2132,16 +2236,6 @@ export function TreatmentWorkspace({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{workspaceMode === 'live' && !isBrowsing && hasLiveContext ? (
|
||||
<TreatmentPreviewCard
|
||||
treatment={currentDraftPreview}
|
||||
heading={t('previewCurrentDraft')}
|
||||
labDependentCodes={labDependentCodes}
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
orgs={orgs}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isBrowsing && previewTreatment ? (
|
||||
<>
|
||||
<div className="rounded-[var(--radius-md)] border border-primary/40 bg-primary/5 px-3 py-2 space-y-2">
|
||||
@@ -2257,218 +2351,242 @@ export function TreatmentWorkspace({
|
||||
);
|
||||
setDetails((prev) => [...prev, next]);
|
||||
setActiveDetailId(next.clientId);
|
||||
setEntryStep('teeth');
|
||||
setEntryStep('treatment');
|
||||
}}
|
||||
onRemoveDetail={handleRemoveDetail}
|
||||
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
|
||||
onUploadFiles={(files, onProgress) =>
|
||||
uploadForDetail(activeDetailId, files, onProgress)
|
||||
}
|
||||
onRemoveAttachment={(attachmentId) => {
|
||||
setDetails((prev) =>
|
||||
prev.map((d) =>
|
||||
d.clientId === activeDetailId
|
||||
? {
|
||||
...d,
|
||||
attachmentMetas: d.attachmentMetas.filter((a) => a.id !== attachmentId),
|
||||
}
|
||||
: d,
|
||||
),
|
||||
);
|
||||
setLabCaseDrafts((prev) =>
|
||||
prev.map((lc) =>
|
||||
lc.detailClientId === activeDetailId && !lc.sentAt
|
||||
? {
|
||||
...lc,
|
||||
attachmentIds: lc.attachmentIds.filter((id) => id !== attachmentId),
|
||||
}
|
||||
: lc,
|
||||
),
|
||||
);
|
||||
}}
|
||||
showChrome
|
||||
showFields={false}
|
||||
showFields={entryStep === 'treatment'}
|
||||
chartLocked={
|
||||
entryStep === 'treatment' && !activeTypeSelected && !showWholeTreatmentPlan
|
||||
}
|
||||
chartLockMessage={t('selectTypeBeforeTeeth')}
|
||||
chart={
|
||||
entryStep === 'treatment' ? (
|
||||
<FdiToothChart
|
||||
className="w-full"
|
||||
selected={chartSelectedTeeth}
|
||||
linkedEdges={chartLinkedEdges}
|
||||
connectedTeeth={showWholeTreatmentPlan ? undefined : connectedSelectedTeeth}
|
||||
toothColors={chartToothColors}
|
||||
readOnly={showWholeTreatmentPlan}
|
||||
headerControl={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWholeTreatmentPlan((open) => !open)}
|
||||
aria-pressed={showWholeTreatmentPlan}
|
||||
className={`
|
||||
rounded-[var(--radius-md)] border px-2.5 py-1 text-[11px] leading-none transition-colors
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
|
||||
${
|
||||
showWholeTreatmentPlan
|
||||
? 'border-primary bg-primary-soft font-medium text-text-primary'
|
||||
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{t('toothChartWholePlan')}
|
||||
</button>
|
||||
}
|
||||
onToggle={(fdi, event) => {
|
||||
if (
|
||||
!canEditTreatmentForDay ||
|
||||
activeLocked ||
|
||||
showWholeTreatmentPlan ||
|
||||
!activeDetailId ||
|
||||
!activeTypeSelected
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = details.find((d) => d.clientId === activeDetailId);
|
||||
if (!detail) return;
|
||||
const currentGroups =
|
||||
detail.toothSelectionGroups.length > 0
|
||||
? detail.toothSelectionGroups
|
||||
: groupsFromFlatTeeth(detail.teeth);
|
||||
|
||||
let nextGroups: ReturnType<typeof toggleToothInGroups> | null = null;
|
||||
|
||||
if (event.shiftKey) {
|
||||
const anchor = rangeAnchorRef.current;
|
||||
if (!anchor || anchor === fdi) {
|
||||
rangeAnchorRef.current = fdi;
|
||||
return;
|
||||
}
|
||||
nextGroups = applyShiftRange(currentGroups, anchor, fdi);
|
||||
rangeAnchorRef.current = fdi;
|
||||
if (!nextGroups) return;
|
||||
} else {
|
||||
nextGroups = toggleToothInGroups(currentGroups, fdi);
|
||||
rangeAnchorRef.current = fdi;
|
||||
}
|
||||
|
||||
setDetails((prev) =>
|
||||
prev.map((d) =>
|
||||
d.clientId !== activeDetailId
|
||||
? d
|
||||
: {
|
||||
...d,
|
||||
toothSelectionGroups: nextGroups!,
|
||||
teeth: deriveTeethFromGroups(nextGroups!),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
setLabCaseDrafts((prev) =>
|
||||
prev.map((lc) => {
|
||||
if (lc.detailClientId !== activeDetailId) return lc;
|
||||
return {
|
||||
...lc,
|
||||
toothProsthesis: pruneToothProsthesisForGroups(
|
||||
lc.toothProsthesis,
|
||||
activeDetailId,
|
||||
nextGroups!,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onToggleLink={(a, b) => {
|
||||
if (
|
||||
!canEditTreatmentForDay ||
|
||||
activeLocked ||
|
||||
showWholeTreatmentPlan ||
|
||||
!activeDetailId ||
|
||||
!activeTypeSelected
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const detail = details.find((d) => d.clientId === activeDetailId);
|
||||
if (!detail) return;
|
||||
const currentGroups =
|
||||
detail.toothSelectionGroups.length > 0
|
||||
? detail.toothSelectionGroups
|
||||
: groupsFromFlatTeeth(detail.teeth);
|
||||
const edgeLinked = linkedEdgesFromGroups(currentGroups).has(toothEdgeKey(a, b));
|
||||
const nextGroups = edgeLinked
|
||||
? unlinkAdjacentTeeth(currentGroups, a, b)
|
||||
: linkAdjacentTeeth(currentGroups, a, b);
|
||||
if (!nextGroups) return;
|
||||
|
||||
setDetails((prev) =>
|
||||
prev.map((d) =>
|
||||
d.clientId !== activeDetailId
|
||||
? d
|
||||
: {
|
||||
...d,
|
||||
toothSelectionGroups: nextGroups,
|
||||
teeth: deriveTeethFromGroups(nextGroups),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
setLabCaseDrafts((prev) =>
|
||||
prev.map((lc) => {
|
||||
if (lc.detailClientId !== activeDetailId) return lc;
|
||||
return {
|
||||
...lc,
|
||||
toothProsthesis: pruneToothProsthesisForGroups(
|
||||
lc.toothProsthesis,
|
||||
activeDetailId,
|
||||
nextGroups,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
!canEditTreatmentForDay ||
|
||||
activeLocked ||
|
||||
!activeTypeSelected
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
stepper={
|
||||
showLabWizardStep ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<WizardStepper
|
||||
className="min-w-0 flex-1"
|
||||
aria-label={t('entryWizardLabel')}
|
||||
steps={[
|
||||
{ id: 'treatment', label: t('entryStepTreatment') },
|
||||
{ id: 'lab', label: t('entryStepLab') },
|
||||
]}
|
||||
currentStepId={entryStep}
|
||||
onStepChange={(stepId) => {
|
||||
const next = stepId as EntryStep;
|
||||
if (next === 'lab' && canEditTreatmentForDay) {
|
||||
void persistDraft({ force: true })
|
||||
.then(() => setEntryStep('lab'))
|
||||
.catch((error: unknown) => {
|
||||
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
|
||||
});
|
||||
return;
|
||||
}
|
||||
setEntryStep(next);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2 sm:justify-end shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={entryStep === 'treatment'}
|
||||
onClick={() => setEntryStep('treatment')}
|
||||
>
|
||||
{t('entryStepBack')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={entryStep === 'lab'}
|
||||
onClick={async () => {
|
||||
if (canEditTreatmentForDay) {
|
||||
try {
|
||||
await persistDraft({ force: true });
|
||||
} catch (error: unknown) {
|
||||
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setEntryStep('lab');
|
||||
}}
|
||||
>
|
||||
{t('entryStepNext')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{entryStep === 'teeth' ? (
|
||||
<FdiToothChart
|
||||
selected={chartSelectedTeeth}
|
||||
linkedEdges={showWholeTreatmentPlan ? undefined : linkedToothEdges}
|
||||
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;
|
||||
|
||||
if (event.shiftKey) {
|
||||
const anchor = rangeAnchorRef.current;
|
||||
// Need a prior click as range start; shift alone on one tooth does nothing.
|
||||
if (!anchor || anchor === fdi) {
|
||||
rangeAnchorRef.current = fdi;
|
||||
return;
|
||||
}
|
||||
nextGroups = applyShiftRange(currentGroups, anchor, fdi);
|
||||
rangeAnchorRef.current = fdi;
|
||||
if (!nextGroups) return;
|
||||
} else {
|
||||
nextGroups = toggleToothInGroups(currentGroups, fdi);
|
||||
rangeAnchorRef.current = fdi;
|
||||
}
|
||||
|
||||
setDetails((prev) =>
|
||||
prev.map((d) =>
|
||||
d.clientId !== activeDetailId
|
||||
? d
|
||||
: {
|
||||
...d,
|
||||
toothSelectionGroups: nextGroups!,
|
||||
teeth: deriveTeethFromGroups(nextGroups!),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
setLabCaseDrafts((prev) =>
|
||||
prev.map((lc) => {
|
||||
if (lc.detailClientId !== activeDetailId) return lc;
|
||||
return {
|
||||
...lc,
|
||||
toothProsthesis: pruneToothProsthesisForGroups(
|
||||
lc.toothProsthesis,
|
||||
activeDetailId,
|
||||
nextGroups!,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onToggleLink={(a, b) => {
|
||||
if (
|
||||
!canEditTreatmentForDay ||
|
||||
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);
|
||||
const edgeLinked = linkedEdgesFromGroups(currentGroups).has(toothEdgeKey(a, b));
|
||||
const nextGroups = edgeLinked
|
||||
? unlinkAdjacentTeeth(currentGroups, a, b)
|
||||
: linkAdjacentTeeth(currentGroups, a, b);
|
||||
if (!nextGroups) return;
|
||||
|
||||
setDetails((prev) =>
|
||||
prev.map((d) =>
|
||||
d.clientId !== activeDetailId
|
||||
? d
|
||||
: {
|
||||
...d,
|
||||
toothSelectionGroups: nextGroups,
|
||||
teeth: deriveTeethFromGroups(nextGroups),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
setLabCaseDrafts((prev) =>
|
||||
prev.map((lc) => {
|
||||
if (lc.detailClientId !== activeDetailId) return lc;
|
||||
return {
|
||||
...lc,
|
||||
toothProsthesis: pruneToothProsthesisForGroups(
|
||||
lc.toothProsthesis,
|
||||
activeDetailId,
|
||||
nextGroups,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}}
|
||||
disabled={!canEditTreatmentForDay || 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}>
|
||||
<div ref={labPanelRef} className="space-y-3">
|
||||
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
|
||||
{showLabDispatchPanel ? (
|
||||
<LabCasesDispatchPanel
|
||||
@@ -2477,6 +2595,7 @@ export function TreatmentWorkspace({
|
||||
labCases={labCaseDrafts}
|
||||
labDependentCodes={labDependentCodes}
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
clinicOrganizationId={currentOrganization?.id}
|
||||
labCaseSummary={activeLabCaseSummary}
|
||||
locale={locale}
|
||||
onLabCaseSummaryChange={handleLabCaseSummaryChange}
|
||||
|
||||
Reference in New Issue
Block a user