improvement: fdi tooth chart selection modes polished. bugs related to teeth selecyion fixed.

This commit is contained in:
2026-07-18 00:02:40 +03:30
parent 4b2349228a
commit 0740493384
23 changed files with 384 additions and 161 deletions

View File

@@ -41,7 +41,6 @@ import {
isLabDependentDetailMissingTeeth,
} from '@/components/treatment/treatmentDetailRules';
import {
applyCtrlRange,
applyShiftRange,
connectedTeethSet,
deriveTeethFromGroups,
@@ -267,6 +266,26 @@ function isDetailsDirty(
return serializeDetails(details) !== savedSnapshot;
}
/** Apply server ids/attachments onto local rows without replacing newer local edits. */
function mergeServerIdsIntoDetails(
local: TreatmentDetailDraft[],
fromServer: TreatmentDetailDraft[],
): TreatmentDetailDraft[] {
const byClientId = new Map(fromServer.map((d) => [d.clientId, d]));
return local.map((d) => {
const s = byClientId.get(d.clientId);
if (!s) return d;
return {
...d,
id: s.id ?? d.id,
labCaseId: s.labCaseId ?? d.labCaseId,
taskProgress: s.taskProgress ?? d.taskProgress,
attachmentMetas:
d.attachmentMetas.length > 0 ? d.attachmentMetas : s.attachmentMetas,
};
});
}
function detailsToPreviewTreatment(
details: TreatmentDetailDraft[],
meta: { title: string; patientId: string; treatmentAt: string; id?: string },
@@ -387,6 +406,8 @@ export function TreatmentWorkspace({
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
const labPanelRef = useRef<HTMLDivElement>(null);
const historyRequestRef = useRef(0);
/** When set, activeDetailId effect opens this wizard step instead of resetting to teeth. */
const pendingEntryStepRef = useRef<EntryStep | null>(null);
useEffect(() => {
pendingAppointmentIdRef.current = initialAppointmentId;
@@ -613,10 +634,13 @@ export function TreatmentWorkspace({
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
// Reset whole-plan overview when switching details.
// Prefer pendingEntryStepRef (e.g. Lab shipments → Lab step) over defaulting to teeth.
useEffect(() => {
setShowWholeTreatmentPlan(false);
rangeAnchorRef.current = null;
setEntryStep('teeth');
const pending = pendingEntryStepRef.current;
pendingEntryStepRef.current = null;
setEntryStep(pending ?? 'teeth');
}, [activeDetailId]);
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
@@ -927,24 +951,40 @@ export function TreatmentWorkspace({
),
});
const mapped = response.data.details.map(mapDetailFromApi);
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev;
});
setSavedSnapshot(serializeDetails(mapped));
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.
}
return response.data;
},
[selectedAppointment, t],
);
const refreshHistory = useCallback(async (patientId: string) => {
const refreshHistory = useCallback(async (patientId: string, options?: { silentLabCases?: boolean }) => {
const requestId = ++historyRequestRef.current;
try {
const response = await treatmentsApi.listPatientHistory(patientId, 50);
if (requestId !== historyRequestRef.current) return;
setHistory(response.data);
void refreshPatientLabCases(patientId);
void refreshPatientLabCases(patientId, { silent: options?.silentLabCases ?? false });
} catch (error: unknown) {
if (requestId !== historyRequestRef.current) return;
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
@@ -1119,10 +1159,16 @@ export function TreatmentWorkspace({
skipNextGetDraftRef.current = true;
draftHydratingRef.current = true;
if (focusDetailClientId && options?.scrollToLabPanel !== false) {
pendingEntryStepRef.current = 'lab';
}
hydrateFromTreatment(treatmentToLoad);
draftHydratingRef.current = false;
if (focusDetailClientId) {
if (options?.scrollToLabPanel !== false) {
pendingEntryStepRef.current = 'lab';
}
setActiveDetailId(focusDetailClientId);
const mappedLabCases = withoutEmptyLabCaseDrafts(
(treatmentToLoad.labCases ?? []).map(mapLabCaseDraftFromApi),
@@ -1187,6 +1233,7 @@ export function TreatmentWorkspace({
(item: LabDispatchAttentionItem) => {
if (item.isCurrentDraft) {
exitBrowse();
pendingEntryStepRef.current = 'lab';
setActiveDetailId(item.detailClientId);
const linked = labCaseDrafts.find(
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
@@ -1268,12 +1315,21 @@ export function TreatmentWorkspace({
workspaceMode === 'live' &&
!isBrowsing
) {
pendingEntryStepRef.current = 'lab';
setActiveDetailId(item.detailClientId);
const matchingDraft = labCaseDrafts.find((lc) => lc.id === item.labCaseId);
if (matchingDraft) {
setActiveLabCaseId(matchingDraft.clientId);
}
setEntryStep('lab');
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
return;
}
await loadTreatmentIntoWorkspace(treatment, item.detailClientId, {
scrollToLabPanel: false,
scrollToLabPanel: true,
});
})();
},
@@ -1284,6 +1340,7 @@ export function TreatmentWorkspace({
history,
historyPanelItems,
isBrowsing,
labCaseDrafts,
loadTreatmentIntoWorkspace,
selectedAppointment?.id,
selectedAppointmentId,
@@ -1456,6 +1513,9 @@ export function TreatmentWorkspace({
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ??
nextDetails[0]?.clientId)
: activeDetailId;
// Sync ref before any persist triggered by lab-case cleanup (same tick).
detailsRef.current = nextDetails;
setDetails(nextDetails);
if (nextActive) setActiveDetailId(nextActive);
@@ -1554,6 +1614,9 @@ export function TreatmentWorkspace({
const activeDetail = details.find((d) => d.clientId === activeDetailId);
if (!activeDetail || !isDetailReadyForLabDispatch(activeDetail, labDependentCodes)) return;
if (isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)) return;
// Already shipped (or locked) — do not create another draft (avoids post-send flicker).
if (isDetailLocked(activeDetail)) return;
if (labCaseDrafts.some((lc) => lc.detailClientId === activeDetailId && lc.sentAt)) return;
const hasDraft = labCaseDrafts.some((lc) => lc.detailClientId === activeDetailId);
if (hasDraft) return;
void handleAddLabCase();
@@ -1563,6 +1626,7 @@ export function TreatmentWorkspace({
details,
entryStep,
handleAddLabCase,
isDetailLocked,
labCaseDrafts,
labDependentCodes,
selectedAppointment,
@@ -1608,12 +1672,17 @@ export function TreatmentWorkspace({
const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id);
if (draftResponse.data?.details?.length) {
const mapped = draftResponse.data.details.map(mapDetailFromApi);
detailsRef.current = mapped;
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev;
});
setSavedSnapshot(serializeDetails(mapped));
} else {
const sentDetailClientId = labCase.detailClientId;
setDetails((prev) =>
prev.map((detail) => {
setDetails((prev) => {
const next = prev.map((detail) => {
if (detail.clientId !== sentDetailClientId) return detail;
return {
...detail,
@@ -1624,8 +1693,10 @@ export function TreatmentWorkspace({
? [response.data.destinationOrganizationId]
: detail.sendToOrganizationIds,
};
}),
);
});
detailsRef.current = next;
return next;
});
}
setLabCaseDrafts((prev) =>
@@ -1637,6 +1708,7 @@ export function TreatmentWorkspace({
sentAt: response.data.sentAt,
destinationOrganizationId: response.data.destinationOrganizationId,
sends: response.data.sends,
taskProgress: response.data.taskProgress ?? lc.taskProgress,
}
: lc,
),
@@ -1649,8 +1721,8 @@ export function TreatmentWorkspace({
showSuccess(t('successCaseSent'));
notifyTabBadgesChanged();
if (selectedAppointment.patientId) {
void refreshPatientLabCases(selectedAppointment.patientId);
void refreshHistory(selectedAppointment.patientId);
// Silent: avoid rail/history loading flicker while staying on Lab step.
void refreshHistory(selectedAppointment.patientId, { silentLabCases: true });
}
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorSendCase')));
@@ -1663,7 +1735,6 @@ export function TreatmentWorkspace({
selectedAppointment,
persistDraft,
persistLabCases,
refreshPatientLabCases,
refreshHistory,
showSuccess,
showError,
@@ -1959,17 +2030,14 @@ export function TreatmentWorkspace({
let nextGroups: ReturnType<typeof toggleToothInGroups> | null = null;
// Shift wins if both modifiers are held (connected span).
if (event.shiftKey || event.ctrlKey) {
if (event.shiftKey) {
const anchor = rangeAnchorRef.current;
// Need a prior click as range start; modifier alone on one tooth does nothing.
// Need a prior click as range start; shift alone on one tooth does nothing.
if (!anchor || anchor === fdi) {
rangeAnchorRef.current = fdi;
return;
}
nextGroups = event.shiftKey
? applyShiftRange(currentGroups, anchor, fdi)
: applyCtrlRange(currentGroups, anchor, fdi);
nextGroups = applyShiftRange(currentGroups, anchor, fdi);
rangeAnchorRef.current = fdi;
if (!nextGroups) return;
} else {