improvement: new prosthesis type data structure implemented and finally working!

This commit is contained in:
2026-09-01 21:03:04 +03:30
parent dc71c73ced
commit a3c14a18c1
51 changed files with 3306 additions and 1117 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 { WizardStepper } from '@/components/ui/shared/WizardStepper';
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
import {
TreatmentLabCasesPanel,
@@ -14,6 +13,7 @@ import { TreatmentRailSection } from '@/components/ui/treatment/TreatmentRailSec
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { ProsthesisAssignChart } from '@/components/ui/treatment/ProsthesisAssignChart';
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
@@ -44,10 +44,8 @@ import { unscheduledStripColorCode, type DayStripItem } from '@/components/treat
import {
areDetailsPersistable,
defaultTreatmentTypeForAppointment,
isDetailReadyForLabDispatch,
isDetailTypeSelected,
isEmptyDraftDetail,
isLabDependentDetailMissingTeeth,
areUnscheduledDetailsStripDeletable,
} from '@/components/treatment/treatmentDetailRules';
import {
@@ -62,6 +60,7 @@ import {
toggleToothInGroups,
unlinkAdjacentTeeth,
} from '@/components/treatment/toothSelectionGroups';
import { hasArchJobs } from '@/components/treatment/prosthesisTree';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import {
@@ -97,7 +96,6 @@ import type {
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
type WorkspaceMode = 'live' | 'historical';
type EntryStep = 'treatment' | 'lab';
function isTreatmentDayHistorical(treatmentAt: string, todayStart: Date): boolean {
return compareLocalDayStart(new Date(treatmentAt), todayStart) < 0;
@@ -107,6 +105,15 @@ function withoutEmptyLabCaseDrafts(drafts: LabCaseDraft[]): LabCaseDraft[] {
return drafts.filter((lc) => lc.sentAt || Boolean(lc.detailClientId));
}
/** Prefer the unsent draft this chart writes to; fall back to a sent case for locked details. */
function labCaseDraftForDetail(
drafts: LabCaseDraft[],
detailClientId: string,
): LabCaseDraft | undefined {
const rows = drafts.filter((lc) => lc.detailClientId === detailClientId);
return rows.find((lc) => !lc.sentAt) ?? rows[0];
}
function labCaseDraftsToPast(
labCaseDrafts: LabCaseDraft[],
details: TreatmentDetailDraft[],
@@ -132,6 +139,7 @@ function labCaseDraftsToPast(
}
: null,
sends: lc.sends ?? [],
toothProsthesis: lc.toothProsthesis,
};
});
}
@@ -450,8 +458,8 @@ export function TreatmentWorkspace({
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
const labPanelRef = useRef<HTMLDivElement>(null);
const historyRequestRef = useRef(0);
/** When set, activeDetailId effect opens this step instead of resetting to treatment. */
const pendingEntryStepRef = useRef<EntryStep | null>(null);
/** When set, switching the active detail scrolls to the lab dispatch panel. */
const pendingScrollToLabRef = useRef(false);
useEffect(() => {
pendingAppointmentIdRef.current = initialAppointmentId;
@@ -470,7 +478,6 @@ export function TreatmentWorkspace({
const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
const [entryStep, setEntryStep] = useState<EntryStep>('treatment');
const [voiceAvailability, setVoiceAvailability] = useState<VoiceAvailability | null>(null);
@@ -629,27 +636,17 @@ export function TreatmentWorkspace({
[details, activeDetailId],
);
const showLabDispatchPanel = useMemo(
() => details.some((d) => isDetailReadyForLabDispatch(d, labDependentCodes)),
[details, labDependentCodes],
);
/** Lab send sheet only for prosthesis (lab-dependent) types on the active detail. */
const showLabWizardStep = useMemo(
const showLabDispatch = useMemo(
() => Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType)),
[activeDetail, labDependentCodes],
);
const showLabShipmentBlocked = useMemo(
() =>
Boolean(
activeDetail && isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes),
),
[activeDetail, labDependentCodes],
);
const activeTypeSelected = Boolean(activeDetail && isDetailTypeSelected(activeDetail));
const activeLocked = Boolean(activeDetail && isDetailLocked(activeDetail));
const activeHasArchJobs = hasArchJobs(
labCaseDraftForDetail(labCaseDrafts, activeDetailId)?.toothProsthesis ?? [],
activeDetailId,
);
useEffect(() => {
if (!selectedStandaloneId || draftHydratingRef.current) return;
@@ -853,28 +850,27 @@ export function TreatmentWorkspace({
const chartSelectedTeeth = showWholeTreatmentPlan ? wholePlanTeethSet : selectedTeethSet;
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
const chartLinkedEdges = showWholeTreatmentPlan ? wholePlanLinkedEdges : linkedToothEdges;
const prosthesisAssignActive =
!showWholeTreatmentPlan &&
Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType)) &&
prosthesisCatalog.length > 0;
// Reset whole-plan overview when switching details.
// Prefer pendingEntryStepRef (e.g. Lab shipments → Lab step) over defaulting to treatment.
useEffect(() => {
setShowWholeTreatmentPlan(false);
rangeAnchorRef.current = null;
setOrganizationSearch('');
const pending = pendingEntryStepRef.current;
pendingEntryStepRef.current = null;
setEntryStep(pending ?? 'treatment');
}, [activeDetailId]);
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
useEffect(() => {
if (entryStep === 'lab' && !showLabWizardStep) {
setEntryStep('treatment');
if (pendingScrollToLabRef.current) {
pendingScrollToLabRef.current = false;
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
}
}, [entryStep, showLabWizardStep]);
}, [activeDetailId]);
// Sync active lab shipment when the selected treatment detail changes.
useEffect(() => {
const match = labCaseDrafts.find((lc) => lc.detailClientId === activeDetailId);
const match = labCaseDraftForDetail(labCaseDrafts, activeDetailId);
setActiveLabCaseId(match?.clientId ?? null);
}, [activeDetailId, labCaseDrafts]);
@@ -1569,14 +1565,14 @@ export function TreatmentWorkspace({
skipNextGetDraftRef.current = true;
draftHydratingRef.current = true;
if (focusDetailClientId && options?.scrollToLabPanel !== false) {
pendingEntryStepRef.current = 'lab';
pendingScrollToLabRef.current = true;
}
hydrateFromTreatment(treatmentToLoad);
draftHydratingRef.current = false;
if (focusDetailClientId) {
if (options?.scrollToLabPanel !== false) {
pendingEntryStepRef.current = 'lab';
pendingScrollToLabRef.current = true;
}
setActiveDetailId(focusDetailClientId);
const mappedLabCases = withoutEmptyLabCaseDrafts(
@@ -1589,7 +1585,6 @@ export function TreatmentWorkspace({
setActiveLabCaseId(linked.clientId);
}
if (options?.scrollToLabPanel !== false) {
setEntryStep('lab');
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
@@ -1690,7 +1685,7 @@ export function TreatmentWorkspace({
(item: LabDispatchAttentionItem) => {
if (item.isCurrentDraft) {
exitBrowse();
pendingEntryStepRef.current = 'lab';
pendingScrollToLabRef.current = true;
setActiveDetailId(item.detailClientId);
const linked = labCaseDrafts.find(
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
@@ -1698,7 +1693,6 @@ export function TreatmentWorkspace({
if (linked) {
setActiveLabCaseId(linked.clientId);
}
setEntryStep('lab');
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
@@ -1759,13 +1753,12 @@ export function TreatmentWorkspace({
workspaceMode === 'live' &&
!isBrowsing
) {
pendingEntryStepRef.current = 'lab';
pendingScrollToLabRef.current = true;
setActiveDetailId(item.detailClientId);
const matchingDraft = labCaseDrafts.find((lc) => lc.id === item.labCaseId);
if (matchingDraft) {
setActiveLabCaseId(matchingDraft.clientId);
}
setEntryStep('lab');
requestAnimationFrame(() => {
scrollWithinMainScrollContainer(labPanelRef.current);
});
@@ -2003,7 +1996,6 @@ export function TreatmentWorkspace({
// persistDraft reads detailsRef, and setDetails has not rendered yet.
detailsRef.current = nextDetails;
setActiveDetailId(detail.clientId);
setEntryStep('treatment');
// Lab-side rows ride on a lab case draft keyed by the detail's *client* id, so a
// brand-new unsaved detail can still carry one; it is persisted after the detail is.
@@ -2098,7 +2090,6 @@ export function TreatmentWorkspace({
detailsRef.current = nextDetails;
setDetails(nextDetails);
setActiveDetailId(nextActive);
if (nextDetails.length === 0) setEntryStep('treatment');
if (labCaseDrafts.some((lc) => lc.detailClientId === detailClientId)) {
handleLabCasesChange(
@@ -2115,7 +2106,61 @@ export function TreatmentWorkspace({
handleLabCasesChange,
isDetailLocked,
labCaseDrafts,
setEntryStep,
t,
],
);
const handleTreatmentTypeChange = useCallback(
(nextType: string) => {
if (!canEditTreatmentForDay) return;
const detail = details.find((d) => d.clientId === activeDetailId);
if (!detail || isDetailLocked(detail)) return;
if (detail.treatmentType === nextType) return;
const leavingLab = labDependentCodes.has(detail.treatmentType);
const jobs =
labCaseDraftForDetail(labCaseDrafts, activeDetailId)?.toothProsthesis ?? [];
const hasWork =
detail.teeth.length > 0 ||
detail.toothSelectionGroups.some((g) => g.teeth.length > 0) ||
jobs.length > 0;
if (leavingLab && hasWork && !window.confirm(t('confirmLeaveProsthesis'))) {
return;
}
const nextDetails = details.map((d) => {
if (d.clientId !== activeDetailId) return d;
if (leavingLab) {
return {
...d,
treatmentType: nextType,
teeth: [],
toothSelectionGroups: [],
};
}
return { ...d, treatmentType: nextType };
});
detailsRef.current = nextDetails;
setDetails(nextDetails);
if (leavingLab) {
const nextDrafts = labCaseDrafts.filter(
(lc) => lc.detailClientId !== activeDetailId || Boolean(lc.sentAt),
);
if (nextDrafts.length !== labCaseDrafts.length) {
handleLabCasesChange(nextDrafts);
}
}
},
[
activeDetailId,
canEditTreatmentForDay,
details,
handleLabCasesChange,
isDetailLocked,
labCaseDrafts,
labDependentCodes,
t,
],
);
@@ -2133,16 +2178,8 @@ export function TreatmentWorkspace({
}
const activeDetail = details.find((d) => d.clientId === activeDetailId);
if (
activeDetail &&
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)
) {
showError(t('labShipmentBlockedBody'));
return;
}
const shouldIncludeActive = Boolean(
activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes),
activeDetail && labDependentCodes.has(activeDetail.treatmentType),
);
const orphan = cleaned.find((lc) => !lc.sentAt && !lc.detailClientId);
@@ -2191,13 +2228,12 @@ export function TreatmentWorkspace({
tErrors,
]);
// Auto-open shipment draft when entering Lab (no manual "Add lab shipment" click).
// Auto-create an unsent draft when the active detail is prosthesis (no wizard step).
useEffect(() => {
if (entryStep !== 'lab') return;
if (!showLabDispatch) return;
if (!canEditTreatmentForDay || !hasLiveContext) return;
const activeDetail = details.find((d) => d.clientId === activeDetailId);
if (!activeDetail || !isDetailReadyForLabDispatch(activeDetail, labDependentCodes)) return;
if (isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)) return;
if (!activeDetail || !labDependentCodes.has(activeDetail.treatmentType)) 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;
@@ -2208,12 +2244,12 @@ export function TreatmentWorkspace({
activeDetailId,
canEditTreatmentForDay,
details,
entryStep,
handleAddLabCase,
hasLiveContext,
isDetailLocked,
labCaseDrafts,
labDependentCodes,
selectedAppointment,
showLabDispatch,
]);
const handleSendLabCase = useCallback(
@@ -2572,6 +2608,7 @@ export function TreatmentWorkspace({
activeDetailId={activeDetailId}
onActiveDetailChange={setActiveDetailId}
onDetailsChange={setDetails}
onTreatmentTypeChange={handleTreatmentTypeChange}
isDetailLocked={isDetailLocked}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentDropdownCatalog}
@@ -2590,7 +2627,6 @@ export function TreatmentWorkspace({
const next = newDetail(seedFromAppointment);
setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId);
setEntryStep('treatment');
}}
onRemoveDetail={handleRemoveDetail}
onUploadFiles={(files, onProgress) =>
@@ -2619,14 +2655,125 @@ export function TreatmentWorkspace({
);
}}
showChrome
showFields={entryStep === 'treatment'}
showFields
hasArchJobs={activeHasArchJobs}
voice={voiceForEditor}
chartLocked={
entryStep === 'treatment' && !activeTypeSelected && !showWholeTreatmentPlan
}
chartLocked={!activeTypeSelected && !showWholeTreatmentPlan}
chartLockMessage={t('selectTypeBeforeTeeth')}
chart={
entryStep === 'treatment' ? (
prosthesisAssignActive ? (
<ProsthesisAssignChart
className="w-full"
groups={
activeDetail?.toothSelectionGroups?.length
? activeDetail.toothSelectionGroups
: groupsFromFlatTeeth(activeDetail?.teeth ?? [])
}
toothProsthesis={
labCaseDraftForDetail(labCaseDrafts, activeDetailId)?.toothProsthesis ??
[]
}
detailClientId={activeDetailId}
catalog={prosthesisCatalog}
disabled={!canEditTreatmentForDay || activeLocked}
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>
}
onToggleLink={(a, b) => {
if (
!canEditTreatmentForDay ||
activeLocked ||
!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,
),
};
}),
);
}}
onChange={({ groups, toothProsthesis }) => {
setDetails((prev) =>
prev.map((d) =>
d.clientId !== activeDetailId
? d
: {
...d,
toothSelectionGroups: groups,
teeth: deriveTeethFromGroups(groups),
},
),
);
setLabCaseDrafts((prev) => {
const existing = prev.find(
(lc) => lc.detailClientId === activeDetailId && !lc.sentAt,
);
if (existing) {
return prev.map((lc) =>
lc.clientId === existing.clientId ? { ...lc, toothProsthesis } : lc,
);
}
return [
...prev,
{
...newLabCaseDraft(),
detailClientId: activeDetailId,
toothProsthesis,
},
];
});
}}
/>
) : (
<FdiToothChart
className="w-full"
selected={chartSelectedTeeth}
@@ -2765,75 +2912,64 @@ export function TreatmentWorkspace({
activeLocked ||
!activeTypeSelected
}
onReset={() => {
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);
if (deriveTeethFromGroups(currentGroups).length === 0) return;
if (!window.confirm(t('confirmResetChart'))) return;
const nextGroups = groupsFromFlatTeeth([]);
setDetails((prev) =>
prev.map((d) =>
d.clientId !== activeDetailId
? d
: {
...d,
toothSelectionGroups: nextGroups,
teeth: [],
},
),
);
setLabCaseDrafts((prev) =>
prev.map((lc) => {
if (lc.detailClientId !== activeDetailId) return lc;
return {
...lc,
toothProsthesis: pruneToothProsthesisForGroups(
lc.toothProsthesis,
activeDetailId,
nextGroups,
),
};
}),
);
}}
/>
) : undefined
}
stepper={
showLabWizardStep ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<WizardStepper
className="min-w-0 flex-1"
aria-label={t('entryWizardLabel')}
steps={[
{ id: 'treatment', label: t('entryStepTreatment') },
{ id: 'lab', label: t('entryStepLab') },
]}
currentStepId={entryStep}
onStepChange={(stepId) => {
const next = stepId as EntryStep;
if (next === 'lab' && canEditTreatmentForDay) {
void persistDraft({ force: true })
.then(() => setEntryStep('lab'))
.catch((error: unknown) => {
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
});
return;
}
setEntryStep(next);
}}
/>
<div className="flex flex-wrap gap-2 sm:justify-end shrink-0">
<Button
type="button"
variant="ghost"
disabled={entryStep === 'treatment'}
onClick={() => setEntryStep('treatment')}
>
{t('entryStepBack')}
</Button>
<Button
type="button"
variant="primary"
disabled={entryStep === 'lab'}
onClick={async () => {
if (canEditTreatmentForDay) {
try {
await persistDraft({ force: true });
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
return;
}
}
setEntryStep('lab');
}}
>
{t('entryStepNext')}
</Button>
</div>
</div>
) : undefined
)
}
/>
{entryStep === 'lab' ? (
{showLabDispatch ? (
<div ref={labPanelRef} className="space-y-3">
{showLabDispatchPanel ? (
<LabCasesDispatchPanel
<LabCasesDispatchPanel
details={details}
activeDetailId={activeDetailId}
labCases={labCaseDrafts}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
prosthesisCatalog={prosthesisCatalog}
labCaseSummary={activeLabCaseSummary}
locale={locale}
onLabCaseSummaryChange={handleLabCaseSummaryChange}
@@ -2857,11 +2993,6 @@ export function TreatmentWorkspace({
canInviteLab={canAccessOrganizations}
onInviteLab={() => router.push('/organizations?action=invite-lab')}
/>
) : showLabShipmentBlocked ? null : (
<p className="text-sm text-text-muted surface-card p-4">
{t('entryStepLabUnavailable')}
</p>
)}
</div>
) : null}
</>