improvement: treatment plan turned into a wizard. shift+click control added to FDI tooth chart for cunnected prosthesises.

This commit is contained in:
2026-07-17 00:39:32 +03:30
parent cf07b4d8a8
commit 68fc9d5d6d
28 changed files with 1461 additions and 477 deletions

View File

@@ -660,24 +660,44 @@ export class CasesService {
}
private buildProsthesisGroupsFromTasks(
tasks: Array<{ prosthesisTypeCode: string; teeth: Prisma.JsonValue }>,
): Array<{ prosthesisTypeCode: string; teeth: string[] }> {
const prosthesisByCode = new Map<string, string[]>();
tasks: Array<{
prosthesisTypeCode: string;
teeth: Prisma.JsonValue;
selectionGroupId?: string;
}>,
): Array<{
prosthesisTypeCode: string;
teeth: string[];
connected?: boolean;
selectionGroupId?: string;
}> {
const prosthesisByGroup = new Map<
string,
{ prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string }
>();
for (const task of tasks) {
if (!task.prosthesisTypeCode) continue;
const selectionGroupId = task.selectionGroupId ?? '';
const key = `${selectionGroupId}::${task.prosthesisTypeCode}`;
const teeth = normalizeTaskTeeth(task.teeth);
const list = prosthesisByCode.get(task.prosthesisTypeCode) ?? [];
list.push(...teeth);
prosthesisByCode.set(task.prosthesisTypeCode, list);
const entry = prosthesisByGroup.get(key) ?? {
prosthesisTypeCode: task.prosthesisTypeCode,
teeth: [],
selectionGroupId,
};
entry.teeth.push(...teeth);
prosthesisByGroup.set(key, entry);
}
return [...prosthesisByCode.entries()]
.map(([prosthesisTypeCode, teeth]) => ({
prosthesisTypeCode,
teeth: [...new Set(teeth)].sort((a, b) =>
return [...prosthesisByGroup.values()]
.map((group) => ({
prosthesisTypeCode: group.prosthesisTypeCode,
teeth: [...new Set(group.teeth)].sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true }),
),
selectionGroupId: group.selectionGroupId || undefined,
connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1,
}))
.sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode));
}
@@ -694,12 +714,14 @@ export class CasesService {
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
selectionGroupId: string;
tasks: ReturnType<CasesService['mapTask']>[];
}
>();
for (const task of tasks) {
const key = `${task.treatmentDetailId}:${task.prosthesisTypeCode}`;
const selectionGroupId = task.selectionGroupId ?? '';
const key = `${task.treatmentDetailId}:${selectionGroupId}:${task.prosthesisTypeCode}`;
const entry = groups.get(key) ?? {
treatmentDetailId: task.treatmentDetailId,
teeth: normalizeTaskTeeth(task.teeth),
@@ -707,6 +729,7 @@ export class CasesService {
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
selectionGroupId,
tasks: [],
};
entry.tasks.push(this.mapTask(task, prosthesisLabels));

View File

@@ -293,24 +293,41 @@ export class LabCaseAccessService {
) {
const tasks = await this.prisma.labCaseTask.findMany({
where: { labCaseId },
select: { prosthesisTypeCode: true, teeth: true },
select: { prosthesisTypeCode: true, teeth: true, selectionGroupId: true },
orderBy: [{ prosthesisTypeCode: 'asc' }],
});
const byCode = new Map<string, string[]>();
const byGroup = new Map<
string,
{ prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string }
>();
for (const task of tasks) {
if (!task.prosthesisTypeCode) continue;
const selectionGroupId = task.selectionGroupId ?? '';
const key = `${selectionGroupId}::${task.prosthesisTypeCode}`;
const teeth = normalizeTaskTeeth(task.teeth);
const list = byCode.get(task.prosthesisTypeCode) ?? [];
list.push(...teeth);
byCode.set(task.prosthesisTypeCode, list);
const entry = byGroup.get(key) ?? {
prosthesisTypeCode: task.prosthesisTypeCode,
teeth: [],
selectionGroupId,
};
entry.teeth.push(...teeth);
byGroup.set(key, entry);
}
return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({
prosthesisTypeCode,
prosthesisTypeLabel: prosthesisLabels.get(prosthesisTypeCode) ?? prosthesisTypeCode,
teeth: [...new Set(teeth)].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })),
}));
return [...byGroup.values()].map((group) => {
const teeth = [...new Set(group.teeth)].sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true }),
);
return {
prosthesisTypeCode: group.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(group.prosthesisTypeCode) ?? group.prosthesisTypeCode,
teeth,
selectionGroupId: group.selectionGroupId || undefined,
connected: Boolean(group.selectionGroupId) && teeth.length > 1,
};
});
}
private async getMembership(userId: string, organizationId: string) {

View File

@@ -11,6 +11,7 @@ function buildMockTx(options: {
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId?: string;
treatmentType?: string;
}>;
prosthesisTypes: Array<{
@@ -35,9 +36,11 @@ function buildMockTx(options: {
treatmentDetailId: row.treatmentDetailId,
tooth: row.tooth,
prosthesisTypeCode: row.prosthesisTypeCode,
selectionGroupId: row.selectionGroupId ?? '',
detail: {
id: row.treatmentDetailId,
treatmentType: row.treatmentType ?? 'prosthesis',
toothSelectionGroups: null,
},
})),
),
@@ -169,6 +172,49 @@ describe('generateLabCaseTasks', () => {
expect(stepCodes).toContain('printer_resin');
});
it('keeps separate selection groups with the same prosthesis type as separate task sets', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '14',
prosthesisTypeCode: 'pfm_crown',
selectionGroupId: 'connected-1',
},
{
treatmentDetailId: 'detail-1',
tooth: '15',
prosthesisTypeCode: 'pfm_crown',
selectionGroupId: 'connected-1',
},
{
treatmentDetailId: 'detail-1',
tooth: '21',
prosthesisTypeCode: 'pfm_crown',
selectionGroupId: 'single-21',
},
],
prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-split', 'en');
expect(count).toBe(pfmSteps.length * 2);
const rows = created as Array<{
teeth: string[];
selectionGroupId: string;
prosthesisTypeCode: string;
}>;
const connected = rows.filter((r) => r.selectionGroupId === 'connected-1');
const single = rows.filter((r) => r.selectionGroupId === 'single-21');
expect(connected).toHaveLength(pfmSteps.length);
expect(single).toHaveLength(pfmSteps.length);
expect(connected.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15']))).toBe(
true,
);
expect(single.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['21']))).toBe(true);
});
it('skips generation when tasks already exist', async () => {
const { tx } = buildMockTx({
existingCount: 3,

View File

@@ -18,7 +18,7 @@ export async function generateLabCaseTasks(
const toothProsthesisRows = await tx.labCaseToothProsthesis.findMany({
where: { labCaseId },
include: {
detail: { select: { id: true, treatmentType: true } },
detail: { select: { id: true, treatmentType: true, toothSelectionGroups: true } },
},
});
@@ -58,19 +58,29 @@ export async function generateLabCaseTasks(
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
// Group teeth that share the same (treatment detail + prosthesis type): one task set
// per group, with each step covering every tooth in that group.
// Group by selection group + prosthesis type so connected spans stay one task set,
// and separate singles stay separate even with the same prosthesis type.
const groups = new Map<
string,
{ treatmentDetailId: string; treatmentType: string; prosthesisTypeCode: string; teeth: string[] }
{
treatmentDetailId: string;
treatmentType: string;
prosthesisTypeCode: string;
selectionGroupId: string;
teeth: string[];
}
>();
for (const row of toothProsthesisRows) {
const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`;
const selectionGroupId =
row.selectionGroupId?.trim() ||
fallbackGroupIdForTooth(row.detail.toothSelectionGroups, row.tooth, row.prosthesisTypeCode);
const key = `${row.treatmentDetailId}::${selectionGroupId}::${row.prosthesisTypeCode}`;
const group = groups.get(key) ?? {
treatmentDetailId: row.treatmentDetailId,
treatmentType: row.detail.treatmentType,
prosthesisTypeCode: row.prosthesisTypeCode,
selectionGroupId,
teeth: [],
};
group.teeth.push(row.tooth);
@@ -94,6 +104,7 @@ export async function generateLabCaseTasks(
teeth,
treatmentType: group.treatmentType,
prosthesisTypeCode: group.prosthesisTypeCode,
selectionGroupId: group.selectionGroupId,
workflowStepCode: step.workflowStepCode,
stepOrder: step.stepOrder,
stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode,
@@ -110,6 +121,24 @@ export async function generateLabCaseTasks(
return taskRows.length;
}
function fallbackGroupIdForTooth(
toothSelectionGroups: unknown,
tooth: string,
prosthesisTypeCode: string,
): string {
if (Array.isArray(toothSelectionGroups)) {
for (const row of toothSelectionGroups) {
if (!row || typeof row !== 'object') continue;
const rec = row as { groupId?: unknown; teeth?: unknown };
if (typeof rec.groupId !== 'string') continue;
if (Array.isArray(rec.teeth) && rec.teeth.includes(tooth)) {
return rec.groupId;
}
}
}
return `legacy-${prosthesisTypeCode}`;
}
function sortTeeth(teeth: string[]): string[] {
return [...new Set(teeth)].sort((a, b) => {
const na = Number(a);

View File

@@ -2,6 +2,7 @@ import {
ArrayMinSize,
IsArray,
IsDateString,
IsIn,
IsOptional,
IsString,
IsUUID,
@@ -10,6 +11,19 @@ import {
} from 'class-validator';
import { Type } from 'class-transformer';
export class ToothSelectionGroupDto {
@IsString()
@MaxLength(64)
groupId: string;
@IsIn(['connected', 'single'])
kind: 'connected' | 'single';
@IsArray()
@IsString({ each: true })
teeth: string[];
}
export class SaveTreatmentDetailDto {
@IsString()
@MaxLength(64)
@@ -27,6 +41,12 @@ export class SaveTreatmentDetailDto {
@IsString({ each: true })
teeth: string[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ToothSelectionGroupDto)
toothSelectionGroups?: ToothSelectionGroupDto[];
@IsOptional()
@IsString()
@MaxLength(5000)
@@ -57,6 +77,11 @@ export class LabCaseToothProsthesisDto {
@IsString()
@MaxLength(64)
prosthesisTypeCode: string;
@IsOptional()
@IsString()
@MaxLength(64)
selectionGroupId?: string;
}
export class SaveLabCaseDto {

View File

@@ -20,6 +20,42 @@ export function normalizeTeeth(teeth: unknown): string[] {
return [...unique].sort();
}
export type ToothSelectionGroupNormalized = {
groupId: string;
kind: 'connected' | 'single';
teeth: string[];
};
export function normalizeToothSelectionGroups(
value: unknown,
fallbackTeeth: string[] = [],
): ToothSelectionGroupNormalized[] {
if (Array.isArray(value) && value.length > 0) {
const out: ToothSelectionGroupNormalized[] = [];
for (const row of value) {
if (!row || typeof row !== 'object') continue;
const rec = row as Record<string, unknown>;
const groupId = typeof rec.groupId === 'string' && rec.groupId.trim() ? rec.groupId.trim() : '';
if (!groupId) continue;
const kind = rec.kind === 'connected' ? 'connected' : 'single';
const teeth = normalizeTeeth(rec.teeth);
if (teeth.length === 0) continue;
out.push({
groupId,
kind: kind === 'connected' && teeth.length < 2 ? 'single' : kind,
teeth,
});
}
if (out.length > 0) return out;
}
return fallbackTeeth.map((tooth, index) => ({
groupId: `legacy-${tooth}-${index}`,
kind: 'single' as const,
teeth: [tooth],
}));
}
export function generateTreatmentTitle(
cases: { treatmentType: string; teeth: string[] }[],
): string {

View File

@@ -30,6 +30,7 @@ import { LabCaseActivityService } from '../notifications/lab-case-activity.servi
import {
generateTreatmentTitle,
normalizeTeeth,
normalizeToothSelectionGroups,
} from './treatment.utils';
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
import { hasEffectivePermission } from '../../common/membership-permissions';
@@ -51,7 +52,7 @@ const sentLabCaseInclude = {
details: {
include: {
detail: {
select: { clientKey: true, treatmentType: true, teeth: true },
select: { clientKey: true, treatmentType: true, teeth: true, toothSelectionGroups: true },
},
},
},
@@ -59,9 +60,9 @@ const sentLabCaseInclude = {
orderBy: [{ sentAt: 'asc' as const }],
include: { organization: { select: { id: true, name: true } } },
},
tasks: { select: { status: true } },
tasks: { select: { status: true, selectionGroupId: true, prosthesisTypeCode: true, teeth: true } },
toothProsthesis: {
select: { tooth: true, prosthesisTypeCode: true },
select: { tooth: true, prosthesisTypeCode: true, selectionGroupId: true },
},
} satisfies Prisma.LabCaseInclude;
@@ -93,7 +94,13 @@ const treatmentInclude = {
details: {
include: {
detail: {
select: { id: true, clientKey: true, treatmentType: true, teeth: true },
select: {
id: true,
clientKey: true,
treatmentType: true,
teeth: true,
toothSelectionGroups: true,
},
},
},
},
@@ -283,17 +290,36 @@ export class TreatmentsService {
const taskProgress = this.mapTaskProgress(lc.tasks);
const labOrg = lc.sends[lc.sends.length - 1]?.organization ?? null;
const detailTeeth = detail ? normalizeTeeth(detail.teeth) : [];
const selectionGroups = normalizeToothSelectionGroups(
detail?.toothSelectionGroups,
detailTeeth,
);
const connectedGroupIds = new Set(
selectionGroups.filter((g) => g.kind === 'connected').map((g) => g.groupId),
);
const prosthesisByCode = new Map<string, string[]>();
const prosthesisByGroup = new Map<
string,
{ prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string; connected: boolean }
>();
for (const row of lc.toothProsthesis ?? []) {
const list = prosthesisByCode.get(row.prosthesisTypeCode) ?? [];
list.push(row.tooth);
prosthesisByCode.set(row.prosthesisTypeCode, list);
const selectionGroupId = row.selectionGroupId || `legacy-${row.prosthesisTypeCode}`;
const key = `${selectionGroupId}::${row.prosthesisTypeCode}`;
const entry = prosthesisByGroup.get(key) ?? {
prosthesisTypeCode: row.prosthesisTypeCode,
teeth: [],
selectionGroupId,
connected: connectedGroupIds.has(row.selectionGroupId),
};
entry.teeth.push(row.tooth);
prosthesisByGroup.set(key, entry);
}
const prosthesisGroups = [...prosthesisByCode.entries()]
.map(([prosthesisTypeCode, teeth]) => ({
prosthesisTypeCode,
teeth: [...new Set(teeth)].sort(),
const prosthesisGroups = [...prosthesisByGroup.values()]
.map((group) => ({
prosthesisTypeCode: group.prosthesisTypeCode,
teeth: [...new Set(group.teeth)].sort(),
connected: group.connected,
selectionGroupId: group.selectionGroupId,
}))
.sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode));
@@ -377,13 +403,21 @@ export class TreatmentsService {
this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType);
}
const normalizedDetails = dto.details.map((d, index) => ({
...d,
sortOrder: index,
teeth: normalizeTeeth(d.teeth),
comment: d.comment?.trim() || null,
attachmentIds: d.attachmentIds ?? [],
}));
const normalizedDetails = dto.details.map((d, index) => {
const teeth = normalizeTeeth(d.teeth);
const toothSelectionGroups = normalizeToothSelectionGroups(
d.toothSelectionGroups,
teeth,
);
return {
...d,
sortOrder: index,
teeth,
toothSelectionGroups,
comment: d.comment?.trim() || null,
attachmentIds: d.attachmentIds ?? [],
};
});
const title = generateTreatmentTitle(
normalizedDetails.map((d) => ({ treatmentType: d.treatmentType, teeth: d.teeth })),
@@ -454,6 +488,7 @@ export class TreatmentsService {
sortOrder: d.sortOrder,
treatmentType: d.treatmentType,
teeth: d.teeth,
toothSelectionGroups: d.toothSelectionGroups,
comment: d.comment,
},
})
@@ -464,6 +499,7 @@ export class TreatmentsService {
sortOrder: d.sortOrder,
treatmentType: d.treatmentType,
teeth: d.teeth,
toothSelectionGroups: d.toothSelectionGroups,
comment: d.comment,
},
});
@@ -642,6 +678,7 @@ export class TreatmentsService {
treatmentDetailId: tp.treatmentDetailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
selectionGroupId: tp.selectionGroupId?.trim() || '',
})),
});
}
@@ -1091,6 +1128,7 @@ export class TreatmentsService {
clientKey?: string | null;
treatmentType: string;
teeth: unknown;
toothSelectionGroups?: unknown;
comment?: string | null;
attachments?: Array<{
id: string;
@@ -1114,11 +1152,13 @@ export class TreatmentsService {
}) {
const labCase = d.labCaseLink?.labCase;
const taskProgress = this.mapTaskProgress(labCase?.tasks ?? []);
const teeth = normalizeTeeth(d.teeth);
return {
id: d.id,
clientId: d.clientKey ?? d.id,
treatmentType: d.treatmentType,
teeth: normalizeTeeth(d.teeth),
teeth,
toothSelectionGroups: normalizeToothSelectionGroups(d.toothSelectionGroups, teeth),
notes: d.comment ?? null,
attachmentMetas: (d.attachments ?? []).map((a) => this.mapAttachment(a)),
labCaseId: labCase?.id ?? null,
@@ -1143,7 +1183,13 @@ export class TreatmentsService {
dueDate?: Date | null;
details?: Array<{
treatmentDetailId: string;
detail?: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown };
detail?: {
id: string;
clientKey: string | null;
treatmentType: string;
teeth: unknown;
toothSelectionGroups?: unknown;
};
}>;
sends?: Array<{
organizationId: string;
@@ -1154,6 +1200,7 @@ export class TreatmentsService {
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
selectionGroupId?: string;
}>;
attachments?: Array<{
attachment: {
@@ -1167,6 +1214,9 @@ export class TreatmentsService {
tasks?: Array<{ id: string; status: LabTaskStatus }>;
}) {
const taskProgress = this.mapTaskProgress(lc.tasks ?? []);
const detailTeeth = lc.details?.[0]?.detail
? normalizeTeeth(lc.details[0].detail.teeth)
: [];
return {
id: lc.id,
clientId: lc.clientKey ?? lc.id,
@@ -1180,13 +1230,18 @@ export class TreatmentsService {
id: lc.details[0].detail?.id ?? lc.details[0].treatmentDetailId,
clientId: lc.details[0].detail?.clientKey ?? lc.details[0].treatmentDetailId,
treatmentType: lc.details[0].detail?.treatmentType ?? '',
teeth: lc.details[0].detail ? normalizeTeeth(lc.details[0].detail.teeth) : [],
teeth: detailTeeth,
toothSelectionGroups: normalizeToothSelectionGroups(
lc.details[0].detail?.toothSelectionGroups,
detailTeeth,
),
}
: null,
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
treatmentDetailId: tp.treatmentDetailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
selectionGroupId: tp.selectionGroupId ?? '',
})),
attachments: (lc.attachments ?? []).map((row) => ({
id: row.attachment.id,