improvement: treatment plan turned into a wizard. shift+click control added to FDI tooth chart for cunnected prosthesises.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user