refactor(voice): extract the apply decision so it can be tested

applyVoiceResult decided what to write and wrote it, in one callback inside a
3200-line client component. Nothing could reach it — not exported, and rendering
its component means mocking next-intl, the i18n router and six axios modules.
Three of the five defects found in live use sat in that callback, while 209
green tests covered the helpers around it.

buildVoiceApplyPlan(result, selection, ctx) -> { detail, labCaseDraft } now
holds the decision and writes nothing. The callback keeps only what a component
must do: setDetails, the ref writes the in-flight save reads, and the order that
lets lab rows carry a real treatmentDetailId. newDetail and newLabCaseDraft move
to treatmentDetailRules.ts so the pure module can build a draft without
importing a component.

21 tests. Verified they bite by reverting each bug in place: reading
labDependent from result.treatmentType fails 1, merging the plain teeth list
onto a prosthesis detail fails 2.

One group asserts an invariant that belongs to the backend — every
toothProsthesis row naming a real tooth must be in detail.teeth, which is
TREATMENT_TOOTH_NOT_ON_DETAIL at treatments.service.ts:806. It spans two
processes, so neither side could state it alone before.

Not covered, still manual: the labCaseDraftsRef timing needs a real render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-11 19:55:39 +08:00
parent 78fd3eb092
commit 2c3e28ac79
7 changed files with 557 additions and 112 deletions

View File

