improvement: Some improvements done. some bugs fixed.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user