improvement: treatments UI/UX updated again to minimize clicking and scrolling.

This commit is contained in:
2026-08-19 23:59:37 +03:30
parent 29c639f5a1
commit d2f07c0ed3
33 changed files with 1651 additions and 744 deletions

View File

@@ -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}