@@ -1,5 +1,5 @@
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { TreatmentDetailDraft } from '@/types/treatment';
import type { LabCaseDraft, TreatmentDetailDraft } from '@/types/treatment';
export type LabCaseTaskProgress = {
completed: number;
@@ -132,3 +132,39 @@ export function canCommentOnDetailLabCase(detail: DetailLike): boolean {
if (!detail.sentAt || !detail.labCaseId) return false;
return !isLabCaseCompleted(detail.taskProgress);
}
/**
* Fresh-draft factories. They live here rather than in the workspace component so the pure
* helpers that build a draft (see `voiceApply.ts`) can call them without importing JSX.
* `crypto.randomUUID` is the id; the fallback covers the older Safari versions the clinic uses.
*/
export function newDetail(defaultTreatmentType?: string): TreatmentDetailDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: defaultTreatmentType ?? '',
teeth: [],
toothSelectionGroups: [],
comment: '',
attachmentMetas: [],
sendToOrganizationIds: [],
sentAt: null,
};
}
export function newLabCaseDraft(): LabCaseDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `lab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
destinationOrganizationId: null,
detailClientId: null,
toothProsthesis: [],
attachmentIds: [],
sentAt: null,
sends: [],
};
}

View File

@@ -0,0 +1,331 @@
import { describe, expect, it } from 'vitest';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
import { ARCH_TOOTH_UPPER } from './prosthesisTree';
import { buildVoiceApplyPlan, type VoiceApplyContext } from './voiceApply';
const CATALOG: ProsthesisCatalogEntry[] = [
{
code: 'pfm_crown',
sortOrder: 1,
label: 'PFM Crown',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'full_metal_crown',
sortOrder: 2,
label: 'Full Metal Crown',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'cast_post_core',
sortOrder: 3,
label: 'Cast Post & Core',
category: 'post_core',
subcategory: '',
chartRegion: 'root',
stackGroup: 'post_core',
},
{
code: 'complete_denture',
sortOrder: 4,
label: 'Complete Denture',
category: 'denture',
subcategory: '',
chartRegion: 'arch',
stackGroup: 'arch',
},
];
const ctx = (overrides: Partial<VoiceApplyContext> = {}): VoiceApplyContext => ({
defaultTreatmentType: 'prosthesis',
labDependentCodes: new Set(['prosthesis']),
prosthesisCatalog: CATALOG,
...overrides,
});
const result = (overrides: Partial<VoiceExtractionResult> = {}): VoiceExtractionResult => ({
treatmentType: 'prosthesis',
teeth: [],
toothSelectionGroups: [],
comment: null,
prosthesisAssignments: [],
labId: null,
labMatchExact: false,
dueDate: null,
unresolved: [],
...overrides,
});
const allTicked: VoiceApplySelection = {
treatmentType: true,
teeth: true,
comment: true,
prosthesis: true,
lab: true,
dueDate: true,
};
const tick = (overrides: Partial<VoiceApplySelection> = {}): VoiceApplySelection => ({
...allTicked,
...overrides,
});
describe('buildVoiceApplyPlan — the treatment type that is actually written', () => {
it('takes the dictated type when its row is ticked', () => {
const plan = buildVoiceApplyPlan(
result({ treatmentType: 'prosthesis' }),
tick(),
ctx({ defaultTreatmentType: 'restoration' }),
);
expect(plan.detail.treatmentType).toBe('prosthesis');
});
it('keeps the appointment default when the type row is unticked', () => {
const plan = buildVoiceApplyPlan(
result({ treatmentType: 'prosthesis' }),
tick({ treatmentType: false }),
ctx({ defaultTreatmentType: 'restoration' }),
);
expect(plan.detail.treatmentType).toBe('restoration');
});
it('reads labDependent from the detail it writes, not from the dictation', () => {
// Decision 41. Unticking the type row leaves a NON lab-dependent default, so the
// prosthesis branch must not run — it used to, and saved lab rows on the seeded type.
const plan = buildVoiceApplyPlan(
result({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: ['12'], types: ['pfm_crown'], spoken: '' }],
}),
tick({ treatmentType: false }),
ctx({ defaultTreatmentType: 'restoration' }),
);
expect(plan.detail.treatmentType).toBe('restoration');
expect(plan.labCaseDraft).toBeNull();
expect(plan.detail.teeth).toEqual([]);
});
});
describe('buildVoiceApplyPlan — teeth on a lab-dependent detail', () => {
it('puts a tooth on the detail only when it carries a job', () => {
// Decision 40. A target whose codes were all refused is not a selection.
const plan = buildVoiceApplyPlan(
result({
teeth: ['12', '14', '16'],
prosthesisAssignments: [
{ targets: ['12', '14'], types: ['full_metal_crown'], spoken: '' },
],
}),
tick(),
ctx(),
);
expect(plan.detail.teeth).toEqual(['12', '14']);
// 16 was dictated as a bare tooth, but a prosthesis detail never takes the plain list.
expect(plan.detail.teeth).not.toContain('16');
});
it('ignores the plain teeth list entirely for a lab-dependent type', () => {
const plan = buildVoiceApplyPlan(result({ teeth: ['21', '22'] }), tick(), ctx());
expect(plan.detail.teeth).toEqual([]);
expect(plan.labCaseDraft).toBeNull();
});
it('takes the plain teeth list for a type that is not lab-dependent', () => {
const plan = buildVoiceApplyPlan(
result({ treatmentType: 'restoration', teeth: ['21', '22'] }),
tick(),
ctx({ defaultTreatmentType: 'restoration' }),
);
expect(plan.detail.teeth).toEqual(['21', '22']);
});
});
describe('buildVoiceApplyPlan — the contract the backend validates', () => {
// treatments.service.ts:806 rejects the save with TREATMENT_TOOTH_NOT_ON_DETAIL when a
// lab row names a tooth the detail does not carry. This is that rule, asserted on our side
// of the wire — it is the error the clinic actually saw.
const everyToothIsOnTheDetail = (plan: ReturnType<typeof buildVoiceApplyPlan>) => {
const teeth = new Set<string>(plan.detail.teeth);
return (plan.labCaseDraft?.toothProsthesis ?? [])
.filter((row) => row.tooth !== 'UA' && row.tooth !== 'LA')
.every((row) => teeth.has(row.tooth));
};
it('holds for two separated teeth sharing one crown type', () => {
const plan = buildVoiceApplyPlan(
result({
prosthesisAssignments: [
{ targets: ['12', '14'], types: ['full_metal_crown'], spoken: '' },
],
}),
tick(),
ctx(),
);
expect(everyToothIsOnTheDetail(plan)).toBe(true);
expect(plan.labCaseDraft?.toothProsthesis).toHaveLength(2);
});
it('holds for a single tooth', () => {
const plan = buildVoiceApplyPlan(
result({
prosthesisAssignments: [{ targets: ['26'], types: ['pfm_crown'], spoken: '' }],
}),
tick(),
ctx(),
);
expect(everyToothIsOnTheDetail(plan)).toBe(true);
});
it('holds for a stack of two codes on one tooth', () => {
const plan = buildVoiceApplyPlan(
result({
prosthesisAssignments: [
{ targets: ['26'], types: ['cast_post_core', 'pfm_crown'], spoken: '' },
],
}),
tick(),
ctx(),
);
expect(everyToothIsOnTheDetail(plan)).toBe(true);
expect(plan.labCaseDraft?.toothProsthesis.map((r) => r.prosthesisTypeCode)).toEqual([
'cast_post_core',
'pfm_crown',
]);
});
it('holds when a jaw and a tooth are dictated together', () => {
const plan = buildVoiceApplyPlan(
result({
prosthesisAssignments: [
{ targets: [ARCH_TOOTH_UPPER], types: ['complete_denture'], spoken: '' },
{ targets: ['36'], types: ['pfm_crown'], spoken: '' },
],
}),
tick(),
ctx(),
);
expect(everyToothIsOnTheDetail(plan)).toBe(true);
// The jaw sentinel rides on the lab rows but is never a tooth on the detail.
expect(plan.detail.teeth).toEqual(['36']);
expect(plan.labCaseDraft?.toothProsthesis.map((r) => r.tooth)).toEqual([
ARCH_TOOTH_UPPER,
'36',
]);
});
});
describe('buildVoiceApplyPlan — selection groups on the lab rows', () => {
it('gives a jaw appliance no selection group', () => {
const plan = buildVoiceApplyPlan(
result({
prosthesisAssignments: [
{ targets: [ARCH_TOOTH_UPPER], types: ['complete_denture'], spoken: '' },
],
}),
tick(),
ctx(),
);
expect(plan.labCaseDraft?.toothProsthesis[0]?.selectionGroupId).toBe('');
});
it('never lets two non-adjacent teeth share a group', () => {
// 12 and 14 are two apart, with 13 between them. Sharing one selectionGroupId told the
// lab to build them as a single bridge.
const plan = buildVoiceApplyPlan(
result({
toothSelectionGroups: [{ groupId: 'g1', kind: 'connected', teeth: ['12', '13', '14'] }],
prosthesisAssignments: [
{ targets: ['12', '14'], types: ['full_metal_crown'], spoken: '' },
],
}),
tick(),
ctx(),
);
const groupIds = plan.labCaseDraft?.toothProsthesis.map((r) => r.selectionGroupId) ?? [];
expect(new Set(groupIds).size).toBe(2);
expect(groupIds.every((id) => id !== '')).toBe(true);
});
it('keeps one group when the teeth really are a span', () => {
const plan = buildVoiceApplyPlan(
result({
toothSelectionGroups: [{ groupId: 'g1', kind: 'connected', teeth: ['12', '13'] }],
prosthesisAssignments: [
{ targets: ['12', '13'], types: ['full_metal_crown'], spoken: '' },
],
}),
tick(),
ctx(),
);
const groupIds = plan.labCaseDraft?.toothProsthesis.map((r) => r.selectionGroupId) ?? [];
expect(new Set(groupIds).size).toBe(1);
});
});
describe('buildVoiceApplyPlan — the lab case draft', () => {
it('is null when nothing lab-side was dictated', () => {
const plan = buildVoiceApplyPlan(result({ comment: 'hello' }), tick(), ctx());
expect(plan.labCaseDraft).toBeNull();
});
it('exists for a lab with no prosthesis work at all', () => {
const plan = buildVoiceApplyPlan(result({ labId: 'lab-1' }), tick(), ctx());
expect(plan.labCaseDraft?.destinationOrganizationId).toBe('lab-1');
expect(plan.labCaseDraft?.toothProsthesis).toEqual([]);
});
it('exists for a due date alone', () => {
const plan = buildVoiceApplyPlan(result({ dueDate: '2026-09-20' }), tick(), ctx());
expect(plan.labCaseDraft?.dueDate).toBe('2026-09-20');
});
it('drops the lab and the due date when their rows are unticked', () => {
const plan = buildVoiceApplyPlan(
result({ labId: 'lab-1', dueDate: '2026-09-20' }),
tick({ lab: false, dueDate: false }),
ctx(),
);
expect(plan.labCaseDraft).toBeNull();
});
it('points at the detail it was built with', () => {
const plan = buildVoiceApplyPlan(result({ labId: 'lab-1' }), tick(), ctx());
expect(plan.labCaseDraft?.detailClientId).toBe(plan.detail.clientId);
expect(plan.detail.clientId).toBeTruthy();
});
});
describe('buildVoiceApplyPlan — the comment', () => {
it('is written when its row is ticked', () => {
const plan = buildVoiceApplyPlan(result({ comment: 'سلام چطوری' }), tick(), ctx());
expect(plan.detail.comment).toBe('سلام چطوری');
});
it('is left blank when its row is unticked', () => {
const plan = buildVoiceApplyPlan(
result({ comment: 'سلام چطوری' }),
tick({ comment: false }),
ctx(),
);
expect(plan.detail.comment).toBe('');
});
});
describe('buildVoiceApplyPlan — it does not mutate what it was given', () => {
it('copies the dictated teeth rather than aliasing them', () => {
const dictated = result({ treatmentType: 'restoration', teeth: ['21'] });
const plan = buildVoiceApplyPlan(
dictated,
tick(),
ctx({ defaultTreatmentType: 'restoration' }),
);
plan.detail.teeth.push('22');
expect(dictated.teeth).toEqual(['21']);
});
});

View File

@@ -0,0 +1,113 @@
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { FdiToothId, LabCaseDraft, TreatmentDetailDraft } from '@/types/treatment';
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
import { newDetail, newLabCaseDraft } from './treatmentDetailRules';
import { groupsFromFlatTeeth } from './toothSelectionGroups';
import { prosthesisTargetLines } from './voiceReviewRows';
export interface VoiceApplyContext {
/**
* Seeded from the appointment purpose, and undefined when the purpose maps to none. It
* survives an unticked treatment-type row, which is the point of seeding it.
*/
defaultTreatmentType: string | undefined;
labDependentCodes: ReadonlySet<string>;
prosthesisCatalog: readonly ProsthesisCatalogEntry[];
}
export interface VoiceApplyPlan {
detail: TreatmentDetailDraft;
/** Null when the dictation produced no lab-side work at all. */
labCaseDraft: LabCaseDraft | null;
}
/**
* Turns a confirmed review sheet into the rows that will be written — one new detail, and the
* lab case draft that rides on it. Pure apart from the two id generators, so the decisions that
* used to live inside the workspace's `applyVoiceResult` callback can be tested directly.
*
* It decides only *what* to write. Committing it — state, refs, and the save order that lets lab
* rows reference a real treatmentDetailId — stays in the component.
*/
export function buildVoiceApplyPlan(
result: VoiceExtractionResult,
selection: VoiceApplySelection,
ctx: VoiceApplyContext,
): VoiceApplyPlan {
const detail = newDetail(ctx.defaultTreatmentType);
// Ticked rows land on top of the seeded default, so unticking the type row leaves the
// appointment-purpose default rather than a blank.
if (selection.treatmentType && result.treatmentType) {
detail.treatmentType = result.treatmentType;
}
// Teeth and prosthesis are one row for a lab-dependent type (decision 39): ticking "teeth"
// on its own could save an empty detail, since `persistDraft` prunes a lab-dependent detail
// down to its jobs. Derived from the detail actually being written, never from
// `result.treatmentType` — reading the result meant an unticked type row still took the
// prosthesis branch and saved lab rows on the seeded type (decision 41).
const labDependent = ctx.labDependentCodes.has(detail.treatmentType);
if (!labDependent && selection.teeth) {
detail.teeth = [...result.teeth];
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
...group,
teeth: [...group.teeth],
}));
}
if (selection.comment && result.comment) {
detail.comment = result.comment;
}
// The stack that will actually land, built through the same `applyLeafToJobs` the manual
// chart writes through (§7) — a code the stack rules refuse is not applied.
const lines =
labDependent && selection.prosthesis
? prosthesisTargetLines(result.prosthesisAssignments, ctx.prosthesisCatalog)
: [];
if (labDependent && selection.prosthesis) {
// An assignment target is a selection: a tooth exists on a prosthesis detail only by
// carrying a job (decision 40), never from the plain `teeth` field.
const teeth = lines
.filter((line) => !line.isJaw && line.applied.length > 0)
.map((line) => line.target as FdiToothId);
detail.teeth = teeth;
detail.toothSelectionGroups = groupsFromFlatTeeth(teeth, result.toothSelectionGroups);
}
const wantsLabDraft =
lines.some((line) => line.applied.length > 0) ||
Boolean(selection.lab && result.labId) ||
Boolean(selection.dueDate && result.dueDate);
if (!wantsLabDraft) return { detail, labCaseDraft: null };
// Keyed by the detail's *client* id, so a brand-new unsaved detail can still carry one.
const labCaseDraft = newLabCaseDraft();
labCaseDraft.detailClientId = detail.clientId;
if (selection.lab && result.labId) {
labCaseDraft.destinationOrganizationId = result.labId;
}
if (selection.dueDate && result.dueDate) {
labCaseDraft.dueDate = result.dueDate;
}
labCaseDraft.toothProsthesis = lines.flatMap((line) => {
// A jaw appliance belongs to no selection group; a tooth takes the group it landed in.
const selectionGroupId = line.isJaw
? ''
: (detail.toothSelectionGroups.find((group) =>
(group.teeth as readonly string[]).includes(line.target),
)?.groupId ?? '');
return line.applied.map((prosthesisTypeCode) => ({
detailClientId: detail.clientId,
tooth: line.target,
prosthesisTypeCode,
selectionGroupId,
}));
});
return { detail, labCaseDraft };
}

View File

@@ -48,7 +48,10 @@ import {
isDetailTypeSelected,
isEmptyDraftDetail,
areUnscheduledDetailsStripDeletable,
newDetail,
newLabCaseDraft,
} from '@/components/treatment/treatmentDetailRules';
import { buildVoiceApplyPlan } from '@/components/treatment/voiceApply';
import {
applyShiftRange,
connectedTeethSet,
@@ -62,7 +65,6 @@ import {
unlinkAdjacentTeeth,
} from '@/components/treatment/toothSelectionGroups';
import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree';
import { prosthesisTargetLines } from '@/components/treatment/voiceReviewRows';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import {
@@ -200,37 +202,6 @@ function buildWorkspaceSnapshot(
};
}
function newDetail(defaultTreatmentType?: string): TreatmentDetailDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: defaultTreatmentType ?? '',
teeth: [],
toothSelectionGroups: [],
comment: '',
attachmentMetas: [],
sendToOrganizationIds: [],
sentAt: null,
};
}
function newLabCaseDraft(): LabCaseDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `lab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
destinationOrganizationId: null,
detailClientId: null,
toothProsthesis: [],
attachmentIds: [],
sentAt: null,
sends: [],
};
}
function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
return {
id: record.id,
@@ -2201,53 +2172,17 @@ export function TreatmentWorkspace({
*/
const applyVoiceResult = useCallback(
(result: VoiceExtractionResult, selection: VoiceApplySelection) => {
const detail = newDetail(
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
);
// Ticked rows land on top of the seeded defaults, so unticking the type row leaves
// the appointment-purpose default rather than a blank.
if (selection.treatmentType && result.treatmentType) {
detail.treatmentType = result.treatmentType;
}
// Teeth and prosthesis are one row for a lab-dependent type (decision 39) — ticking
// "teeth" independently of "prosthesis" could save an empty detail, since
// `persistDraft` prunes a lab-dependent detail down to its jobs. `selection.prosthesis`
// alone drives both below; `selection.teeth` only ever applies to the plain row.
// Derived from the detail actually being written, never from `result.treatmentType`.
// Reading the result meant that unticking the type row still took the prosthesis branch,
// saving lab rows on whatever type the appointment purpose had seeded (decision 41).
const labDependent = labDependentCodes.has(detail.treatmentType);
if (!labDependent && selection.teeth) {
detail.teeth = [...result.teeth];
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
...group,
teeth: [...group.teeth],
}));
}
if (selection.comment && result.comment) {
detail.comment = result.comment;
}
// The stack that will actually land, built through the same `applyLeafToJobs` the
// manual chart writes through (§7) — a code the stack rules refuse is not applied.
const prosthesisLines =
labDependent && selection.prosthesis
? prosthesisTargetLines(result.prosthesisAssignments, prosthesisCatalog)
: [];
// An assignment target is a selection: a tooth exists on a prosthesis detail only by
// carrying a job (decision 40) — never from the plain `teeth` field.
const prosthesisTeeth = prosthesisLines
.filter((line) => !line.isJaw && line.applied.length > 0)
.map((line) => line.target as FdiToothId);
if (labDependent && selection.prosthesis) {
detail.teeth = prosthesisTeeth;
detail.toothSelectionGroups = groupsFromFlatTeeth(
prosthesisTeeth,
result.toothSelectionGroups,
);
}
// What to write is decided by `buildVoiceApplyPlan`, which is pure and unit tested.
// Everything below is the commit: state, the refs the save reads, and the order that
// lets lab rows reference a real treatmentDetailId.
const { detail, labCaseDraft } = buildVoiceApplyPlan(result, selection, {
defaultTreatmentType: defaultTreatmentTypeForAppointment(
selectedAppointment?.purpose,
treatmentCatalog,
),
labDependentCodes,
prosthesisCatalog,
});
const nextDetails = [...detailsRef.current, detail];
setDetails(nextDetails);
@@ -2255,36 +2190,8 @@ export function TreatmentWorkspace({
detailsRef.current = nextDetails;
setActiveDetailId(detail.clientId);
// 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.
const wantsLabDraft =
prosthesisLines.some((line) => line.applied.length > 0) ||
(selection.lab && result.labId) ||
(selection.dueDate && result.dueDate);
if (wantsLabDraft) {
const draft = newLabCaseDraft();
draft.detailClientId = detail.clientId;
if (selection.lab && result.labId) {
draft.destinationOrganizationId = result.labId;
}
if (selection.dueDate && result.dueDate) {
draft.dueDate = result.dueDate;
}
draft.toothProsthesis = prosthesisLines.flatMap((line) => {
const groupId = line.isJaw
? ''
: (detail.toothSelectionGroups.find((group) =>
(group.teeth as readonly string[]).includes(line.target),
)?.groupId ?? '');
return line.applied.map((prosthesisTypeCode) => ({
detailClientId: detail.clientId,
tooth: line.target,
prosthesisTypeCode,
selectionGroupId: groupId,
}));
});
const updatedLabCases = [...labCaseDrafts, draft];
if (labCaseDraft) {
const updatedLabCases = [...labCaseDrafts, labCaseDraft];
labCaseDraftsRef.current = updatedLabCases;
setLabCaseDrafts(updatedLabCases);