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