improvement: Some improvements done. some bugs fixed.

This commit is contained in:
2026-09-02 17:24:01 +03:30
parent a3c14a18c1
commit 7f92e735fb
32 changed files with 1092 additions and 315 deletions

View File

@@ -170,11 +170,8 @@ export function CaseCreatePanel({
? activeLine.toothSelectionGroups
: groupsFromFlatTeeth(activeLine?.teeth ?? []);
const connectedGroupIds = new Set(
groups.filter((group) => group.kind === 'connected').map((group) => group.groupId),
);
const toothJobRows = activeLine
? toothJobRowsForDetail(activeLine.toothProsthesis, activeLine.clientId, connectedGroupIds)
? toothJobRowsForDetail(activeLine.toothProsthesis, activeLine.clientId)
: [];
const buildPayload = useCallback(

View File

@@ -18,10 +18,8 @@ import {
import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge';
import {
formatToothList,
prosthesisTypeBadgeStyleFromCatalog,
} from '@/components/treatment/prosthesisTypeDisplay';
import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay';
import { ProsthesisStackedTypeLabel } from '@/components/ui/lab/ProsthesisStackedTypeLabel';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import {
buildCaseConnectedTeeth,
@@ -313,23 +311,16 @@ export function CaseDetailPanel({
className="rounded-md border border-border p-3 space-y-2"
>
<div className="flex flex-wrap items-center gap-2">
<Badge
truncate
<ProsthesisStackedTypeLabel
className="max-w-[16rem] text-sm font-medium"
code={group.prosthesisTypeCode}
catalog={prosthesisCatalog}
title={group.prosthesisTypeLabel}
style={prosthesisTypeBadgeStyleFromCatalog(
group.prosthesisTypeCode,
prosthesisCatalog,
)}
>
{group.prosthesisTypeLabel}
</Badge>
<span className="text-sm font-medium text-text-primary">
{t('toothGroupTitle', {
teeth: formatToothList(group.teeth, {
UA: tTreatment('selectedArchUpper'),
LA: tTreatment('selectedArchLower'),
}),
prosthesis: group.prosthesisTypeLabel,
/>
<span className="text-sm text-text-secondary">
{formatToothList(group.teeth, {
UA: tTreatment('selectedArchUpper'),
LA: tTreatment('selectedArchLower'),
})}
</span>
{group.connected ? <ConnectedSelectionBadge /> : null}

View File

@@ -5,8 +5,9 @@ import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSele
import {
formatToothList,
prosthesisGroupLabelFromCatalog,
prosthesisTypeColorFromCatalog,
prosthesisTypeLabelStyleFromCatalog,
} from '@/components/treatment/prosthesisTypeDisplay';
import { ProsthesisStackedTypeLabel } from '@/components/ui/lab/ProsthesisStackedTypeLabel';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
export type LabCaseProsthesisGroup = {
@@ -23,7 +24,7 @@ interface LabCaseProsthesisGroupsListProps {
}
function prosthesisLabel(code: string, catalog: readonly ProsthesisCatalogEntry[]): string {
return prosthesisGroupLabelFromCatalog(code, catalog);
return catalog.find((entry) => entry.code === code)?.label ?? prosthesisGroupLabelFromCatalog(code, catalog);
}
export function LabCaseProsthesisGroupsList({
@@ -43,13 +44,12 @@ export function LabCaseProsthesisGroupsList({
<li
key={`${group.selectionGroupId ?? ''}-${group.prosthesisTypeCode}-${group.teeth.join(',')}`}
className="text-[11px] leading-snug"
style={{
color: prosthesisTypeColorFromCatalog(group.prosthesisTypeCode, prosthesisCatalog),
}}
>
<span className="font-medium">
{prosthesisLabel(group.prosthesisTypeCode, prosthesisCatalog)}
</span>
<ProsthesisStackedTypeLabel
className="font-medium"
code={group.prosthesisTypeCode}
catalog={prosthesisCatalog}
/>
{group.teeth.length > 0 ? (
<span className="text-text-muted">
{' · '}
@@ -73,7 +73,7 @@ export function LabCaseProsthesisGroupsList({
}
export type LabCaseToothJobRow = {
tooth: string;
teeth: string[];
codes: string[];
connected?: boolean;
};
@@ -98,11 +98,11 @@ export function LabCaseToothJobsList({
<ul className="space-y-1">
{rows.map((row) => (
<li
key={row.tooth}
key={`${row.teeth.join(',')}-${row.codes.join('+')}`}
className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-[11px] leading-snug"
>
<span className="font-semibold text-text-primary">
{formatToothList([row.tooth], archLabels)}
{formatToothList(row.teeth, archLabels)}
</span>
{row.codes.map((code) => (
<span key={code} className="inline-flex items-baseline gap-x-1.5">
@@ -111,7 +111,7 @@ export function LabCaseToothJobsList({
</span>
<span
className="font-medium"
style={{ color: prosthesisTypeColorFromCatalog(code, prosthesisCatalog) }}
style={prosthesisTypeLabelStyleFromCatalog(code, prosthesisCatalog)}
>
{prosthesisLabel(code, prosthesisCatalog)}
</span>

View File

@@ -0,0 +1,50 @@
'use client';
import {
prosthesisGroupLabelFromCatalog,
prosthesisTypeLabelStyleFromCatalog,
splitProsthesisGroupCode,
} from '@/components/treatment/prosthesisTypeDisplay';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
interface ProsthesisStackedTypeLabelProps {
code: string;
catalog: readonly ProsthesisCatalogEntry[];
className?: string;
title?: string;
}
export function ProsthesisStackedTypeLabel({
code,
catalog,
className,
title,
}: ProsthesisStackedTypeLabelProps) {
const parts = splitProsthesisGroupCode(code);
const fullTitle = title ?? prosthesisGroupLabelFromCatalog(code, catalog);
if (parts.length <= 1) {
const part = parts[0] ?? code;
return (
<span
className={className}
title={fullTitle}
style={prosthesisTypeLabelStyleFromCatalog(part, catalog)}
>
{catalog.find((entry) => entry.code === part)?.label ?? part}
</span>
);
}
return (
<span className={className} title={fullTitle}>
{parts.map((part, index) => (
<span key={`${part}-${index}`}>
{index > 0 ? <span className="text-text-muted"> + </span> : null}
<span style={prosthesisTypeLabelStyleFromCatalog(part, catalog)}>
{catalog.find((entry) => entry.code === part)?.label ?? part}
</span>
</span>
))}
</span>
);
}

View File

@@ -1,8 +1,8 @@
'use client';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/shared/Badge';
import { formatToothList, prosthesisTypeBadgeStyleFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay';
import { ProsthesisStackedTypeLabel } from '@/components/ui/lab/ProsthesisStackedTypeLabel';
import type { ProsthesisTaskGroup } from '@/components/lab/taskListGrouping';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
@@ -24,15 +24,12 @@ export function TaskProsthesisGroupHeader({
return (
<div className="flex flex-wrap items-center gap-2 px-3 py-1.5 bg-background-secondary/30 border-b border-border/40">
<Badge
fixedWidth={false}
truncate
<ProsthesisStackedTypeLabel
className="max-w-[16rem] text-xs font-medium"
code={group.prosthesisTypeCode}
catalog={prosthesisCatalog}
title={group.prosthesisTypeLabel}
style={prosthesisTypeBadgeStyleFromCatalog(group.prosthesisTypeCode, prosthesisCatalog)}
className="max-w-[10rem]"
>
{group.prosthesisTypeLabel}
</Badge>
/>
<span className="text-xs text-text-secondary">
{t('teethLabel', { teeth: formatToothList(group.teeth, archLabels) })}
</span>

View File

@@ -16,10 +16,8 @@ import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge';
import { isLabGeneratedCase } from '@/components/lab/labCaseOrigin';
import { formatAppDate, APP_DATE } from '@/lib/i18n/format';
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
import {
formatToothList,
prosthesisTypeBadgeStyleFromCatalog,
} from '@/components/treatment/prosthesisTypeDisplay';
import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay';
import { ProsthesisStackedTypeLabel } from '@/components/ui/lab/ProsthesisStackedTypeLabel';
import { tasksApi } from '@/lib/api/tasks';
import type { LabTaskListItem, LabTaskStatus } from '@/types/cases';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
@@ -207,18 +205,12 @@ export function TaskRow({
{t('showInCase')}
</Button>
) : null}
<Badge
fixedWidth={false}
truncate
<ProsthesisStackedTypeLabel
className="w-full max-w-[10rem] text-xs font-medium sm:w-[8rem]"
code={task.prosthesisTypeCode}
catalog={prosthesisCatalog}
title={task.prosthesisTypeLabel}
style={prosthesisTypeBadgeStyleFromCatalog(
task.prosthesisTypeCode,
prosthesisCatalog,
)}
className="w-full max-w-[8rem] sm:w-[7rem]"
>
{task.prosthesisTypeLabel}
</Badge>
/>
</div>
) : null}
</div>

View File

@@ -254,13 +254,8 @@ export function LabCasesDispatchPanel({
}
const typeLabel = treatmentTypeLabelFromCatalog(activeDetail.treatmentType, treatmentCatalog);
const connectedGroupIds = new Set(
(activeDetail.toothSelectionGroups ?? [])
.filter((group) => group.kind === 'connected')
.map((group) => group.groupId),
);
const toothJobRows = activeLabCase
? toothJobRowsForDetail(activeLabCase.toothProsthesis, activeDetailId, connectedGroupIds)
? toothJobRowsForDetail(activeLabCase.toothProsthesis, activeDetailId)
: [];
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];

View File

@@ -171,8 +171,31 @@ export function ProsthesisAssignChart({
if (!nextGroups || !union) return;
setRange(union);
commit(nextGroups, toothProsthesis);
setScope('tooth');
setAnchor(fdi);
setStep({ kind: 'category' });
setOpen(true);
return;
}
const isSelected = selected.has(fdi);
const hasJobs = codesOnTooth(toothProsthesis, detailClientId, fdi).length > 0;
const inPendingRange = Boolean(pendingRangeRef.current?.includes(fdi));
// Drop a tooth from an in-progress Shift range. A selected tooth with no jobs
// after hydrate must open the picker — otherwise reload looks like a deselect.
if (isSelected && !hasJobs && inPendingRange) {
const nextGroups = removeTeethFromGroups(groups, [fdi]);
const nextRows = clearJobsForTeeth(toothProsthesis, detailClientId, [fdi]);
const remainingRange = (pendingRangeRef.current ?? []).filter((tooth) => tooth !== fdi);
setRange(remainingRange.length > 1 ? remainingRange : null);
commit(nextGroups, nextRows);
if (anchor === fdi) {
setOpen(false);
setAnchor(null);
}
return;
}
openToothPicker(fdi);
}
@@ -256,11 +279,17 @@ export function ProsthesisAssignChart({
setOpen(false);
return;
}
const tooth = focusTooth;
if (!tooth) return;
const pending = pendingRangeRef.current;
const teeth =
pending && pending.length > 0
? pending
: focusTooth
? [focusTooth]
: [];
if (teeth.length === 0) return;
commit(
removeTeethFromGroups(groups, [tooth]),
clearJobsForTeeth(toothProsthesis, detailClientId, [tooth]),
removeTeethFromGroups(groups, teeth),
clearJobsForTeeth(toothProsthesis, detailClientId, teeth),
);
brushRef.current = [];
setOpen(false);
@@ -336,7 +365,10 @@ export function ProsthesisAssignChart({
onPickAddon={handlePickAddon}
onRemoveJob={handleRemoveJob}
onClear={handleClear}
onClose={() => setOpen(false)}
onClose={() => {
setOpen(false);
setRange(null);
}}
/>
}
onAssignPointer={handleAssignPointer}

View File

@@ -2,14 +2,14 @@
import type { ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { X } from 'lucide-react';
import { Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import {
ResponsiveDialogOverlay,
ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
import { prosthesisTypeColorFromCatalog, prosthesisTypeFillAccent } from '@/components/treatment/prosthesisTypeDisplay';
import {
addonLeaves,
categoryCodesInCatalog,
@@ -151,7 +151,7 @@ export function ProsthesisJobPopover({
const back = parentStep(step);
function openCategory(code: string) {
const subs = subcategoriesFor(catalog, code);
const subs = subcategoriesFor(catalog, code, scope);
const arch = scope === 'arch' ? lockedArch : undefined;
if (subs.length > 0) {
onStep({ kind: 'subcategory', category: code, arch });
@@ -264,7 +264,7 @@ export function ProsthesisJobPopover({
} else if (step.kind === 'leaves' && step.subcategory) {
selectedNode = { label: subLabel(step.subcategory) };
childrenKey = `leaves-${step.category}-${step.subcategory}`;
childTiles = leavesFor(catalog, step.category, step.subcategory).map(leafTile);
childTiles = leavesFor(catalog, step.category, step.subcategory, scope).map(leafTile);
} else {
selectedNode = {
label: categoryLabel(step.category),
@@ -274,7 +274,7 @@ export function ProsthesisJobPopover({
const arch = step.arch;
const subs =
step.kind === 'subcategory'
? subcategoriesFor(catalog, step.category).map((sub) => (
? subcategoriesFor(catalog, step.category, scope).map((sub) => (
<Tile
key={sub}
label={subLabel(sub)}
@@ -293,6 +293,7 @@ export function ProsthesisJobPopover({
catalog,
step.category,
step.kind === 'leaves' ? step.subcategory : undefined,
scope,
).map(leafTile);
childTiles = [...subs, ...leaves];
}
@@ -384,32 +385,39 @@ export function ProsthesisJobPopover({
{jobCodes.map((code) => {
const entry = byCode.get(code);
const color = prosthesisTypeColorFromCatalog(code, catalog);
const trashColor = prosthesisTypeFillAccent(color);
return (
<span
key={code}
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium"
className="inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border text-[11px] font-medium"
style={{
backgroundColor: color,
color: CHIP_INK,
borderColor: 'rgba(0,0,0,0.16)',
}}
>
<span className="opacity-70">
{entry?.category
? t(`category_${entry.category}` as never)
: t('regionCrown')}
<span className="inline-flex items-center gap-1 px-2 py-0.5">
<span className="opacity-70">
{entry?.category
? t(`category_${entry.category}` as never)
: t('regionCrown')}
</span>
{entry?.label ?? code}
</span>
{entry?.label ?? code}
<button
type="button"
className="rounded-full p-0.5 hover:bg-black/10"
className="inline-flex items-center justify-center border-s px-1.5 transition-colors hover:brightness-90"
style={{
borderColor: trashColor,
color: trashColor,
}}
aria-label={t('removeJob')}
onClick={(e) => {
e.stopPropagation();
onRemoveJob(code);
}}
>
<X className="h-3 w-3" aria-hidden />
<Trash2 className="lucide-inherit h-3.5 w-3.5" aria-hidden />
</button>
</span>
);

View File

@@ -329,12 +329,70 @@ function mergeServerIdsIntoDetails(
});
}
const PREVIEW_TREATMENT_ID = 'current-draft';
function isPreviewTreatment(treatment: PastTreatment): boolean {
return treatment.id === PREVIEW_TREATMENT_ID;
}
function serializeLabCases(drafts: LabCaseDraft[]): string {
return JSON.stringify(
[...drafts]
.sort((a, b) => a.clientId.localeCompare(b.clientId))
.map((lc) => ({
clientId: lc.clientId,
id: lc.id ?? null,
destinationOrganizationId: lc.destinationOrganizationId ?? null,
detailClientId: lc.detailClientId,
dueDate: lc.dueDate ?? null,
attachmentIds: [...lc.attachmentIds].sort(),
toothProsthesis: [...lc.toothProsthesis]
.map((tp) => ({
detailClientId: tp.detailClientId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
selectionGroupId: tp.selectionGroupId ?? '',
}))
.sort(
(a, b) =>
a.tooth.localeCompare(b.tooth) ||
a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode),
),
})),
);
}
function isLabCasesDirty(drafts: LabCaseDraft[], savedSnapshot: string | null): boolean {
if (savedSnapshot === null) {
return drafts.some(
(lc) =>
lc.toothProsthesis.length > 0 ||
Boolean(lc.destinationOrganizationId) ||
Boolean(lc.dueDate) ||
lc.attachmentIds.length > 0,
);
}
return serializeLabCases(drafts) !== savedSnapshot;
}
function mergeServerIdsIntoLabCases(
local: LabCaseDraft[],
fromServer: LabCaseDraft[],
): LabCaseDraft[] {
const byClientId = new Map(fromServer.map((lc) => [lc.clientId, lc]));
return local.map((lc) => {
const s = byClientId.get(lc.clientId);
if (!s) return lc;
return { ...lc, id: s.id ?? lc.id };
});
}
function detailsToPreviewTreatment(
details: TreatmentDetailDraft[],
meta: { title: string; patientId: string; treatmentAt: string; id?: string },
): PastTreatment {
return {
id: meta.id ?? 'current-draft',
id: meta.id ?? PREVIEW_TREATMENT_ID,
patientId: meta.patientId,
title: meta.title,
treatmentAt: meta.treatmentAt,
@@ -434,6 +492,7 @@ export function TreatmentWorkspace({
const [activeDetailId, setActiveDetailId] = useState<string>(() => details[0].clientId);
const [activeLabCaseId, setActiveLabCaseId] = useState<string | null>(null);
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
const [savedLabCasesSnapshot, setSavedLabCasesSnapshot] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'dirty' | 'saving' | 'saved' | 'error'>('idle');
const [selectedPreviewId, setSelectedPreviewId] = useState<string | null>(null);
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('live');
@@ -445,6 +504,8 @@ export function TreatmentWorkspace({
detailsRef.current = details;
const savedSnapshotRef = useRef(savedSnapshot);
savedSnapshotRef.current = savedSnapshot;
const savedLabCasesSnapshotRef = useRef(savedLabCasesSnapshot);
savedLabCasesSnapshotRef.current = savedLabCasesSnapshot;
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveInFlightRef = useRef(false);
const saveQueuedRef = useRef(false);
@@ -498,8 +559,10 @@ export function TreatmentWorkspace({
}, [labCaseDrafts, activeDetailId]);
const isDirty = useMemo(
() => isDetailsDirty(details, savedSnapshot),
[details, savedSnapshot],
() =>
isDetailsDirty(details, savedSnapshot) ||
isLabCasesDirty(labCaseDrafts, savedLabCasesSnapshot),
[details, savedSnapshot, labCaseDrafts, savedLabCasesSnapshot],
);
const AUTOSAVE_DEBOUNCE_MS = 600;
@@ -799,7 +862,11 @@ export function TreatmentWorkspace({
const mappedLabCases = withoutEmptyLabCaseDrafts(
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
);
const labSnap = serializeLabCases(mappedLabCases);
labCaseDraftsRef.current = mappedLabCases;
savedLabCasesSnapshotRef.current = labSnap;
setLabCaseDrafts(mappedLabCases);
setSavedLabCasesSnapshot(labSnap);
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
setSaveStatus('idle');
@@ -1158,7 +1225,11 @@ export function TreatmentWorkspace({
const mappedLabCases = withoutEmptyLabCaseDrafts(
(response.data?.labCases ?? []).map(mapLabCaseDraftFromApi),
);
const labSnap = serializeLabCases(mappedLabCases);
labCaseDraftsRef.current = mappedLabCases;
savedLabCasesSnapshotRef.current = labSnap;
setLabCaseDrafts(mappedLabCases);
setSavedLabCasesSnapshot(labSnap);
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
setSaveStatus('idle');
@@ -1241,12 +1312,15 @@ export function TreatmentWorkspace({
// 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) {
detailsRef.current = mapped;
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : (mapped[0]?.clientId ?? '');
});
setSavedSnapshot(serializeDetails(mapped));
const snap = serializeDetails(mapped);
savedSnapshotRef.current = snap;
setSavedSnapshot(snap);
} else {
const merged = mergeServerIdsIntoDetails(localNow, mapped);
detailsRef.current = merged;
@@ -1262,6 +1336,111 @@ export function TreatmentWorkspace({
[selectedAppointment, selectedStandalone, t],
);
const persistLabCases = useCallback(
async (savedTreatment: PastTreatment, draftsOverride?: LabCaseDraft[]) => {
if (!selectedAppointment && !selectedStandalone) throw new Error('No visit selected');
if (isPreviewTreatment(savedTreatment)) {
return savedTreatment;
}
const drafts = draftsOverride ?? labCaseDraftsRef.current;
const detailIdByClientId = new Map(
savedTreatment.details.map((d) => [d.clientId, d.id]),
);
const payload = drafts
.map((lc) => {
if (!lc.detailClientId) return null;
const treatmentDetailId = detailIdByClientId.get(lc.detailClientId);
if (!treatmentDetailId) return null;
return {
clientId: lc.clientId,
id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
treatmentDetailId,
toothProsthesis: lc.toothProsthesis
.map((tp) => {
const detailId = detailIdByClientId.get(tp.detailClientId);
if (!detailId) return null;
return {
treatmentDetailId: detailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
selectionGroupId: tp.selectionGroupId ?? '',
};
})
.filter(
(
row,
): row is {
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId: string;
} => row !== null,
),
attachmentIds: lc.attachmentIds,
dueDate: lc.dueDate ?? null,
};
})
.filter((row): row is NonNullable<typeof row> => row !== null);
const localJobCount = drafts.reduce((n, lc) => n + lc.toothProsthesis.length, 0);
const payloadJobCount = payload.reduce((n, lc) => n + lc.toothProsthesis.length, 0);
if (payloadJobCount < localJobCount) {
throw new Error('Lab case jobs could not be mapped to saved details');
}
const applySavedDrafts = (mapped: LabCaseDraft[]) => {
const sent = serializeLabCases(drafts);
const localNow = labCaseDraftsRef.current;
if (serializeLabCases(localNow) === sent) {
const snap = serializeLabCases(mapped);
labCaseDraftsRef.current = mapped;
savedLabCasesSnapshotRef.current = snap;
setLabCaseDrafts(mapped);
setSavedLabCasesSnapshot(snap);
setActiveLabCaseId((prev) => {
if (prev && mapped.some((lc) => lc.clientId === prev)) return prev;
return mapped[0]?.clientId ?? null;
});
return;
}
const merged = mergeServerIdsIntoLabCases(localNow, mapped);
labCaseDraftsRef.current = merged;
setLabCaseDrafts(merged);
};
const emptySnap = serializeLabCases([]);
if (payload.length === 0) {
if (drafts.length > 0) {
throw new Error('Lab case drafts could not be mapped to saved details');
}
if (
!savedLabCasesSnapshotRef.current ||
savedLabCasesSnapshotRef.current === emptySnap
) {
savedLabCasesSnapshotRef.current = emptySnap;
setSavedLabCasesSnapshot(emptySnap);
return savedTreatment;
}
}
const response = selectedAppointment
? await treatmentsApi.saveLabCases(selectedAppointment.id, {
labCases: payload,
})
: await treatmentsApi.saveLabCasesByTreatment(selectedStandalone!.id, {
labCases: payload,
});
const mapped = withoutEmptyLabCaseDrafts(response.data.labCases.map(mapLabCaseDraftFromApi));
applySavedDrafts(mapped);
return response.data;
},
[selectedAppointment, selectedStandalone],
);
const refreshHistory = useCallback(async (patientId: string, options?: { silentLabCases?: boolean }) => {
const requestId = ++historyRequestRef.current;
try {
@@ -1281,17 +1460,28 @@ export function TreatmentWorkspace({
return;
}
if (
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current) ||
!areDetailsPersistable(detailsRef.current)
) {
const detailsDirty = isDetailsDirty(detailsRef.current, savedSnapshotRef.current);
const labDirty = isLabCasesDirty(
labCaseDraftsRef.current,
savedLabCasesSnapshotRef.current,
);
if (!detailsDirty && !labDirty) {
return;
}
if (!areDetailsPersistable(detailsRef.current)) {
return;
}
saveInFlightRef.current = true;
setSaveStatus('saving');
const draftsAtSave = labCaseDraftsRef.current;
try {
await persistDraft();
const saved = await persistDraft({ force: labDirty && !detailsDirty });
if (isPreviewTreatment(saved)) {
setSaveStatus('dirty');
return;
}
await persistLabCases(saved, draftsAtSave);
setSaveStatus('saved');
if (historyPatientId) {
await refreshHistory(historyPatientId);
@@ -1304,12 +1494,15 @@ export function TreatmentWorkspace({
saveInFlightRef.current = false;
if (saveQueuedRef.current) {
saveQueuedRef.current = false;
if (isDetailsDirty(detailsRef.current, savedSnapshotRef.current)) {
if (
isDetailsDirty(detailsRef.current, savedSnapshotRef.current) ||
isLabCasesDirty(labCaseDraftsRef.current, savedLabCasesSnapshotRef.current)
) {
void runDraftSave();
}
}
}
}, [hasLiveContext, persistDraft, showError, t, historyPatientId, refreshHistory]);
}, [hasLiveContext, persistDraft, persistLabCases, showError, t, historyPatientId, refreshHistory]);
const flushDraftSave = useCallback(async (): Promise<boolean> => {
if (autosaveTimerRef.current) {
@@ -1325,7 +1518,10 @@ export function TreatmentWorkspace({
await new Promise((resolve) => setTimeout(resolve, 50));
}
if (!isDetailsDirty(detailsRef.current, savedSnapshotRef.current)) {
if (
!isDetailsDirty(detailsRef.current, savedSnapshotRef.current) &&
!isLabCasesDirty(labCaseDraftsRef.current, savedLabCasesSnapshotRef.current)
) {
return true;
}
@@ -1850,79 +2046,11 @@ export function TreatmentWorkspace({
[canEditTreatmentForDay, isDetailLocked, hasLiveContext, selectedAppointment, selectedStandalone, showSuccess, showError, t, tErrors],
);
const persistLabCases = useCallback(
async (savedTreatment: PastTreatment, draftsOverride?: LabCaseDraft[]) => {
if (!selectedAppointment && !selectedStandalone) throw new Error('No visit selected');
const drafts = draftsOverride ?? labCaseDrafts;
const detailIdByClientId = new Map(
savedTreatment.details.map((d) => [d.clientId, d.id]),
);
const payload = drafts
.map((lc) => {
if (!lc.detailClientId) return null;
const treatmentDetailId = detailIdByClientId.get(lc.detailClientId);
if (!treatmentDetailId) return null;
return {
clientId: lc.clientId,
id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
treatmentDetailId,
toothProsthesis: lc.toothProsthesis
.map((tp) => {
const detailId = detailIdByClientId.get(tp.detailClientId);
if (!detailId) return null;
return {
treatmentDetailId: detailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
selectionGroupId: tp.selectionGroupId ?? '',
};
})
.filter(
(
row,
): row is {
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId: string;
} => row !== null,
),
attachmentIds: lc.attachmentIds,
dueDate: lc.dueDate ?? null,
};
})
.filter((row): row is NonNullable<typeof row> => row !== null);
if (payload.length === 0) {
return savedTreatment;
}
const response = selectedAppointment
? await treatmentsApi.saveLabCases(selectedAppointment.id, {
labCases: payload,
})
: await treatmentsApi.saveLabCasesByTreatment(selectedStandalone!.id, {
labCases: payload,
});
const mapped = withoutEmptyLabCaseDrafts(response.data.labCases.map(mapLabCaseDraftFromApi));
setLabCaseDrafts(mapped);
setActiveLabCaseId((prev) => {
if (prev && mapped.some((lc) => lc.clientId === prev)) return prev;
return mapped[0]?.clientId ?? null;
});
return response.data;
},
[labCaseDrafts, selectedAppointment, selectedStandalone],
);
const handleLabCasesChange = useCallback(
(next: LabCaseDraft[]) => {
const prevCleaned = withoutEmptyLabCaseDrafts(labCaseDrafts);
const cleaned = withoutEmptyLabCaseDrafts(next);
labCaseDraftsRef.current = cleaned;
setLabCaseDrafts(cleaned);
if (!cleaned.some((lc) => lc.detailClientId === activeDetailId)) {
@@ -2036,9 +2164,7 @@ export function TreatmentWorkspace({
const updatedLabCases = [...labCaseDrafts, draft];
setLabCaseDrafts(updatedLabCases);
// Autosave only watches `details`, so a lab draft left in state alone loses the
// lab, the due date and the prosthesis map on reload — silently, because the
// detail itself survives.
// Persist the new detail first so lab-case rows can use real treatmentDetailIds.
void (async () => {
try {
const saved = await persistDraft({ force: true });