TreatmentType and ProsthesisType database shcema and data updated. Lab dispatch wired through new data.

This commit is contained in:
2026-07-06 20:40:19 +03:30
parent 3e81c110a3
commit 667b08ed0c
48 changed files with 1813 additions and 275 deletions

View File

@@ -21,7 +21,8 @@
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy", "prisma:deploy": "prisma migrate deploy",
"prisma:seed": "prisma db seed" "prisma:seed": "prisma db seed",
"prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts"
}, },
"prisma": { "prisma": {
"seed": "ts-node prisma/seed.ts" "seed": "ts-node prisma/seed.ts"

View File

@@ -0,0 +1,276 @@
import { CatalogEntityKind } from '@prisma/client';
export type CatalogTranslationSeed = {
entityKind: CatalogEntityKind;
entityCode: string;
locale: string;
label: string;
};
export const TREATMENT_TYPES = [
{ code: 'restoration', labDependent: false, sortOrder: 1 },
{ code: 'specialized_restoration', labDependent: false, sortOrder: 2 },
{ code: 'radiography', labDependent: false, sortOrder: 3 },
{ code: 'endo', labDependent: false, sortOrder: 4 },
{ code: 'surgery', labDependent: false, sortOrder: 5 },
{ code: 'prosthesis', labDependent: true, sortOrder: 6 },
{ code: 'implant', labDependent: false, sortOrder: 7 },
{ code: 'orthodontics', labDependent: false, sortOrder: 8 },
{ code: 'perio', labDependent: false, sortOrder: 9 },
{ code: 'pediatrics', labDependent: false, sortOrder: 10 },
{ code: 'extraction', labDependent: false, sortOrder: 11 },
{ code: 'clinic_visit', labDependent: false, sortOrder: 12 },
] as const;
/** Legacy codes kept for historical rows; hidden from catalog. */
export const LEGACY_TREATMENT_TYPES = [
{ code: 'consultation', labDependent: false, sortOrder: 99 },
{ code: 'filling', labDependent: false, sortOrder: 100 },
{ code: 'visit', labDependent: false, sortOrder: 101 },
{ code: 'hygiene', labDependent: false, sortOrder: 102 },
] as const;
export const LAB_WORKFLOW_STEPS = [
{ code: 'intraoral_scan', sortOrder: 1 },
{ code: 'design', sortOrder: 2 },
{ code: 'milling_dry', sortOrder: 3 },
{ code: 'milling_wet', sortOrder: 4 },
{ code: 'printer_resin', sortOrder: 5 },
{ code: 'printer_metal', sortOrder: 6 },
{ code: 'sinter', sortOrder: 7 },
{ code: 'build_up', sortOrder: 8 },
{ code: 'stain', sortOrder: 9 },
{ code: 'glaze', sortOrder: 10 },
{ code: 'polish_prep', sortOrder: 11 },
{ code: 'packing', sortOrder: 12 },
{ code: 'shipping', sortOrder: 13 },
{ code: 'pressing', sortOrder: 14 },
] as const;
export type ProsthesisTypeSeed = {
code: string;
sortOrder: number;
skipPackingShipping?: boolean;
/** Manufacturing steps between design and packing (exclusive of universal scan/design/pack/ship). */
manufacturingSteps: readonly string[];
};
export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
{
code: 'pfm_crown',
sortOrder: 1,
manufacturingSteps: ['milling_wet', 'build_up', 'stain', 'glaze', 'polish_prep'],
},
{
code: 'pfz_crown',
sortOrder: 2,
manufacturingSteps: ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep'],
},
{
code: 'monolithic_zirconia',
sortOrder: 3,
manufacturingSteps: ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep'],
},
{
code: 'glass_ceramic_crown',
sortOrder: 4,
manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'],
},
{
code: 'full_metal_crown',
sortOrder: 5,
manufacturingSteps: ['milling_wet', 'polish_prep'],
},
{
code: 'temporary_resin_crown',
sortOrder: 6,
manufacturingSteps: ['milling_wet', 'polish_prep'],
},
{
code: 'pmma',
sortOrder: 7,
manufacturingSteps: ['milling_dry', 'polish_prep'],
},
{
code: 'peek_crown',
sortOrder: 8,
manufacturingSteps: ['milling_dry', 'polish_prep'],
},
{
code: 'veneer_zirconia',
sortOrder: 9,
manufacturingSteps: ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep'],
},
{
code: 'veneer_ips_press',
sortOrder: 10,
manufacturingSteps: [
'printer_resin',
'build_up',
'stain',
'glaze',
'polish_prep',
'pressing',
],
},
{
code: 'veneer_ips_cad',
sortOrder: 11,
manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'],
},
{
code: 'soft_structure',
sortOrder: 12,
manufacturingSteps: ['milling_dry', 'sinter'],
},
{
code: 'customized_abutment',
sortOrder: 13,
manufacturingSteps: ['milling_wet', 'polish_prep'],
},
{
code: 'prefabricated_abutment',
sortOrder: 14,
manufacturingSteps: ['polish_prep'],
},
{
code: 'ti_base_abutment',
sortOrder: 15,
manufacturingSteps: ['polish_prep'],
},
{
code: 'multi_unit_abutment',
sortOrder: 16,
manufacturingSteps: ['polish_prep'],
},
{
code: 'zirconia_abutment',
sortOrder: 17,
manufacturingSteps: ['milling_dry', 'sinter', 'polish_prep'],
},
{
code: 'screw_retained',
sortOrder: 18,
manufacturingSteps: [
'milling_wet',
'printer_metal',
'sinter',
'build_up',
'stain',
'glaze',
'polish_prep',
],
},
{
code: 'zirconia_overlay',
sortOrder: 19,
manufacturingSteps: ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep'],
},
{
code: 'ips_overlay',
sortOrder: 20,
manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'],
},
{
code: 'smile_design',
sortOrder: 21,
skipPackingShipping: true,
manufacturingSteps: ['printer_resin'],
},
{
code: 'mockup',
sortOrder: 22,
manufacturingSteps: ['printer_resin'],
},
];
const UNIVERSAL_PREFIX = ['intraoral_scan', 'design'] as const;
const UNIVERSAL_SUFFIX = ['packing', 'shipping'] as const;
export function buildProsthesisStepCodes(type: ProsthesisTypeSeed): string[] {
const steps = [...UNIVERSAL_PREFIX, ...type.manufacturingSteps];
if (!type.skipPackingShipping) {
steps.push(...UNIVERSAL_SUFFIX);
}
return steps;
}
const TREATMENT_LABELS: Record<string, Record<string, string>> = {
restoration: { en: 'Restoration', fa: 'ترمیم', nl: 'Restauratie' },
specialized_restoration: { en: 'Specialized Restoration', fa: 'ترمیم تخصصی', nl: 'Gespecialiseerde Restauratie' },
radiography: { en: 'Radiography', fa: 'رادیوگرافی', nl: 'Röntgen' },
endo: { en: 'Endo', fa: 'اندو', nl: 'Endo' },
surgery: { en: 'Surgery', fa: 'جراحی', nl: 'Chirurgie' },
prosthesis: { en: 'Prosthesis', fa: 'پروتز', nl: 'Prothese' },
implant: { en: 'Implant', fa: 'ایمپلنت', nl: 'Implantaat' },
orthodontics: { en: 'Orthodontics', fa: 'ارتودنسی', nl: 'Orthodontie' },
perio: { en: 'Perio', fa: 'پریو', nl: 'Paro' },
pediatrics: { en: 'Pediatrics', fa: 'اطفال', nl: 'Kinderen' },
extraction: { en: 'Extraction', fa: 'کشیدن', nl: 'Extractie' },
clinic_visit: { en: 'Clinic Visit', fa: 'درمانگاه', nl: 'Kliniekbezoek' },
consultation: { en: 'Consultation', fa: 'مشاوره', nl: 'Consult' },
filling: { en: 'Filling', fa: 'پر کردن', nl: 'Vulling' },
visit: { en: 'Visit', fa: 'ویزیت', nl: 'Bezoek' },
hygiene: { en: 'Hygiene', fa: 'بهداشت', nl: 'Hygiëne' },
};
const PROSTHESIS_LABELS: Record<string, Record<string, string>> = {
pfm_crown: { en: 'PFM Crown', fa: 'روکش PFM', nl: 'PFM Kroon' },
pfz_crown: { en: 'PFZ Crown', fa: 'روکش PFZ', nl: 'PFZ Kroon' },
monolithic_zirconia: { en: 'Monolithic Zirconia', fa: 'زیرکونیا مونولیتیک', nl: 'Monolithisch Zirconia' },
glass_ceramic_crown: { en: 'Glass Ceramic Crown', fa: 'روکش سرامیک شیشه‌ای', nl: 'Glaskeramische Kroon' },
full_metal_crown: { en: 'Full Metal Crown', fa: 'روکش تمام فلز', nl: 'Volledige Metalen Kroon' },
temporary_resin_crown: { en: 'Temporary Resin Crown', fa: 'روکش موقت رزینی', nl: 'Tijdelijke Harskroon' },
pmma: { en: 'PMMA', fa: 'PMMA', nl: 'PMMA' },
peek_crown: { en: 'PEEK Crown', fa: 'روکش PEEK', nl: 'PEEK Kroon' },
veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' },
veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' },
veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' },
soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' },
customized_abutment: { en: 'Customized Abutment', fa: 'اباتمنت سفارشی', nl: 'Aangepast Abutment' },
prefabricated_abutment: { en: 'Prefabricated Abutment', fa: 'اباتمنت آماده', nl: 'Prefab Abutment' },
ti_base_abutment: { en: 'Ti Base Abutment', fa: 'اباتمنت پایه تیتانیوم', nl: 'Ti Basis Abutment' },
multi_unit_abutment: { en: 'Multi Unit Abutment', fa: 'اباتمنت مولتی یونیت', nl: 'Multi Unit Abutment' },
zirconia_abutment: { en: 'Zirconia Abutment', fa: 'اباتمنت زیرکونیا', nl: 'Zirconia Abutment' },
screw_retained: { en: 'Screw Retained', fa: 'پیچی', nl: 'Schroefgehouden' },
zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' },
ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' },
smile_design: { en: 'Smile Design', fa: 'طراحی لبخند', nl: 'Smile Design' },
mockup: { en: 'Mockup', fa: 'ماکاپ', nl: 'Mockup' },
};
const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
intraoral_scan: { en: 'Intraoral Scan', fa: 'اسکن داخل دهان', nl: 'Intraorale Scan' },
design: { en: 'Design', fa: 'طراحی', nl: 'Ontwerp' },
milling_dry: { en: 'Milling Dry', fa: 'فرز خشک', nl: 'Droog Frezen' },
milling_wet: { en: 'Milling Wet', fa: 'فرز تر', nl: 'Nat Frezen' },
printer_resin: { en: 'Printer Resin', fa: 'پرینتر رزین', nl: 'Harsprinter' },
printer_metal: { en: 'Printer Metal', fa: 'پرینتر فلز', nl: 'Metaalprinter' },
sinter: { en: 'Sinter', fa: 'سینتر', nl: 'Sinteren' },
build_up: { en: 'Build Up', fa: 'بیلدآپ', nl: 'Opbouw' },
stain: { en: 'Stain', fa: 'رنگ‌آمیزی', nl: 'Kleuren' },
glaze: { en: 'Glaze', fa: 'گلیز', nl: 'Glazuur' },
polish_prep: { en: 'Polish/Prep', fa: 'پولیش/آماده‌سازی', nl: 'Polijsten/Voorbereiding' },
packing: { en: 'Packing', fa: 'بسته‌بندی', nl: 'Verpakken' },
shipping: { en: 'Shipping', fa: 'ارسال', nl: 'Verzending' },
pressing: { en: 'Pressing', fa: 'پرس', nl: 'Persen' },
};
function labelsToTranslations(
entityKind: CatalogEntityKind,
labels: Record<string, Record<string, string>>,
): CatalogTranslationSeed[] {
const out: CatalogTranslationSeed[] = [];
for (const [entityCode, locales] of Object.entries(labels)) {
for (const [locale, label] of Object.entries(locales)) {
out.push({ entityKind, entityCode, locale, label });
}
}
return out;
}
export const CATALOG_TRANSLATIONS: CatalogTranslationSeed[] = [
...labelsToTranslations(CatalogEntityKind.TREATMENT_TYPE, TREATMENT_LABELS),
...labelsToTranslations(CatalogEntityKind.PROSTHESIS_TYPE, PROSTHESIS_LABELS),
...labelsToTranslations(CatalogEntityKind.LAB_WORKFLOW_STEP, WORKFLOW_STEP_LABELS),
];

View File

@@ -0,0 +1,95 @@
-- Prosthesis catalog refactor: drop treatment workflow steps, add prosthesis catalog tables
CREATE TYPE "CatalogEntityKind" AS ENUM ('TREATMENT_TYPE', 'PROSTHESIS_TYPE', 'LAB_WORKFLOW_STEP');
ALTER TABLE "treatment_types" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true;
DROP TABLE IF EXISTS "treatment_workflow_steps";
CREATE TABLE "catalog_translations" (
"id" TEXT NOT NULL,
"entityKind" "CatalogEntityKind" NOT NULL,
"entityCode" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"label" TEXT NOT NULL,
CONSTRAINT "catalog_translations_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "catalog_translations_entityKind_entityCode_locale_key"
ON "catalog_translations"("entityKind", "entityCode", "locale");
CREATE TABLE "prosthesis_types" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"skipPackingShipping" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "prosthesis_types_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "prosthesis_types_code_key" ON "prosthesis_types"("code");
CREATE TABLE "lab_workflow_steps" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "lab_workflow_steps_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "lab_workflow_steps_code_key" ON "lab_workflow_steps"("code");
CREATE TABLE "prosthesis_type_steps" (
"id" TEXT NOT NULL,
"prosthesisTypeId" TEXT NOT NULL,
"labWorkflowStepId" TEXT NOT NULL,
"stepOrder" INTEGER NOT NULL,
CONSTRAINT "prosthesis_type_steps_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "prosthesis_type_steps_prosthesisTypeId_stepOrder_key"
ON "prosthesis_type_steps"("prosthesisTypeId", "stepOrder");
CREATE UNIQUE INDEX "prosthesis_type_steps_prosthesisTypeId_labWorkflowStepId_key"
ON "prosthesis_type_steps"("prosthesisTypeId", "labWorkflowStepId");
ALTER TABLE "prosthesis_type_steps"
ADD CONSTRAINT "prosthesis_type_steps_prosthesisTypeId_fkey"
FOREIGN KEY ("prosthesisTypeId") REFERENCES "prosthesis_types"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "prosthesis_type_steps"
ADD CONSTRAINT "prosthesis_type_steps_labWorkflowStepId_fkey"
FOREIGN KEY ("labWorkflowStepId") REFERENCES "lab_workflow_steps"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TABLE "lab_case_tooth_prosthesis" (
"id" TEXT NOT NULL,
"labCaseId" TEXT NOT NULL,
"treatmentDetailId" TEXT NOT NULL,
"tooth" TEXT NOT NULL,
"prosthesisTypeCode" TEXT NOT NULL,
CONSTRAINT "lab_case_tooth_prosthesis_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "lab_case_tooth_prosthesis_labCaseId_treatmentDetailId_tooth_key"
ON "lab_case_tooth_prosthesis"("labCaseId", "treatmentDetailId", "tooth");
ALTER TABLE "lab_case_tooth_prosthesis"
ADD CONSTRAINT "lab_case_tooth_prosthesis_labCaseId_fkey"
FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "lab_case_tooth_prosthesis"
ADD CONSTRAINT "lab_case_tooth_prosthesis_treatmentDetailId_fkey"
FOREIGN KEY ("treatmentDetailId") REFERENCES "treatment_details"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Lab case tasks: add prosthesis fields and update unique constraint
ALTER TABLE "lab_case_tasks" ADD COLUMN "prosthesisTypeCode" TEXT NOT NULL DEFAULT '';
ALTER TABLE "lab_case_tasks" ADD COLUMN "workflowStepCode" TEXT NOT NULL DEFAULT '';
DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_tooth_treatmentType_stepOrder_key";
CREATE UNIQUE INDEX "lab_case_tasks_labCaseId_treatmentDetailId_tooth_stepOrder_key"
ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "tooth", "stepOrder");

View File

@@ -0,0 +1,41 @@
/**
* Dev-only: truncate treatment and lab case data (preserves catalog tables).
* Usage: npx ts-node prisma/reset-treatment-data.ts
*/
import { PrismaClient } from '@prisma/client';
import { config } from 'dotenv';
import path from 'path';
const envPath = path.join(__dirname, '..', '.env');
config({ path: envPath });
if (process.env.NODE_ENV === 'production') {
console.error('reset-treatment-data is not allowed in production');
process.exit(1);
}
const prisma = new PrismaClient();
async function main() {
console.log('Truncating treatment and lab case data...');
await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_tasks" CASCADE');
await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_sends" CASCADE');
await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_tooth_prosthesis" CASCADE');
await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_details" CASCADE');
await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_cases" CASCADE');
await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatment_detail_attachments" CASCADE');
await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatment_details" CASCADE');
await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatments" CASCADE');
console.log('Done.');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -155,6 +155,7 @@ model TreatmentDetail {
attachments TreatmentDetailAttachment[] attachments TreatmentDetailAttachment[]
labCaseLink LabCaseDetail? labCaseLink LabCaseDetail?
labCaseTasks LabCaseTask[] labCaseTasks LabCaseTask[]
toothProsthesis LabCaseToothProsthesis[]
@@index([treatmentId, sortOrder]) @@index([treatmentId, sortOrder])
@@map("treatment_details") @@map("treatment_details")
@@ -188,10 +189,11 @@ model LabCase {
labComment String? labComment String?
sentAt DateTime? sentAt DateTime?
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
details LabCaseDetail[] details LabCaseDetail[]
sends LabCaseSend[] sends LabCaseSend[]
tasks LabCaseTask[] tasks LabCaseTask[]
toothProsthesis LabCaseToothProsthesis[]
@@index([treatmentId, sortOrder]) @@index([treatmentId, sortOrder])
@@map("lab_cases") @@map("lab_cases")
@@ -226,36 +228,92 @@ model TreatmentType {
code String @unique code String @unique
labDependent Boolean @default(false) labDependent Boolean @default(false)
sortOrder Int @default(0) sortOrder Int @default(0)
isActive Boolean @default(true)
workflowSteps TreatmentWorkflowStep[]
@@map("treatment_types") @@map("treatment_types")
} }
model TreatmentWorkflowStep { enum CatalogEntityKind {
id String @id @default(uuid()) TREATMENT_TYPE
treatmentTypeId String PROSTHESIS_TYPE
stepOrder Int LAB_WORKFLOW_STEP
label String }
treatmentType TreatmentType @relation(fields: [treatmentTypeId], references: [id], onDelete: Cascade) model CatalogTranslation {
id String @id @default(uuid())
entityKind CatalogEntityKind
entityCode String
locale String
label String
@@unique([treatmentTypeId, stepOrder]) @@unique([entityKind, entityCode, locale])
@@map("treatment_workflow_steps") @@map("catalog_translations")
}
model ProsthesisType {
id String @id @default(uuid())
code String @unique
sortOrder Int @default(0)
isActive Boolean @default(true)
skipPackingShipping Boolean @default(false)
steps ProsthesisTypeStep[]
@@map("prosthesis_types")
}
model LabWorkflowStep {
id String @id @default(uuid())
code String @unique
sortOrder Int @default(0)
prosthesisSteps ProsthesisTypeStep[]
@@map("lab_workflow_steps")
}
model ProsthesisTypeStep {
id String @id @default(uuid())
prosthesisTypeId String
labWorkflowStepId String
stepOrder Int
prosthesisType ProsthesisType @relation(fields: [prosthesisTypeId], references: [id], onDelete: Cascade)
labWorkflowStep LabWorkflowStep @relation(fields: [labWorkflowStepId], references: [id], onDelete: Cascade)
@@unique([prosthesisTypeId, stepOrder])
@@unique([prosthesisTypeId, labWorkflowStepId])
@@map("prosthesis_type_steps")
}
model LabCaseToothProsthesis {
id String @id @default(uuid())
labCaseId String
treatmentDetailId String
tooth String
prosthesisTypeCode String
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
@@unique([labCaseId, treatmentDetailId, tooth])
@@map("lab_case_tooth_prosthesis")
} }
model LabCaseTask { model LabCaseTask {
id String @id @default(uuid()) id String @id @default(uuid())
labCaseId String labCaseId String
treatmentDetailId String treatmentDetailId String
tooth String tooth String
treatmentType String treatmentType String
stepOrder Int prosthesisTypeCode String
stepLabel String workflowStepCode String
assigneeUserId String? stepOrder Int
assignedAt DateTime? stepLabel String
priority Int @default(3) assigneeUserId String?
status LabTaskStatus @default(PENDING) assignedAt DateTime?
priority Int @default(3)
status LabTaskStatus @default(PENDING)
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
@@ -264,7 +322,7 @@ model LabCaseTask {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([labCaseId, tooth, treatmentType, stepOrder]) @@unique([labCaseId, treatmentDetailId, tooth, stepOrder])
@@index([labCaseId, status]) @@index([labCaseId, status])
@@index([assigneeUserId, priority, createdAt]) @@index([assigneeUserId, priority, createdAt])
@@index([assignedAt, labCaseId, priority]) @@index([assignedAt, labCaseId, priority])

View File

@@ -3,6 +3,14 @@ import { PrismaClient } from '@prisma/client';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { config } from 'dotenv'; import { config } from 'dotenv';
import path from 'path'; import path from 'path';
import {
TREATMENT_TYPES,
LEGACY_TREATMENT_TYPES,
LAB_WORKFLOW_STEPS,
PROSTHESIS_TYPES,
CATALOG_TRANSLATIONS,
buildProsthesisStepCodes,
} from './catalog-seed-data';
// Load environment variables from the correct path // Load environment variables from the correct path
const envPath = path.join(__dirname, '..', '.env'); const envPath = path.join(__dirname, '..', '.env');
@@ -128,57 +136,118 @@ async function main() {
} }
console.log('✅ Created features and permissions'); console.log('✅ Created features and permissions');
const workflowSteps = [ for (const type of [...TREATMENT_TYPES, ...LEGACY_TREATMENT_TYPES]) {
{ code: 'endo', stepOrder: 1, label: 'Access review' }, const isActive = TREATMENT_TYPES.some((t) => t.code === type.code);
{ code: 'endo', stepOrder: 2, label: 'Fabrication' },
] as const;
const treatmentTypes = [
{ code: 'consultation', labDependent: false, sortOrder: 1 },
{ code: 'filling', labDependent: false, sortOrder: 2 },
{ code: 'endo', labDependent: true, sortOrder: 3 },
{ code: 'visit', labDependent: false, sortOrder: 4 },
{ code: 'hygiene', labDependent: false, sortOrder: 5 },
] as const;
for (const type of treatmentTypes) {
await prisma.treatmentType.upsert({ await prisma.treatmentType.upsert({
where: { code: type.code }, where: { code: type.code },
update: { labDependent: type.labDependent, sortOrder: type.sortOrder }, update: {
labDependent: type.labDependent,
sortOrder: type.sortOrder,
isActive,
},
create: { create: {
id: randomUUID(), id: randomUUID(),
code: type.code, code: type.code,
labDependent: type.labDependent, labDependent: type.labDependent,
sortOrder: type.sortOrder, sortOrder: type.sortOrder,
isActive,
}, },
}); });
} }
console.log('✅ Seeded treatment type catalog'); console.log('✅ Seeded treatment type catalog');
for (const step of workflowSteps) { for (const step of LAB_WORKFLOW_STEPS) {
const treatmentType = await prisma.treatmentType.findUniqueOrThrow({ await prisma.labWorkflowStep.upsert({
where: { code: step.code }, where: { code: step.code },
select: { id: true }, update: { sortOrder: step.sortOrder },
});
await prisma.treatmentWorkflowStep.upsert({
where: {
treatmentTypeId_stepOrder: {
treatmentTypeId: treatmentType.id,
stepOrder: step.stepOrder,
},
},
update: { label: step.label },
create: { create: {
id: randomUUID(), id: randomUUID(),
treatmentTypeId: treatmentType.id, code: step.code,
stepOrder: step.stepOrder, sortOrder: step.sortOrder,
label: step.label,
}, },
}); });
} }
console.log('✅ Seeded lab workflow steps'); console.log('✅ Seeded lab workflow steps');
const workflowStepByCode = new Map(
(
await prisma.labWorkflowStep.findMany({
select: { id: true, code: true },
})
).map((s) => [s.code, s.id]),
);
for (const type of PROSTHESIS_TYPES) {
const prosthesisType = await prisma.prosthesisType.upsert({
where: { code: type.code },
update: {
sortOrder: type.sortOrder,
skipPackingShipping: type.skipPackingShipping ?? false,
isActive: true,
},
create: {
id: randomUUID(),
code: type.code,
sortOrder: type.sortOrder,
skipPackingShipping: type.skipPackingShipping ?? false,
isActive: true,
},
});
const stepCodes = buildProsthesisStepCodes(type);
for (const [index, stepCode] of stepCodes.entries()) {
const labWorkflowStepId = workflowStepByCode.get(stepCode);
if (!labWorkflowStepId) {
throw new Error(`Unknown workflow step code: ${stepCode}`);
}
await prisma.prosthesisTypeStep.upsert({
where: {
prosthesisTypeId_stepOrder: {
prosthesisTypeId: prosthesisType.id,
stepOrder: index + 1,
},
},
update: { labWorkflowStepId },
create: {
id: randomUUID(),
prosthesisTypeId: prosthesisType.id,
labWorkflowStepId,
stepOrder: index + 1,
},
});
}
}
console.log('✅ Seeded prosthesis types and workflow mappings');
for (const tr of CATALOG_TRANSLATIONS) {
const existing = await prisma.catalogTranslation.findFirst({
where: {
entityKind: tr.entityKind,
entityCode: tr.entityCode,
locale: tr.locale,
},
});
if (existing) {
await prisma.catalogTranslation.update({
where: { id: existing.id },
data: { label: tr.label },
});
} else {
await prisma.catalogTranslation.create({
data: {
id: randomUUID(),
entityKind: tr.entityKind,
entityCode: tr.entityCode,
locale: tr.locale,
label: tr.label,
},
});
}
}
console.log('✅ Seeded catalog translations');
console.log('🌱 Seeding completed successfully!'); console.log('🌱 Seeding completed successfully!');
} }

View File

@@ -14,6 +14,8 @@ import { TreatmentsModule } from './modules/treatments/treatments.module';
import { CasesModule } from './modules/cases/cases.module'; import { CasesModule } from './modules/cases/cases.module';
import { TasksModule } from './modules/tasks/tasks.module'; import { TasksModule } from './modules/tasks/tasks.module';
import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module'; import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module';
import { CatalogModule } from './modules/catalog/catalog.module';
import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module';
@Module({ @Module({
imports: [ imports: [
@@ -22,7 +24,9 @@ import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-ca
load: [configurations], load: [configurations],
}), }),
PrismaModule, // ✅ ADD THIS PrismaModule, // ✅ ADD THIS
CatalogModule,
TreatmentCatalogModule, TreatmentCatalogModule,
ProsthesisCatalogModule,
AuthModule, AuthModule,
PatientsModule, PatientsModule,
AppointmentsModule, AppointmentsModule,

View File

@@ -46,7 +46,7 @@ export class CasesController {
@ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' }) @ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' })
getOne(@Param('id') id: string, @Req() req) { getOne(@Param('id') id: string, @Req() req) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user); const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.getOne(id, organizationId, req.user.id); return this.casesService.getOne(id, organizationId, req.user.id, req.user.language);
} }
@Patch(':id/tasks/:taskId') @Patch(':id/tasks/:taskId')
@@ -58,6 +58,6 @@ export class CasesController {
@Req() req, @Req() req,
) { ) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user); const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id); return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id, req.user.language);
} }
} }

View File

@@ -4,9 +4,13 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { LabTaskStatus, Prisma } from '@prisma/client'; import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone'; import { normalizeMobile } from '../../common/phone';
import {
CatalogLabelService,
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { normalizeTeeth } from '../treatments/treatment.utils'; import { normalizeTeeth } from '../treatments/treatment.utils';
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto'; import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
@@ -52,6 +56,7 @@ export class CasesService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly treatmentCatalog: TreatmentCatalogService, private readonly treatmentCatalog: TreatmentCatalogService,
private readonly catalogLabels: CatalogLabelService,
) {} ) {}
getOrganizationIdFromUser(user: { organizationId?: string }) { getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -142,8 +147,8 @@ export class CasesService {
} }
} }
const treatmentTypes = this.treatmentCatalog const catalog = await this.treatmentCatalog.list();
.list() const treatmentTypes = catalog
.filter((entry) => entry.labDependent && typeCodes.has(entry.code)) .filter((entry) => entry.labDependent && typeCodes.has(entry.code))
.map((entry) => ({ code: entry.code, labDependent: entry.labDependent })); .map((entry) => ({ code: entry.code, labDependent: entry.labDependent }));
@@ -218,6 +223,7 @@ export class CasesService {
labCaseId: string, labCaseId: string,
clinicOrganizationId: string, clinicOrganizationId: string,
labOrganizationId: string, labOrganizationId: string,
localeInput?: string | null,
) { ) {
const labCase = await this.prisma.labCase.findFirst({ const labCase = await this.prisma.labCase.findFirst({
where: { where: {
@@ -233,10 +239,18 @@ export class CasesService {
throw new NotFoundException('Case not found'); throw new NotFoundException('Case not found');
} }
return { success: true, data: this.mapLabCaseDetail(labCase) }; return {
success: true,
data: await this.mapLabCaseDetail(labCase, localeInput),
};
} }
async getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) { async getOne(
labCaseId: string,
labOrganizationId: string,
actorUserId: string,
localeInput?: string | null,
) {
await this.assertCanReadCases(actorUserId, labOrganizationId); await this.assertCanReadCases(actorUserId, labOrganizationId);
const labCase = await this.prisma.labCase.findFirst({ const labCase = await this.prisma.labCase.findFirst({
@@ -252,7 +266,7 @@ export class CasesService {
throw new NotFoundException('Case not found'); throw new NotFoundException('Case not found');
} }
return { success: true, data: this.mapLabCaseDetail(labCase) }; return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
} }
async updateTask( async updateTask(
@@ -261,6 +275,7 @@ export class CasesService {
dto: UpdateLabCaseTaskDto, dto: UpdateLabCaseTaskDto,
labOrganizationId: string, labOrganizationId: string,
actorUserId: string, actorUserId: string,
localeInput?: string | null,
) { ) {
await this.assertCanEditCases(actorUserId, labOrganizationId); await this.assertCanEditCases(actorUserId, labOrganizationId);
@@ -299,7 +314,14 @@ export class CasesService {
}, },
}); });
return { success: true, data: this.mapTask(updated) }; const locale = normalizeCatalogLocale(localeInput);
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
[updated.prosthesisTypeCode],
locale,
);
return { success: true, data: this.mapTask(updated, prosthesisLabels) };
} }
async listAssignableMembers(labOrganizationId: string, actorUserId: string) { async listAssignableMembers(labOrganizationId: string, actorUserId: string) {
@@ -431,9 +453,19 @@ export class CasesService {
}; };
} }
private mapLabCaseDetail(lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>) { private async mapLabCaseDetail(
lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>,
localeInput?: string | null,
) {
const locale = normalizeCatalogLocale(localeInput);
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))]; const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
const tasksByTooth = this.groupTasksByTooth(lc.tasks); const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))];
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
locale,
);
const tasksByTooth = this.groupTasksByTooth(lc.tasks, prosthesisLabels);
return { return {
id: lc.id, id: lc.id,
@@ -454,7 +486,7 @@ export class CasesService {
organizationName: s.organization.name, organizationName: s.organization.name,
sentAt: s.sentAt.toISOString(), sentAt: s.sentAt.toISOString(),
})), })),
tasks: lc.tasks.map((t) => this.mapTask(t)), tasks: lc.tasks.map((t) => this.mapTask(t, prosthesisLabels)),
tasksByTooth, tasksByTooth,
taskProgress: { taskProgress: {
completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length, completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length,
@@ -468,6 +500,7 @@ export class CasesService {
id: string; id: string;
tooth: string; tooth: string;
treatmentType: string; treatmentType: string;
prosthesisTypeCode: string;
stepOrder: number; stepOrder: number;
stepLabel: string; stepLabel: string;
status: LabTaskStatus; status: LabTaskStatus;
@@ -477,47 +510,62 @@ export class CasesService {
createdAt: Date; createdAt: Date;
assignee: { id: string; name: string; email: string } | null; assignee: { id: string; name: string; email: string } | null;
}>, }>,
prosthesisLabels: Map<string, string>,
) { ) {
const groups = new Map< const groups = new Map<
string, string,
{ {
tooth: string; tooth: string;
treatmentType: string; treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
tasks: ReturnType<CasesService['mapTask']>[]; tasks: ReturnType<CasesService['mapTask']>[];
} }
>(); >();
for (const task of tasks) { for (const task of tasks) {
const key = `${task.tooth}:${task.treatmentType}`; const key = `${task.tooth}:${task.treatmentType}:${task.prosthesisTypeCode}`;
const entry = groups.get(key) ?? { const entry = groups.get(key) ?? {
tooth: task.tooth, tooth: task.tooth,
treatmentType: task.treatmentType, treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
tasks: [], tasks: [],
}; };
entry.tasks.push(this.mapTask(task)); entry.tasks.push(this.mapTask(task, prosthesisLabels));
groups.set(key, entry); groups.set(key, entry);
} }
return [...groups.values()]; return [...groups.values()];
} }
private mapTask(task: { private mapTask(
id: string; task: {
tooth: string; id: string;
treatmentType: string; tooth: string;
stepOrder: number; treatmentType: string;
stepLabel: string; prosthesisTypeCode: string;
status: LabTaskStatus; workflowStepCode?: string;
priority: number; stepOrder: number;
assigneeUserId: string | null; stepLabel: string;
assignedAt: Date | null; status: LabTaskStatus;
createdAt: Date; priority: number;
assignee: { id: string; name: string; email: string } | null; assigneeUserId: string | null;
}) { assignedAt: Date | null;
createdAt: Date;
assignee: { id: string; name: string; email: string } | null;
},
prosthesisLabels: Map<string, string>,
) {
return { return {
id: task.id, id: task.id,
tooth: task.tooth, tooth: task.tooth,
treatmentType: task.treatmentType, treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
workflowStepCode: task.workflowStepCode ?? '',
stepOrder: task.stepOrder, stepOrder: task.stepOrder,
stepLabel: task.stepLabel, stepLabel: task.stepLabel,
status: task.status, status: task.status,

View File

@@ -0,0 +1,154 @@
import { CatalogEntityKind } from '@prisma/client';
import {
PROSTHESIS_TYPES,
buildProsthesisStepCodes,
} from '../../../prisma/catalog-seed-data';
import { generateLabCaseTasks } from './lab-case-task.generator';
function buildMockTx(options: {
existingCount?: number;
toothProsthesisRows: Array<{
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
treatmentType?: string;
}>;
prosthesisTypes: Array<{
code: string;
steps: Array<{ stepOrder: number; workflowStepCode: string }>;
}>;
stepLabels?: Record<string, string>;
}) {
const created: unknown[] = [];
const tx = {
labCaseTask: {
count: jest.fn().mockResolvedValue(options.existingCount ?? 0),
createMany: jest.fn().mockImplementation(({ data }) => {
created.push(...data);
return { count: data.length };
}),
},
labCaseToothProsthesis: {
findMany: jest.fn().mockResolvedValue(
options.toothProsthesisRows.map((row) => ({
treatmentDetailId: row.treatmentDetailId,
tooth: row.tooth,
prosthesisTypeCode: row.prosthesisTypeCode,
detail: {
id: row.treatmentDetailId,
treatmentType: row.treatmentType ?? 'prosthesis',
},
})),
),
},
prosthesisType: {
findMany: jest.fn().mockResolvedValue(
options.prosthesisTypes.map((type) => ({
code: type.code,
isActive: true,
steps: type.steps.map((step) => ({
stepOrder: step.stepOrder,
labWorkflowStep: { code: step.workflowStepCode },
})),
})),
),
},
catalogTranslation: {
findMany: jest.fn().mockImplementation(({ where }) => {
const codes = where.entityCode?.in ?? [];
return codes.map((code: string) => ({
entityCode: code,
locale: 'en',
label: options.stepLabels?.[code] ?? code,
entityKind: CatalogEntityKind.LAB_WORKFLOW_STEP,
}));
}),
},
};
return { tx, created };
}
function stepsFromSeed(code: string) {
const seed = PROSTHESIS_TYPES.find((type) => type.code === code);
if (!seed) {
throw new Error(`Unknown prosthesis code: ${code}`);
}
return buildProsthesisStepCodes(seed).map((workflowStepCode, index) => ({
stepOrder: index + 1,
workflowStepCode,
}));
}
describe('generateLabCaseTasks', () => {
it('creates tasks for pfm_crown with universal and type-specific steps', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '14',
prosthesisTypeCode: 'pfm_crown',
},
],
prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }],
stepLabels: { intraoral_scan: 'Intraoral Scan', packing: 'Packing' },
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-1', 'en');
expect(count).toBe(pfmSteps.length);
expect(created).toHaveLength(pfmSteps.length);
expect(created[0]).toMatchObject({
tooth: '14',
prosthesisTypeCode: 'pfm_crown',
workflowStepCode: 'intraoral_scan',
stepLabel: 'Intraoral Scan',
});
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes).toEqual(pfmSteps.map((step) => step.workflowStepCode));
expect(stepCodes).toContain('packing');
expect(stepCodes).toContain('shipping');
expect(stepCodes).toContain('milling_wet');
});
it('omits packing and shipping for smile_design', async () => {
const smileSteps = stepsFromSeed('smile_design');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '11',
prosthesisTypeCode: 'smile_design',
},
],
prosthesisTypes: [{ code: 'smile_design', steps: smileSteps }],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-2', 'en');
expect(count).toBe(smileSteps.length);
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes).not.toContain('packing');
expect(stepCodes).not.toContain('shipping');
expect(stepCodes).toContain('printer_resin');
});
it('skips generation when tasks already exist', async () => {
const { tx } = buildMockTx({
existingCount: 3,
toothProsthesisRows: [],
prosthesisTypes: [],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-3', 'en');
expect(count).toBe(0);
expect(tx.labCaseTask.createMany).not.toHaveBeenCalled();
});
});

View File

@@ -1,84 +1,83 @@
import { LabTaskStatus, Prisma } from '@prisma/client'; import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { normalizeTeeth } from '../treatments/treatment.utils'; import { normalizeCatalogLocale } from '../catalog/catalog-label.service';
type TransactionClient = Prisma.TransactionClient; type TransactionClient = Prisma.TransactionClient;
export async function generateLabCaseTasks( export async function generateLabCaseTasks(
tx: TransactionClient, tx: TransactionClient,
labCaseId: string, labCaseId: string,
localeInput?: string | null,
): Promise<number> { ): Promise<number> {
const existingCount = await tx.labCaseTask.count({ where: { labCaseId } }); const existingCount = await tx.labCaseTask.count({ where: { labCaseId } });
if (existingCount > 0) { if (existingCount > 0) {
return 0; return 0;
} }
const labCase = await tx.labCase.findUnique({ const locale = normalizeCatalogLocale(localeInput);
where: { id: labCaseId },
const toothProsthesisRows = await tx.labCaseToothProsthesis.findMany({
where: { labCaseId },
include: { include: {
details: { detail: { select: { id: true, treatmentType: true } },
include: { },
detail: { });
select: { id: true, treatmentType: true, teeth: true },
}, if (toothProsthesisRows.length === 0) {
}, return 0;
}
const prosthesisCodes = [...new Set(toothProsthesisRows.map((r) => r.prosthesisTypeCode))];
const prosthesisTypes = await tx.prosthesisType.findMany({
where: { code: { in: prosthesisCodes }, isActive: true },
include: {
steps: {
orderBy: { stepOrder: 'asc' },
include: { labWorkflowStep: { select: { code: true } } },
}, },
}, },
}); });
if (!labCase?.details.length) { const stepsByProsthesisCode = new Map(
return 0; prosthesisTypes.map((type) => [
} type.code,
type.steps.map((s) => ({
stepOrder: s.stepOrder,
workflowStepCode: s.labWorkflowStep.code,
})),
]),
);
const treatmentTypeCodes = [...new Set(labCase.details.map((d) => d.detail.treatmentType))]; const allStepCodes = [
...new Set(
prosthesisTypes.flatMap((type) =>
type.steps.map((s) => s.labWorkflowStep.code),
),
),
];
const labDependentTypes = await tx.treatmentType.findMany({ const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
where: { code: { in: treatmentTypeCodes }, labDependent: true },
select: { id: true, code: true },
});
if (labDependentTypes.length === 0) {
return 0;
}
const labDependentCodes = new Set(labDependentTypes.map((t) => t.code));
const workflowSteps = await tx.treatmentWorkflowStep.findMany({
where: { treatmentTypeId: { in: labDependentTypes.map((t) => t.id) } },
orderBy: [{ treatmentTypeId: 'asc' }, { stepOrder: 'asc' }],
include: { treatmentType: { select: { code: true } } },
});
const stepsByTypeCode = new Map<string, { stepOrder: number; label: string }[]>();
for (const step of workflowSteps) {
const code = step.treatmentType.code;
const list = stepsByTypeCode.get(code) ?? [];
list.push({ stepOrder: step.stepOrder, label: step.label });
stepsByTypeCode.set(code, list);
}
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = []; const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
for (const link of labCase.details) { for (const row of toothProsthesisRows) {
const detail = link.detail; const typeSteps = stepsByProsthesisCode.get(row.prosthesisTypeCode) ?? [];
if (!labDependentCodes.has(detail.treatmentType)) { if (typeSteps.length === 0) {
continue; continue;
} }
const teeth = normalizeTeeth(detail.teeth); for (const step of typeSteps) {
const typeSteps = stepsByTypeCode.get(detail.treatmentType) ?? []; taskRows.push({
labCaseId,
for (const tooth of teeth) { treatmentDetailId: row.treatmentDetailId,
for (const step of typeSteps) { tooth: row.tooth,
taskRows.push({ treatmentType: row.detail.treatmentType,
labCaseId, prosthesisTypeCode: row.prosthesisTypeCode,
treatmentDetailId: detail.id, workflowStepCode: step.workflowStepCode,
tooth, stepOrder: step.stepOrder,
treatmentType: detail.treatmentType, stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode,
stepOrder: step.stepOrder, status: LabTaskStatus.PENDING,
stepLabel: step.label, });
status: LabTaskStatus.PENDING,
});
}
} }
} }
@@ -89,3 +88,37 @@ export async function generateLabCaseTasks(
await tx.labCaseTask.createMany({ data: taskRows }); await tx.labCaseTask.createMany({ data: taskRows });
return taskRows.length; return taskRows.length;
} }
async function resolveStepLabels(
tx: TransactionClient,
stepCodes: string[],
locale: string,
): Promise<Map<string, string>> {
if (stepCodes.length === 0) {
return new Map();
}
const rows = await tx.catalogTranslation.findMany({
where: {
entityKind: CatalogEntityKind.LAB_WORKFLOW_STEP,
entityCode: { in: stepCodes },
locale: { in: [locale, 'en'] },
},
select: { entityCode: true, locale: true, label: true },
});
const byCode = new Map<string, { en?: string; locale?: string }>();
for (const row of rows) {
const entry = byCode.get(row.entityCode) ?? {};
if (row.locale === 'en') entry.en = row.label;
if (row.locale === locale) entry.locale = row.label;
byCode.set(row.entityCode, entry);
}
const result = new Map<string, string>();
for (const code of stepCodes) {
const entry = byCode.get(code);
result.set(code, entry?.locale ?? entry?.en ?? code);
}
return result;
}

View File

@@ -0,0 +1,67 @@
import { Injectable } from '@nestjs/common';
import { CatalogEntityKind } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
const SUPPORTED_LOCALES = ['en', 'fa', 'nl'] as const;
export type CatalogLocale = (typeof SUPPORTED_LOCALES)[number];
export function normalizeCatalogLocale(language?: string | null): CatalogLocale {
if (language === 'fa' || language === 'nl') return language;
return 'en';
}
@Injectable()
export class CatalogLabelService {
constructor(private readonly prisma: PrismaService) {}
async resolveLabels(
entityKind: CatalogEntityKind,
codes: string[],
locale: CatalogLocale,
): Promise<Map<string, string>> {
const uniqueCodes = [...new Set(codes.filter(Boolean))];
if (uniqueCodes.length === 0) {
return new Map();
}
const rows = await this.prisma.catalogTranslation.findMany({
where: {
entityKind,
entityCode: { in: uniqueCodes },
locale: { in: [locale, 'en'] },
},
select: { entityCode: true, locale: true, label: true },
});
const byCode = new Map<string, { en?: string; locale?: string }>();
for (const row of rows) {
const entry = byCode.get(row.entityCode) ?? {};
if (row.locale === 'en') entry.en = row.label;
if (row.locale === locale) entry.locale = row.label;
byCode.set(row.entityCode, entry);
}
const result = new Map<string, string>();
for (const code of uniqueCodes) {
const entry = byCode.get(code);
result.set(code, entry?.locale ?? entry?.en ?? formatCodeAsLabel(code));
}
return result;
}
async resolveLabel(
entityKind: CatalogEntityKind,
code: string,
locale: CatalogLocale,
): Promise<string> {
const map = await this.resolveLabels(entityKind, [code], locale);
return map.get(code) ?? formatCodeAsLabel(code);
}
}
export function formatCodeAsLabel(code: string): string {
return code
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}

View File

@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { CatalogLabelService } from './catalog-label.service';
@Global()
@Module({
providers: [CatalogLabelService, PrismaService],
exports: [CatalogLabelService],
})
export class CatalogModule {}

View File

@@ -143,7 +143,7 @@ export class OrganizationController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get one case exchanged with a connected organization' }) @ApiOperation({ summary: 'Get one case exchanged with a connected organization' })
getConnectionCase( getConnectionCase(
@Req() req: { user: { id: string; organizationId?: string } }, @Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
@Param('connectionId') connectionId: string, @Param('connectionId') connectionId: string,
@Param('caseId') caseId: string, @Param('caseId') caseId: string,
) { ) {
@@ -153,6 +153,7 @@ export class OrganizationController {
organizationId, organizationId,
connectionId, connectionId,
caseId, caseId,
req.user.language,
); );
} }

View File

@@ -379,6 +379,7 @@ export class OrganizationService {
organizationId: string, organizationId: string,
connectionId: string, connectionId: string,
caseId: string, caseId: string,
localeInput?: string | null,
) { ) {
const actor = await this.getActorMembership(userId, organizationId); const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) { if (!actor || !this.canEditOrganizations(actor)) {
@@ -392,6 +393,7 @@ export class OrganizationService {
caseId, caseId,
clinicOrganizationId, clinicOrganizationId,
labOrganizationId, labOrganizationId,
localeInput,
); );
return { return {

View File

@@ -0,0 +1,26 @@
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ProsthesisCatalogService } from './prosthesis-catalog.service';
@ApiTags('prosthesis-catalog')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('prosthesis-catalog')
export class ProsthesisCatalogController {
constructor(private readonly prosthesisCatalogService: ProsthesisCatalogService) {}
@Get()
@ApiOperation({
summary: 'List prosthesis types (optionally scoped to lab — v1 returns all types)',
})
list(
@Req() req: { user?: { language?: string | null } },
@Query('labOrganizationId') _labOrganizationId?: string,
) {
return this.prosthesisCatalogService.list(req.user?.language).then((data) => ({
success: true,
data,
}));
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { ProsthesisCatalogController } from './prosthesis-catalog.controller';
import { ProsthesisCatalogService } from './prosthesis-catalog.service';
@Module({
controllers: [ProsthesisCatalogController],
providers: [ProsthesisCatalogService, PrismaService],
exports: [ProsthesisCatalogService],
})
export class ProsthesisCatalogModule {}

View File

@@ -0,0 +1,98 @@
import { Injectable, BadRequestException, OnModuleInit } from '@nestjs/common';
import { CatalogEntityKind } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import {
CatalogLabelService,
CatalogLocale,
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
export type ProsthesisTypeCatalogEntry = {
code: string;
sortOrder: number;
label: string;
};
@Injectable()
export class ProsthesisCatalogService implements OnModuleInit {
private loaded = false;
private byCode = new Map<string, { sortOrder: number }>();
constructor(
private readonly prisma: PrismaService,
private readonly catalogLabels: CatalogLabelService,
) {}
async onModuleInit() {
await this.refresh();
}
async refresh(): Promise<void> {
const rows = await this.prisma.prosthesisType.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }],
select: { code: true, sortOrder: true },
});
this.byCode = new Map(rows.map((row) => [row.code, { sortOrder: row.sortOrder }]));
this.loaded = true;
}
async list(localeInput?: string | null): Promise<ProsthesisTypeCatalogEntry[]> {
this.ensureLoaded();
const locale = normalizeCatalogLocale(localeInput);
const codes = [...this.byCode.keys()];
const labels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
codes,
locale,
);
return codes
.map((code) => ({
code,
sortOrder: this.byCode.get(code)!.sortOrder,
label: labels.get(code) ?? code,
}))
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
}
assertKnownProsthesisType(code: string): void {
this.ensureLoaded();
if (!this.byCode.has(code)) {
throw new BadRequestException(`Unknown prosthesis type: ${code}`);
}
}
async getStepCodesForProsthesisType(prosthesisTypeCode: string): Promise<string[]> {
const type = await this.prisma.prosthesisType.findUnique({
where: { code: prosthesisTypeCode },
select: {
steps: {
orderBy: { stepOrder: 'asc' },
select: { labWorkflowStep: { select: { code: true } } },
},
},
});
if (!type) {
return [];
}
return type.steps.map((s) => s.labWorkflowStep.code);
}
async resolveStepLabels(stepCodes: string[], locale: CatalogLocale): Promise<Map<string, string>> {
return this.catalogLabels.resolveLabels(
CatalogEntityKind.LAB_WORKFLOW_STEP,
stepCodes,
locale,
);
}
private ensureLoaded() {
if (!this.loaded) {
throw new Error('Prosthesis catalog is not loaded yet');
}
}
}

View File

@@ -16,7 +16,7 @@ export class TasksController {
@ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' }) @ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' })
list(@Query() query: ListLabTasksDto, @Req() req) { list(@Query() query: ListLabTasksDto, @Req() req) {
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user); const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
return this.tasksService.list(organizationId, req.user.id, query); return this.tasksService.list(organizationId, req.user.id, query, req.user.language);
} }
@Patch(':taskId') @Patch(':taskId')
@@ -27,6 +27,12 @@ export class TasksController {
@Req() req, @Req() req,
) { ) {
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user); const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
return this.tasksService.updateStatus(taskId, dto, organizationId, req.user.id); return this.tasksService.updateStatus(
taskId,
dto,
organizationId,
req.user.id,
req.user.language,
);
} }
} }

View File

@@ -4,8 +4,12 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { LabTaskStatus, Prisma } from '@prisma/client'; import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import {
CatalogLabelService,
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto'; import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
const taskListInclude = { const taskListInclude = {
@@ -24,7 +28,10 @@ const taskListInclude = {
@Injectable() @Injectable()
export class TasksService { export class TasksService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly catalogLabels: CatalogLabelService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) { getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) { if (!user?.organizationId) {
@@ -33,7 +40,12 @@ export class TasksService {
return user.organizationId; return user.organizationId;
} }
async list(labOrganizationId: string, actorUserId: string, query: ListLabTasksDto) { async list(
labOrganizationId: string,
actorUserId: string,
query: ListLabTasksDto,
localeInput?: string | null,
) {
await this.assertCanReadTasks(actorUserId, labOrganizationId); await this.assertCanReadTasks(actorUserId, labOrganizationId);
const membership = await this.getMembership(actorUserId, labOrganizationId); const membership = await this.getMembership(actorUserId, labOrganizationId);
@@ -71,10 +83,18 @@ export class TasksService {
this.prisma.labCaseTask.count({ where }), this.prisma.labCaseTask.count({ where }),
]); ]);
const locale = normalizeCatalogLocale(localeInput);
const prosthesisCodes = [...new Set(items.map((t) => t.prosthesisTypeCode).filter(Boolean))];
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
locale,
);
return { return {
success: true, success: true,
data: { data: {
items: items.map((task) => this.mapTaskListItem(task)), items: items.map((task) => this.mapTaskListItem(task, prosthesisLabels)),
pagination: { pagination: {
page, page,
limit, limit,
@@ -90,6 +110,7 @@ export class TasksService {
dto: UpdateLabTaskDto, dto: UpdateLabTaskDto,
labOrganizationId: string, labOrganizationId: string,
actorUserId: string, actorUserId: string,
localeInput?: string | null,
) { ) {
await this.assertCanEditTasks(actorUserId, labOrganizationId); await this.assertCanEditTasks(actorUserId, labOrganizationId);
@@ -123,17 +144,28 @@ export class TasksService {
include: taskListInclude, include: taskListInclude,
}); });
return { success: true, data: this.mapTaskListItem(updated) }; const locale = normalizeCatalogLocale(localeInput);
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
[updated.prosthesisTypeCode],
locale,
);
return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) };
} }
private mapTaskListItem( private mapTaskListItem(
task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>, task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
prosthesisLabels: Map<string, string>,
) { ) {
return { return {
id: task.id, id: task.id,
labCaseId: task.labCaseId, labCaseId: task.labCaseId,
tooth: task.tooth, tooth: task.tooth,
treatmentType: task.treatmentType, treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
stepOrder: task.stepOrder, stepOrder: task.stepOrder,
stepLabel: task.stepLabel, stepLabel: task.stepLabel,
status: task.status, status: task.status,

View File

@@ -1,4 +1,4 @@
import { Controller, Get, UseGuards } from '@nestjs/common'; import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { TreatmentCatalogService } from './treatment-catalog.service'; import { TreatmentCatalogService } from './treatment-catalog.service';
@@ -11,11 +11,9 @@ export class TreatmentCatalogController {
constructor(private readonly treatmentCatalogService: TreatmentCatalogService) {} constructor(private readonly treatmentCatalogService: TreatmentCatalogService) {}
@Get() @Get()
@ApiOperation({ summary: 'List treatment types from the catalog (data-driven)' }) @ApiOperation({ summary: 'List active treatment types with localized labels' })
list() { async list(@Req() req: { user?: { language?: string | null } }) {
return { const data = await this.treatmentCatalogService.list(req.user?.language);
success: true, return { success: true, data };
data: this.treatmentCatalogService.list(),
};
} }
} }

View File

@@ -1,11 +1,18 @@
import { BadRequestException, Injectable, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleInit } from '@nestjs/common';
import { BadRequestException } from '@nestjs/common';
import { CatalogEntityKind } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import {
CatalogLabelService,
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
export type TreatmentTypeCatalogEntry = { export type TreatmentTypeCatalogEntry = {
id: string; id: string;
code: string; code: string;
labDependent: boolean; labDependent: boolean;
sortOrder: number; sortOrder: number;
label: string;
}; };
@Injectable() @Injectable()
@@ -13,7 +20,10 @@ export class TreatmentCatalogService implements OnModuleInit {
private loaded = false; private loaded = false;
private byCode = new Map<string, TreatmentTypeCatalogEntry>(); private byCode = new Map<string, TreatmentTypeCatalogEntry>();
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly catalogLabels: CatalogLabelService,
) {}
async onModuleInit() { async onModuleInit() {
await this.refresh(); await this.refresh();
@@ -21,17 +31,46 @@ export class TreatmentCatalogService implements OnModuleInit {
async refresh(): Promise<void> { async refresh(): Promise<void> {
const rows = await this.prisma.treatmentType.findMany({ const rows = await this.prisma.treatmentType.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }], orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }],
select: { id: true, code: true, labDependent: true, sortOrder: true }, select: { id: true, code: true, labDependent: true, sortOrder: true },
}); });
this.byCode = new Map(rows.map((row) => [row.code, row])); this.byCode = new Map(
rows.map((row) => [
row.code,
{
id: row.id,
code: row.code,
labDependent: row.labDependent,
sortOrder: row.sortOrder,
label: row.code,
},
]),
);
this.loaded = true; this.loaded = true;
} }
list(): TreatmentTypeCatalogEntry[] { async list(localeInput?: string | null): Promise<TreatmentTypeCatalogEntry[]> {
await this.ensureLabels(localeInput);
return [...this.byCode.values()].sort(
(a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code),
);
}
private async ensureLabels(localeInput?: string | null) {
this.ensureLoaded(); this.ensureLoaded();
return [...this.byCode.values()]; const locale = normalizeCatalogLocale(localeInput);
const codes = [...this.byCode.keys()];
const labels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.TREATMENT_TYPE,
codes,
locale,
);
for (const [code, entry] of this.byCode) {
entry.label = labels.get(code) ?? entry.code;
}
} }
getByCode(code: string): TreatmentTypeCatalogEntry | undefined { getByCode(code: string): TreatmentTypeCatalogEntry | undefined {

View File

@@ -45,6 +45,19 @@ export class SaveTreatmentDraftDto {
details: SaveTreatmentDetailDto[]; details: SaveTreatmentDetailDto[];
} }
export class LabCaseToothProsthesisDto {
@IsUUID()
treatmentDetailId: string;
@IsString()
@MaxLength(8)
tooth: string;
@IsString()
@MaxLength(64)
prosthesisTypeCode: string;
}
export class SaveLabCaseDto { export class SaveLabCaseDto {
@IsString() @IsString()
@MaxLength(64) @MaxLength(64)
@@ -67,6 +80,12 @@ export class SaveLabCaseDto {
@ArrayMinSize(1) @ArrayMinSize(1)
@IsUUID(undefined, { each: true }) @IsUUID(undefined, { each: true })
treatmentDetailIds: string[]; treatmentDetailIds: string[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => LabCaseToothProsthesisDto)
toothProsthesis?: LabCaseToothProsthesisDto[];
} }
export class SaveTreatmentLabCasesDto { export class SaveTreatmentLabCasesDto {

View File

@@ -0,0 +1,52 @@
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
describe('assertCompleteToothProsthesisMap', () => {
const prosthesisDetailId = 'detail-1';
it('passes when every prosthesis tooth has a mapping', () => {
expect(() =>
assertCompleteToothProsthesisMap({
details: [
{
treatmentDetailId: prosthesisDetailId,
detail: { id: prosthesisDetailId, treatmentType: 'prosthesis', teeth: ['14', '15'] },
},
],
toothProsthesis: [
{ treatmentDetailId: prosthesisDetailId, tooth: '14', prosthesisTypeCode: 'pfm_crown' },
{ treatmentDetailId: prosthesisDetailId, tooth: '15', prosthesisTypeCode: 'pfm_crown' },
],
}),
).not.toThrow();
});
it('ignores non-prosthesis details', () => {
expect(() =>
assertCompleteToothProsthesisMap({
details: [
{
treatmentDetailId: 'endo-1',
detail: { id: 'endo-1', treatmentType: 'endo', teeth: ['36'] },
},
],
toothProsthesis: [],
}),
).not.toThrow();
});
it('throws when a prosthesis tooth is missing from the map', () => {
expect(() =>
assertCompleteToothProsthesisMap({
details: [
{
treatmentDetailId: prosthesisDetailId,
detail: { id: prosthesisDetailId, treatmentType: 'prosthesis', teeth: ['14', '15'] },
},
],
toothProsthesis: [
{ treatmentDetailId: prosthesisDetailId, tooth: '14', prosthesisTypeCode: 'pfm_crown' },
],
}),
).toThrow('missing tooth 15');
});
});

View File

@@ -0,0 +1,37 @@
import { BadRequestException } from '@nestjs/common';
import { normalizeTeeth } from './treatment.utils';
export type LabCaseProsthesisLink = {
treatmentDetailId: string;
detail: { id: string; treatmentType: string; teeth: unknown };
};
export type LabCaseToothProsthesisRow = {
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
};
export function assertCompleteToothProsthesisMap(labCase: {
details: LabCaseProsthesisLink[];
toothProsthesis: LabCaseToothProsthesisRow[];
}) {
const prosthesisByKey = new Set(
labCase.toothProsthesis.map((tp) => `${tp.treatmentDetailId}:${tp.tooth}`),
);
for (const link of labCase.details) {
if (link.detail.treatmentType !== 'prosthesis') {
continue;
}
const teeth = normalizeTeeth(link.detail.teeth);
for (const tooth of teeth) {
const key = `${link.treatmentDetailId}:${tooth}`;
if (!prosthesisByKey.has(key)) {
throw new BadRequestException(
`Each tooth must have a prosthesis type before sending (missing tooth ${tooth})`,
);
}
}
}
}

View File

@@ -184,9 +184,14 @@ export class TreatmentsController {
@ApiOperation({ summary: 'Send a lab case to its destination organization (TAB_TREATMENT_EDIT)' }) @ApiOperation({ summary: 'Send a lab case to its destination organization (TAB_TREATMENT_EDIT)' })
sendLabCase( sendLabCase(
@Param('labCaseId') labCaseId: string, @Param('labCaseId') labCaseId: string,
@Req() req: { user: { id: string; organizationId?: string } }, @Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
) { ) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.sendLabCase(labCaseId, organizationId, req.user.id); return this.treatmentsService.sendLabCase(
labCaseId,
organizationId,
req.user.id,
req.user.language,
);
} }
} }

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module';
import { TreatmentsController } from './treatments.controller'; import { TreatmentsController } from './treatments.controller';
import { TreatmentsService } from './treatments.service'; import { TreatmentsService } from './treatments.service';
@Module({ @Module({
imports: [ProsthesisCatalogModule],
controllers: [TreatmentsController], controllers: [TreatmentsController],
providers: [TreatmentsService, PrismaService, ClinicOrgGuard], providers: [TreatmentsService, PrismaService, ClinicOrgGuard],
}) })

View File

@@ -10,6 +10,7 @@ import { join } from 'path';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { generateLabCaseTasks } from '../cases/lab-case-task.generator'; import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { import {
SaveTreatmentDraftDto, SaveTreatmentDraftDto,
@@ -19,6 +20,7 @@ import {
generateTreatmentTitle, generateTreatmentTitle,
normalizeTeeth, normalizeTeeth,
} from './treatment.utils'; } from './treatment.utils';
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
const treatmentInclude = { const treatmentInclude = {
details: { details: {
@@ -53,6 +55,7 @@ const treatmentInclude = {
orderBy: [{ sentAt: 'asc' as const }], orderBy: [{ sentAt: 'asc' as const }],
include: { organization: { select: { id: true, name: true } } }, include: { organization: { select: { id: true, name: true } } },
}, },
toothProsthesis: true,
}, },
}, },
}; };
@@ -64,6 +67,7 @@ export class TreatmentsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly treatmentCatalog: TreatmentCatalogService, private readonly treatmentCatalog: TreatmentCatalogService,
private readonly prosthesisCatalog: ProsthesisCatalogService,
) {} ) {}
getOrganizationIdFromUser(user: { organizationId?: string }) { getOrganizationIdFromUser(user: { organizationId?: string }) {
@@ -326,7 +330,7 @@ export class TreatmentsService {
const details = await this.prisma.treatmentDetail.findMany({ const details = await this.prisma.treatmentDetail.findMany({
where: { treatmentId: treatment.id, id: { in: detailIds } }, where: { treatmentId: treatment.id, id: { in: detailIds } },
select: { id: true, treatmentType: true }, select: { id: true, treatmentType: true, teeth: true },
}); });
if (details.length !== uniqueDetailIds.size) { if (details.length !== uniqueDetailIds.size) {
throw new BadRequestException('One or more treatment details were not found'); throw new BadRequestException('One or more treatment details were not found');
@@ -336,12 +340,30 @@ export class TreatmentsService {
this.treatmentCatalog.assertLabDependentTreatmentType(detail.treatmentType); this.treatmentCatalog.assertLabDependentTreatmentType(detail.treatmentType);
} }
const detailById = new Map(details.map((d) => [d.id, d]));
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
for (const lc of dto.labCases) { for (const lc of dto.labCases) {
if (lc.destinationOrganizationId && !linkedOrgIds.has(lc.destinationOrganizationId)) { if (lc.destinationOrganizationId && !linkedOrgIds.has(lc.destinationOrganizationId)) {
throw new BadRequestException('Destination organization is not an active linked counterpart'); throw new BadRequestException('Destination organization is not an active linked counterpart');
} }
for (const row of lc.toothProsthesis ?? []) {
if (!lc.treatmentDetailIds.includes(row.treatmentDetailId)) {
throw new BadRequestException(
'Tooth prosthesis must reference a detail included in this lab case',
);
}
const detail = detailById.get(row.treatmentDetailId);
if (!detail) {
throw new BadRequestException('Tooth prosthesis references an unknown treatment detail');
}
const teeth = normalizeTeeth(detail.teeth);
if (!teeth.includes(row.tooth)) {
throw new BadRequestException(`Tooth ${row.tooth} is not on the selected treatment detail`);
}
this.prosthesisCatalog.assertKnownProsthesisType(row.prosthesisTypeCode);
}
} }
const saved = await this.prisma.$transaction(async (tx) => { const saved = await this.prisma.$transaction(async (tx) => {
@@ -395,6 +417,18 @@ export class TreatmentsService {
treatmentDetailId, treatmentDetailId,
})), })),
}); });
await tx.labCaseToothProsthesis.deleteMany({ where: { labCaseId: row.id } });
if (lc.toothProsthesis?.length) {
await tx.labCaseToothProsthesis.createMany({
data: lc.toothProsthesis.map((tp) => ({
labCaseId: row.id,
treatmentDetailId: tp.treatmentDetailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
})),
});
}
} }
return tx.treatment.findUniqueOrThrow({ return tx.treatment.findUniqueOrThrow({
@@ -410,6 +444,7 @@ export class TreatmentsService {
labCaseId: string, labCaseId: string,
organizationId: string, organizationId: string,
actorUserId: string, actorUserId: string,
actorLanguage?: string | null,
) { ) {
await this.assertCanEditTreatment(actorUserId, organizationId); await this.assertCanEditTreatment(actorUserId, organizationId);
@@ -421,7 +456,12 @@ export class TreatmentsService {
include: { include: {
treatment: { select: { providerUserId: true } }, treatment: { select: { providerUserId: true } },
sends: { select: { organizationId: true } }, sends: { select: { organizationId: true } },
details: { select: { treatmentDetailId: true } }, details: {
include: {
detail: { select: { id: true, treatmentType: true, teeth: true } },
},
},
toothProsthesis: true,
}, },
}); });
@@ -437,6 +477,8 @@ export class TreatmentsService {
throw new BadRequestException('Lab case must include at least one treatment detail'); throw new BadRequestException('Lab case must include at least one treatment detail');
} }
assertCompleteToothProsthesisMap(labCase);
if (labCase.treatment.providerUserId !== actorUserId) { if (labCase.treatment.providerUserId !== actorUserId) {
const membership = await this.getMembership(actorUserId, organizationId); const membership = await this.getMembership(actorUserId, organizationId);
if (!membership?.isOwner) { if (!membership?.isOwner) {
@@ -473,7 +515,7 @@ export class TreatmentsService {
}); });
} }
await generateLabCaseTasks(tx, labCaseId); await generateLabCaseTasks(tx, labCaseId, actorLanguage);
}); });
const refreshed = await this.prisma.labCase.findUniqueOrThrow({ const refreshed = await this.prisma.labCase.findUniqueOrThrow({
@@ -490,6 +532,7 @@ export class TreatmentsService {
orderBy: [{ sentAt: 'asc' }], orderBy: [{ sentAt: 'asc' }],
include: { organization: { select: { id: true, name: true } } }, include: { organization: { select: { id: true, name: true } } },
}, },
toothProsthesis: true,
}, },
}); });
@@ -722,6 +765,11 @@ export class TreatmentsService {
sentAt: Date; sentAt: Date;
organization?: { id: string; name: string }; organization?: { id: string; name: string };
}>; }>;
toothProsthesis?: Array<{
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
}>;
}) { }) {
return { return {
id: lc.id, id: lc.id,
@@ -736,6 +784,11 @@ export class TreatmentsService {
treatmentType: d.detail?.treatmentType ?? '', treatmentType: d.detail?.treatmentType ?? '',
teeth: d.detail ? normalizeTeeth(d.detail.teeth) : [], teeth: d.detail ? normalizeTeeth(d.detail.teeth) : [],
})), })),
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
treatmentDetailId: tp.treatmentDetailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
})),
sends: sends:
lc.sends?.map((s) => ({ lc.sends?.map((s) => ({
organizationId: s.organizationId, organizationId: s.organizationId,

View File

@@ -330,7 +330,7 @@
"treatmentDetails": "Treatment details", "treatmentDetails": "Treatment details",
"teethLabel": "Teeth", "teethLabel": "Teeth",
"tasksByTooth": "Tasks by tooth", "tasksByTooth": "Tasks by tooth",
"toothGroupTitle": "Tooth {tooth} · {type}", "toothGroupTitle": "Tooth {tooth} · {prosthesis} · {type}",
"noTasks": "No tasks were generated for this case.", "noTasks": "No tasks were generated for this case.",
"unassigned": "Unassigned", "unassigned": "Unassigned",
"statusPending": "Pending", "statusPending": "Pending",
@@ -497,8 +497,14 @@
"labShipmentNoIncludedDetails": "No details were included in this shipment.", "labShipmentNoIncludedDetails": "No details were included in this shipment.",
"labShipmentNoDetailsAvailable": "All lab details are already in other shipments or have been sent.", "labShipmentNoDetailsAvailable": "All lab details are already in other shipments or have been sent.",
"labDetailLine": "Detail {n} · {type} · {teeth}", "labDetailLine": "Detail {n} · {type} · {teeth}",
"noLabDetails": "No lab-dependent treatment details yet. Add a lab type (e.g. endo) in treatment details above.", "noLabDetails": "No prosthesis treatment details yet. Add prosthesis in treatment details above.",
"labDispatchEmpty": "Add a lab shipment to group details and send them to a lab.", "labDispatchEmpty": "Add a lab shipment to group details and send them to a lab.",
"prosthesisTypesTitle": "Prosthesis types",
"prosthesisApplyAll": "Apply to all teeth",
"prosthesisSelectPlaceholder": "Select prosthesis type…",
"prosthesisColTooth": "Tooth",
"prosthesisColDetail": "Detail",
"prosthesisColType": "Prosthesis type",
"labComment": "Message for the lab", "labComment": "Message for the lab",
"labCommentPlaceholder": "Optional instructions for this shipment…", "labCommentPlaceholder": "Optional instructions for this shipment…",
"selectLab": "Destination lab", "selectLab": "Destination lab",

View File

@@ -330,7 +330,7 @@
"treatmentDetails": "جزئیات درمان", "treatmentDetails": "جزئیات درمان",
"teethLabel": "دندان‌ها", "teethLabel": "دندان‌ها",
"tasksByTooth": "وظایف به تفکیک دندان", "tasksByTooth": "وظایف به تفکیک دندان",
"toothGroupTitle": "دندان {tooth} · {type}", "toothGroupTitle": "دندان {tooth} · {prosthesis} · {type}",
"noTasks": "برای این پرونده وظیفه‌ای ایجاد نشده است.", "noTasks": "برای این پرونده وظیفه‌ای ایجاد نشده است.",
"unassigned": "بدون مسئول", "unassigned": "بدون مسئول",
"statusPending": "در انتظار", "statusPending": "در انتظار",
@@ -497,8 +497,14 @@
"labShipmentNoIncludedDetails": "جزئیاتی در این محموله گنجانده نشده است.", "labShipmentNoIncludedDetails": "جزئیاتی در این محموله گنجانده نشده است.",
"labShipmentNoDetailsAvailable": "همه جزئیات لاب در محموله‌های دیگر هستند یا ارسال شده‌اند.", "labShipmentNoDetailsAvailable": "همه جزئیات لاب در محموله‌های دیگر هستند یا ارسال شده‌اند.",
"labDetailLine": "جزئیات {n} · {type} · {teeth}", "labDetailLine": "جزئیات {n} · {type} · {teeth}",
"noLabDetails": "هنوز جزئیات وابسته به لاب وجود ندارد. نوع لاب (مثلاً اندو) در جزئیات درمان بالا اضافه کنید.", "noLabDetails": "هنوز جزئیات پروتز وجود ندارد. پروتز را در جزئیات درمان بالا اضافه کنید.",
"labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروه‌بندی و ارسال کنید.", "labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروه‌بندی و ارسال کنید.",
"prosthesisTypesTitle": "انواع پروتز",
"prosthesisApplyAll": "اعمال برای همه دندان‌ها",
"prosthesisSelectPlaceholder": "نوع پروتز را انتخاب کنید…",
"prosthesisColTooth": "دندان",
"prosthesisColDetail": "جزئیات",
"prosthesisColType": "نوع پروتز",
"labComment": "پیام برای لابراتوار", "labComment": "پیام برای لابراتوار",
"labCommentPlaceholder": "دستورالعمل اختیاری برای این محموله…", "labCommentPlaceholder": "دستورالعمل اختیاری برای این محموله…",
"selectLab": "لابراتوار مقصد", "selectLab": "لابراتوار مقصد",

View File

@@ -330,7 +330,7 @@
"treatmentDetails": "Behandeldetails", "treatmentDetails": "Behandeldetails",
"teethLabel": "Tanden", "teethLabel": "Tanden",
"tasksByTooth": "Taken per tand", "tasksByTooth": "Taken per tand",
"toothGroupTitle": "Tand {tooth} · {type}", "toothGroupTitle": "Tand {tooth} · {prosthesis} · {type}",
"noTasks": "Er zijn geen taken gegenereerd voor dit dossier.", "noTasks": "Er zijn geen taken gegenereerd voor dit dossier.",
"unassigned": "Niet toegewezen", "unassigned": "Niet toegewezen",
"statusPending": "In afwachting", "statusPending": "In afwachting",
@@ -497,8 +497,14 @@
"labShipmentNoIncludedDetails": "Geen details opgenomen in deze zending.", "labShipmentNoIncludedDetails": "Geen details opgenomen in deze zending.",
"labShipmentNoDetailsAvailable": "Alle labdetails zitten al in andere zendingen of zijn verzonden.", "labShipmentNoDetailsAvailable": "Alle labdetails zitten al in andere zendingen of zijn verzonden.",
"labDetailLine": "Detail {n} · {type} · {teeth}", "labDetailLine": "Detail {n} · {type} · {teeth}",
"noLabDetails": "Nog geen lab-afhankelijke details. Voeg een labtype (bijv. endo) toe in de behandeldetails hierboven.", "noLabDetails": "Nog geen prothese-details. Voeg prothese toe in de behandeldetails hierboven.",
"labDispatchEmpty": "Voeg een labzending toe om details te groeperen en naar een lab te sturen.", "labDispatchEmpty": "Voeg een labzending toe om details te groeperen en naar een lab te sturen.",
"prosthesisTypesTitle": "Prothesetypes",
"prosthesisApplyAll": "Toepassen op alle tanden",
"prosthesisSelectPlaceholder": "Selecteer prothesetype…",
"prosthesisColTooth": "Tand",
"prosthesisColDetail": "Detail",
"prosthesisColType": "Prothesetype",
"labComment": "Bericht voor het lab", "labComment": "Bericht voor het lab",
"labCommentPlaceholder": "Optionele instructies voor deze zending…", "labCommentPlaceholder": "Optionele instructies voor deze zending…",
"selectLab": "Bestemmingslab", "selectLab": "Bestemmingslab",

View File

@@ -10,9 +10,12 @@ import { useToast } from '@/lib/hooks/useToast';
import { canEditCases } from '@/components/shared/permissions'; import { canEditCases } from '@/components/shared/permissions';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge'; import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { casesApi } from '@/lib/api/cases'; import { casesApi } from '@/lib/api/cases';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar'; import { SearchBar } from '@/components/ui/shared/SearchBar';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { import type {
AssignableMember, AssignableMember,
CasesFilterOptions, CasesFilterOptions,
@@ -22,14 +25,6 @@ import type {
PaginatedLabCases, PaginatedLabCases,
} from '@/types/cases'; } from '@/types/cases';
const TREATMENT_TYPE_KEYS = {
consultation: 'typeConsultation',
filling: 'typeFilling',
endo: 'typeEndo',
visit: 'typeVisit',
hygiene: 'typeHygiene',
} as const;
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const; const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const;
@@ -101,6 +96,7 @@ export default function CasesPage() {
clinics: [], clinics: [],
treatmentTypes: [], treatmentTypes: [],
}); });
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null); const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null); const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
@@ -113,11 +109,8 @@ export default function CasesPage() {
const locale = user?.language ?? 'en'; const locale = user?.language ?? 'en';
const treatmentLabel = useCallback( const treatmentLabel = useCallback(
(type: string) => { (type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog),
const key = TREATMENT_TYPE_KEYS[type as keyof typeof TREATMENT_TYPE_KEYS]; [treatmentCatalog],
return key ? tTreatment(key) : type;
},
[tTreatment],
); );
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
@@ -179,6 +172,7 @@ export default function CasesPage() {
useEffect(() => { useEffect(() => {
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {}); void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {}); void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {});
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
}, []); }, []);
@@ -490,6 +484,7 @@ export default function CasesPage() {
<div className="text-sm font-medium text-text-primary"> <div className="text-sm font-medium text-text-primary">
{t('toothGroupTitle', { {t('toothGroupTitle', {
tooth: group.tooth, tooth: group.tooth,
prosthesis: group.prosthesisTypeLabel,
type: treatmentLabel(group.treatmentType), type: treatmentLabel(group.treatmentType),
})} })}
</div> </div>

View File

@@ -12,7 +12,10 @@ import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast'; import { useToast } from '@/lib/hooks/useToast';
import { tasksApi } from '@/lib/api/tasks'; import { tasksApi } from '@/lib/api/tasks';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases'; import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -46,6 +49,7 @@ export default function TasksPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null); const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const canView = canViewTasks(currentOrganization); const canView = canViewTasks(currentOrganization);
const canEdit = canEditTasks(currentOrganization); const canEdit = canEditTasks(currentOrganization);
@@ -64,6 +68,10 @@ export default function TasksPage() {
[t], [t],
); );
useEffect(() => {
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
if (!canView) return; if (!canView) return;
@@ -166,6 +174,7 @@ export default function TasksPage() {
<p className="text-[11px] text-text-secondary truncate"> <p className="text-[11px] text-text-secondary truncate">
{t('fromClinic', { name: task.clinic.name })} ·{' '} {t('fromClinic', { name: task.clinic.name })} ·{' '}
{formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })} {formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })}
{task.prosthesisTypeLabel ? ` · ${task.prosthesisTypeLabel}` : ''}
</p> </p>
<p className="text-[11px] text-text-muted truncate flex flex-wrap items-center gap-x-1 gap-y-0.5"> <p className="text-[11px] text-text-muted truncate flex flex-wrap items-center gap-x-1 gap-y-0.5">
<span>{t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}</span> <span>{t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}</span>
@@ -213,7 +222,10 @@ export default function TasksPage() {
<Badge variant="default" fixedWidth={false}> <Badge variant="default" fixedWidth={false}>
{t('priorityLabel', { n: task.priority })} {t('priorityLabel', { n: task.priority })}
</Badge> </Badge>
<TreatmentTypeBadge type={task.treatmentType} /> <TreatmentTypeBadge
type={task.treatmentType}
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
/>
</div> </div>
</li> </li>
); );

View File

@@ -6,20 +6,15 @@ import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast'; import { useToast } from '@/lib/hooks/useToast';
import { organizationApi } from '@/lib/api/organization'; import { organizationApi } from '@/lib/api/organization';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge'; import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { SearchBar } from '@/components/ui/shared/SearchBar'; import { SearchBar } from '@/components/ui/shared/SearchBar';
import { ToastStack } from '@/components/ui/shared/Toast'; import { ToastStack } from '@/components/ui/shared/Toast';
import type { CounterpartItemDto } from '@/lib/api/organization'; import type { CounterpartItemDto } from '@/lib/api/organization';
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases'; import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const TREATMENT_TYPE_KEYS = {
consultation: 'typeConsultation',
filling: 'typeFilling',
endo: 'typeEndo',
visit: 'typeVisit',
hygiene: 'typeHygiene',
} as const;
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@@ -78,7 +73,6 @@ export function ConnectionCaseHistoryContent({
}: ConnectionCaseHistoryContentProps) { }: ConnectionCaseHistoryContentProps) {
const t = useTranslations('organizations'); const t = useTranslations('organizations');
const tCases = useTranslations('cases'); const tCases = useTranslations('cases');
const tTreatment = useTranslations('treatment');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const { currentOrganization, user } = useAuth(); const { currentOrganization, user } = useAuth();
const { showError, setError, messages: toastMessages } = useToast(); const { showError, setError, messages: toastMessages } = useToast();
@@ -94,6 +88,7 @@ export function ConnectionCaseHistoryContent({
}); });
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null); const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null); const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const [loadingList, setLoadingList] = useState(false); const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false);
@@ -104,13 +99,14 @@ export function ConnectionCaseHistoryContent({
tRef.current = t; tRef.current = t;
const treatmentLabel = useCallback( const treatmentLabel = useCallback(
(type: string) => { (type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog),
const key = TREATMENT_TYPE_KEYS[type as keyof typeof TREATMENT_TYPE_KEYS]; [treatmentCatalog],
return key ? tTreatment(key) : type;
},
[tTreatment],
); );
useEffect(() => {
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
}, []);
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [ () => [
{ value: 'PENDING', label: tCases('statusPending') }, { value: 'PENDING', label: tCases('statusPending') },
@@ -375,6 +371,7 @@ export function ConnectionCaseHistoryContent({
<div className="text-sm font-medium text-text-primary"> <div className="text-sm font-medium text-text-primary">
{tCases('toothGroupTitle', { {tCases('toothGroupTitle', {
tooth: group.tooth, tooth: group.tooth,
prosthesis: group.prosthesisTypeLabel,
type: treatmentLabel(group.treatmentType), type: treatmentLabel(group.treatmentType),
})} })}
</div> </div>

View File

@@ -6,16 +6,10 @@ import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeSty
import { Card } from '@/components/ui/shared/Card'; import { Card } from '@/components/ui/shared/Card';
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
import { startOfLocalDay } from '@/components/appointments/appointmentTime'; import { startOfLocalDay } from '@/components/appointments/appointmentTime';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { TreatmentAppointment } from '@/types/treatment'; import type { TreatmentAppointment } from '@/types/treatment';
const TREATMENT_TYPE_KEYS = {
consultation: 'typeConsultation',
filling: 'typeFilling',
endo: 'typeEndo',
visit: 'typeVisit',
hygiene: 'typeHygiene',
} as const;
interface AppointmentsStripProps { interface AppointmentsStripProps {
stripHidden: boolean; stripHidden: boolean;
onToggleStripHidden: () => void; onToggleStripHidden: () => void;
@@ -24,6 +18,7 @@ interface AppointmentsStripProps {
appointments: TreatmentAppointment[]; appointments: TreatmentAppointment[];
selectedAppointmentId: string | null; selectedAppointmentId: string | null;
onSelectAppointment: (id: string) => void; onSelectAppointment: (id: string) => void;
treatmentCatalog: TreatmentCatalogEntry[];
loading?: boolean; loading?: boolean;
} }
@@ -35,6 +30,7 @@ export function AppointmentsStrip({
appointments, appointments,
selectedAppointmentId, selectedAppointmentId,
onSelectAppointment, onSelectAppointment,
treatmentCatalog,
loading = false, loading = false,
}: AppointmentsStripProps) { }: AppointmentsStripProps) {
const t = useTranslations('treatment'); const t = useTranslations('treatment');
@@ -96,7 +92,7 @@ export function AppointmentsStrip({
minute: '2-digit', minute: '2-digit',
})}`; })}`;
const palette = purposeStyle(a.purpose); const palette = purposeStyle(a.purpose);
const purposeKey = TREATMENT_TYPE_KEYS[a.purpose as keyof typeof TREATMENT_TYPE_KEYS]; const purposeLabel = treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog);
return ( return (
<Card <Card
as="button" as="button"
@@ -115,9 +111,7 @@ export function AppointmentsStrip({
<p className="text-sm font-medium leading-tight truncate mt-0.5"> <p className="text-sm font-medium leading-tight truncate mt-0.5">
{a.patientFirstName} {a.patientLastName} {a.patientFirstName} {a.patientLastName}
</p> </p>
<p className="text-[11px] opacity-90 capitalize mt-0.5"> <p className="text-[11px] opacity-90 mt-0.5 truncate">{purposeLabel}</p>
{purposeKey ? t(purposeKey) : a.purpose}
</p>
</Card> </Card>
); );
})} })}

View File

@@ -1,20 +1,24 @@
'use client'; 'use client';
import { useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Checkbox } from '@/components/ui/shared/Checkbox';
import { Dropdown } from '@/components/ui/shared/Dropdown'; import { Dropdown } from '@/components/ui/shared/Dropdown';
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar'; import { SearchBar } from '@/components/ui/shared/SearchBar';
import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel'; import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel'; import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { TREATMENT_TYPE_KEYS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay'; import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment'; import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
interface LabCasesDispatchPanelProps { interface LabCasesDispatchPanelProps {
details: TreatmentDetailDraft[]; details: TreatmentDetailDraft[];
labCases: LabCaseDraft[]; labCases: LabCaseDraft[];
labDependentCodes: Set<string>; labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
activeLabCaseId: string | null; activeLabCaseId: string | null;
onActiveLabCaseChange: (id: string) => void; onActiveLabCaseChange: (id: string) => void;
onLabCasesChange: (labCases: LabCaseDraft[]) => void; onLabCasesChange: (labCases: LabCaseDraft[]) => void;
@@ -52,7 +56,6 @@ function detailInOtherDraftShipment(
); );
} }
/** Lab-dependent details not yet sent to any lab. */
function unsentLabDetails( function unsentLabDetails(
details: TreatmentDetailDraft[], details: TreatmentDetailDraft[],
labCases: LabCaseDraft[], labCases: LabCaseDraft[],
@@ -62,7 +65,6 @@ function unsentLabDetails(
return details.filter((d) => labDependentCodes.has(d.treatmentType) && !sent.has(d.clientId)); return details.filter((d) => labDependentCodes.has(d.treatmentType) && !sent.has(d.clientId));
} }
/** Unsent lab details not already assigned to another draft shipment. */
function detailsAvailableForNewShipment( function detailsAvailableForNewShipment(
details: TreatmentDetailDraft[], details: TreatmentDetailDraft[],
labCases: LabCaseDraft[], labCases: LabCaseDraft[],
@@ -73,7 +75,6 @@ function detailsAvailableForNewShipment(
); );
} }
/** Details the user can pick for the active draft shipment. */
function selectableDetailsForDraftShipment( function selectableDetailsForDraftShipment(
details: TreatmentDetailDraft[], details: TreatmentDetailDraft[],
labCases: LabCaseDraft[], labCases: LabCaseDraft[],
@@ -89,10 +90,40 @@ function selectableDetailsForDraftShipment(
}); });
} }
function prosthesisTeethRows(
labCase: LabCaseDraft,
details: TreatmentDetailDraft[],
): Array<{ detailClientId: string; tooth: string; detailNumber: number }> {
const rows: Array<{ detailClientId: string; tooth: string; detailNumber: number }> = [];
for (const clientId of labCase.detailClientIds) {
const detail = details.find((d) => d.clientId === clientId);
if (!detail || detail.treatmentType !== 'prosthesis') continue;
const detailNumber = details.findIndex((d) => d.clientId === clientId) + 1;
for (const tooth of detail.teeth) {
rows.push({ detailClientId: clientId, tooth, detailNumber });
}
}
return rows;
}
function isProsthesisMapComplete(labCase: LabCaseDraft, details: TreatmentDetailDraft[]): boolean {
const rows = prosthesisTeethRows(labCase, details);
if (rows.length === 0) return true;
return rows.every((row) =>
labCase.toothProsthesis.some(
(tp) =>
tp.detailClientId === row.detailClientId &&
tp.tooth === row.tooth &&
Boolean(tp.prosthesisTypeCode),
),
);
}
export function LabCasesDispatchPanel({ export function LabCasesDispatchPanel({
details, details,
labCases, labCases,
labDependentCodes, labDependentCodes,
treatmentCatalog,
activeLabCaseId, activeLabCaseId,
onActiveLabCaseChange, onActiveLabCaseChange,
onLabCasesChange, onLabCasesChange,
@@ -108,6 +139,9 @@ export function LabCasesDispatchPanel({
onSendLabCase, onSendLabCase,
}: LabCasesDispatchPanelProps) { }: LabCasesDispatchPanelProps) {
const t = useTranslations('treatment'); const t = useTranslations('treatment');
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const activeLinkedOrganizations = orgs.filter((o) => o.active); const activeLinkedOrganizations = orgs.filter((o) => o.active);
const filteredOrganizations = (() => { const filteredOrganizations = (() => {
const q = organizationSearch.trim().toLowerCase(); const q = organizationSearch.trim().toLowerCase();
@@ -136,17 +170,39 @@ export function LabCasesDispatchPanel({
? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name ? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
: null; : null;
const prosthesisRows = activeLabCase ? prosthesisTeethRows(activeLabCase, details) : [];
const prosthesisComplete = activeLabCase
? isProsthesisMapComplete(activeLabCase, details)
: true;
useEffect(() => {
if (!activeLabCase?.destinationOrganizationId) {
setProsthesisOptions([]);
return;
}
let cancelled = false;
void prosthesisCatalogApi
.list(activeLabCase.destinationOrganizationId)
.then((res) => {
if (!cancelled) setProsthesisOptions(res.data);
})
.catch(() => {
if (!cancelled) setProsthesisOptions([]);
});
return () => {
cancelled = true;
};
}, [activeLabCase?.destinationOrganizationId]);
function detailNumber(d: TreatmentDetailDraft) { function detailNumber(d: TreatmentDetailDraft) {
const idx = details.findIndex((row) => row.clientId === d.clientId); const idx = details.findIndex((row) => row.clientId === d.clientId);
return idx >= 0 ? idx + 1 : 0; return idx >= 0 ? idx + 1 : 0;
} }
function detailSummary(d: TreatmentDetailDraft) { function detailSummary(d: TreatmentDetailDraft) {
const typeKey = treatmentTypeLabelKey(d.treatmentType); const typeLabel = treatmentTypeLabelFromCatalog(d.treatmentType, treatmentCatalog);
const typeLabel =
d.treatmentType in TREATMENT_TYPE_KEYS
? t(typeKey as 'typeEndo')
: d.treatmentType;
const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone'); const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
return `${t('detailLabel', { n: detailNumber(d) })} · ${typeLabel} · ${teeth}`; return `${t('detailLabel', { n: detailNumber(d) })} · ${typeLabel} · ${teeth}`;
} }
@@ -158,6 +214,31 @@ export function LabCasesDispatchPanel({
); );
} }
function setToothProsthesis(
detailClientId: string,
tooth: string,
prosthesisTypeCode: string,
) {
if (!activeLabCase) return;
const rest = activeLabCase.toothProsthesis.filter(
(tp) => !(tp.detailClientId === detailClientId && tp.tooth === tooth),
);
const next = prosthesisTypeCode
? [...rest, { detailClientId, tooth, prosthesisTypeCode }]
: rest;
updateActiveLabCase({ toothProsthesis: next });
}
function applyProsthesisToAll(code: string) {
if (!activeLabCase || !code) return;
const next = prosthesisRows.map((row) => ({
detailClientId: row.detailClientId,
tooth: row.tooth,
prosthesisTypeCode: code,
}));
updateActiveLabCase({ toothProsthesis: next });
}
function toggleDetailInActiveLabCase(detailClientId: string, checked: boolean) { function toggleDetailInActiveLabCase(detailClientId: string, checked: boolean) {
if (!activeLabCase || sent) return; if (!activeLabCase || sent) return;
@@ -169,7 +250,12 @@ export function LabCasesDispatchPanel({
const set = new Set(lc.detailClientIds); const set = new Set(lc.detailClientIds);
if (checked) set.add(detailClientId); if (checked) set.add(detailClientId);
else set.delete(detailClientId); else set.delete(detailClientId);
return { ...lc, detailClientIds: [...set] };
const keptProsthesis = lc.toothProsthesis.filter((tp) =>
[...set].includes(tp.detailClientId),
);
return { ...lc, detailClientIds: [...set], toothProsthesis: keptProsthesis };
} }
if (checked) { if (checked) {
@@ -375,11 +461,14 @@ export function LabCasesDispatchPanel({
)} )}
<Dropdown <Dropdown
value={activeLabCase.destinationOrganizationId ?? ''} value={activeLabCase.destinationOrganizationId ?? ''}
onChange={(e) => onChange={(e) => {
const nextOrgId = e.target.value || null;
updateActiveLabCase({ updateActiveLabCase({
destinationOrganizationId: e.target.value || null, destinationOrganizationId: nextOrgId,
}) toothProsthesis: [],
} });
setApplyAllProsthesis('');
}}
disabled={disabled || filteredOrganizations.length === 0} disabled={disabled || filteredOrganizations.length === 0}
> >
<option value="">{t('selectLabPlaceholder')}</option> <option value="">{t('selectLabPlaceholder')}</option>
@@ -394,6 +483,84 @@ export function LabCasesDispatchPanel({
)} )}
</div> </div>
{prosthesisRows.length > 0 && activeLabCase.destinationOrganizationId ? (
<div className="space-y-3 border-t border-border/60 pt-3">
<p className="text-xs font-medium text-text-secondary">
{t('prosthesisTypesTitle')}
</p>
<label className="block text-xs text-text-muted space-y-1">
{t('prosthesisApplyAll')}
<select
value={applyAllProsthesis}
disabled={disabled || prosthesisOptions.length === 0}
onChange={(e) => {
const code = e.target.value;
setApplyAllProsthesis(code);
if (code) applyProsthesisToAll(code);
}}
className={`${FORM_SELECT_CLASS} w-full mt-1`}
>
<option value="">{t('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</label>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted">
<th className="pb-2 pr-3 font-medium">{t('prosthesisColTooth')}</th>
<th className="pb-2 pr-3 font-medium">{t('prosthesisColDetail')}</th>
<th className="pb-2 font-medium">{t('prosthesisColType')}</th>
</tr>
</thead>
<tbody>
{prosthesisRows.map((row) => {
const current =
activeLabCase.toothProsthesis.find(
(tp) =>
tp.detailClientId === row.detailClientId &&
tp.tooth === row.tooth,
)?.prosthesisTypeCode ?? '';
return (
<tr key={`${row.detailClientId}-${row.tooth}`} className="border-t border-border/40">
<td className="py-2 pr-3 text-text-primary">{row.tooth}</td>
<td className="py-2 pr-3 text-text-secondary">
{t('detailLabel', { n: row.detailNumber })}
</td>
<td className="py-2">
<select
value={current}
disabled={disabled}
onChange={(e) =>
setToothProsthesis(
row.detailClientId,
row.tooth,
e.target.value,
)
}
className={`${FORM_SELECT_CLASS} w-full min-w-[160px]`}
>
<option value="">{t('prosthesisSelectPlaceholder')}</option>
{prosthesisOptions.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.label}
</option>
))}
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
) : null}
<div className="flex flex-wrap items-center gap-3 pt-1"> <div className="flex flex-wrap items-center gap-3 pt-1">
<Button <Button
type="button" type="button"
@@ -402,7 +569,8 @@ export function LabCasesDispatchPanel({
disabled || disabled ||
sendBusyId === activeLabCase.clientId || sendBusyId === activeLabCase.clientId ||
!activeLabCase.destinationOrganizationId || !activeLabCase.destinationOrganizationId ||
activeLabCase.detailClientIds.length === 0 activeLabCase.detailClientIds.length === 0 ||
!prosthesisComplete
} }
isLoading={sendBusyId === activeLabCase.clientId} isLoading={sendBusyId === activeLabCase.clientId}
onClick={() => onSendLabCase(activeLabCase)} onClick={() => onSendLabCase(activeLabCase)}

View File

@@ -3,9 +3,11 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { TreatmentHistoryDetailLine } from '@/components/ui/treatment/TreatmentHistoryDetailLine'; import { TreatmentHistoryDetailLine } from '@/components/ui/treatment/TreatmentHistoryDetailLine';
import type { PastTreatment } from '@/types/treatment'; import type { PastTreatment } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
interface PastTreatmentsPanelProps { interface PastTreatmentsPanelProps {
items: PastTreatment[]; items: PastTreatment[];
treatmentCatalog: TreatmentCatalogEntry[];
loading?: boolean; loading?: boolean;
selectedPreviewId?: string | null; selectedPreviewId?: string | null;
onSelectTreatment?: (treatment: PastTreatment) => void; onSelectTreatment?: (treatment: PastTreatment) => void;
@@ -13,6 +15,7 @@ interface PastTreatmentsPanelProps {
export function PastTreatmentsPanel({ export function PastTreatmentsPanel({
items, items,
treatmentCatalog,
loading, loading,
selectedPreviewId, selectedPreviewId,
onSelectTreatment, onSelectTreatment,
@@ -78,6 +81,7 @@ export function PastTreatmentsPanel({
<TreatmentHistoryDetailLine <TreatmentHistoryDetailLine
detail={detail} detail={detail}
detailNumber={idx + 1} detailNumber={idx + 1}
treatmentCatalog={treatmentCatalog}
/> />
</div> </div>
))} ))}

View File

@@ -3,12 +3,15 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge'; import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment'; import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment';
interface TreatmentDetailSummaryRowProps { interface TreatmentDetailSummaryRowProps {
detail: PastTreatmentDetail; detail: PastTreatmentDetail;
detailNumber: number; detailNumber: number;
labDependentCodes: Set<string>; labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
orgs?: LinkedOrganizationOption[]; orgs?: LinkedOrganizationOption[];
compact?: boolean; compact?: boolean;
} }
@@ -17,6 +20,7 @@ export function TreatmentDetailSummaryRow({
detail, detail,
detailNumber, detailNumber,
labDependentCodes, labDependentCodes,
treatmentCatalog,
orgs, orgs,
compact = false, compact = false,
}: TreatmentDetailSummaryRowProps) { }: TreatmentDetailSummaryRowProps) {
@@ -35,7 +39,10 @@ export function TreatmentDetailSummaryRow({
<span className={`text-text-muted tabular-nums ${compact ? 'text-[11px]' : 'text-xs'}`}> <span className={`text-text-muted tabular-nums ${compact ? 'text-[11px]' : 'text-xs'}`}>
{t('detailLabel', { n: detailNumber })} {t('detailLabel', { n: detailNumber })}
</span> </span>
<TreatmentTypeBadge type={detail.treatmentType} /> <TreatmentTypeBadge
type={detail.treatmentType}
label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)}
/>
</div> </div>
<DetailLabSendBadge detail={detail} labDependentCodes={labDependentCodes} orgs={orgs} /> <DetailLabSendBadge detail={detail} labDependentCodes={labDependentCodes} orgs={orgs} />
</div> </div>

View File

@@ -10,7 +10,8 @@ import {
labSentBannerClass, labSentBannerClass,
} from '@/components/ui/treatment/treatmentStatusStyles'; } from '@/components/ui/treatment/treatmentStatusStyles';
import type { TreatmentDetailDraft } from '@/types/treatment'; import type { TreatmentDetailDraft } from '@/types/treatment';
import { TREATMENT_TYPE_COLORS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
interface TreatmentDetailsEditorProps { interface TreatmentDetailsEditorProps {
details: TreatmentDetailDraft[]; details: TreatmentDetailDraft[];
@@ -19,6 +20,7 @@ interface TreatmentDetailsEditorProps {
onDetailsChange: (details: TreatmentDetailDraft[]) => void; onDetailsChange: (details: TreatmentDetailDraft[]) => void;
isDetailLocked: (detail: TreatmentDetailDraft) => boolean; isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
labDependentCodes: Set<string>; labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
disabled: boolean; disabled: boolean;
canEdit: boolean; canEdit: boolean;
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error'; saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
@@ -34,6 +36,7 @@ export function TreatmentDetailsEditor({
onDetailsChange, onDetailsChange,
isDetailLocked, isDetailLocked,
labDependentCodes, labDependentCodes,
treatmentCatalog,
disabled, disabled,
canEdit, canEdit,
saveStatus, saveStatus,
@@ -49,7 +52,10 @@ export function TreatmentDetailsEditor({
const locked = isDetailLocked(activeDetail); const locked = isDetailLocked(activeDetail);
const readOnly = disabled || locked; const readOnly = disabled || locked;
const treatmentTypeTextColor = TREATMENT_TYPE_COLORS[activeDetail.treatmentType]; const treatmentTypeTextColor = treatmentTypeColor(
activeDetail.treatmentType,
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
);
const isLabDependent = labDependentCodes.has(activeDetail.treatmentType); const isLabDependent = labDependentCodes.has(activeDetail.treatmentType);
const showPendingLabHint = isLabDependent && !locked && !readOnly; const showPendingLabHint = isLabDependent && !locked && !readOnly;
@@ -125,14 +131,20 @@ export function TreatmentDetailsEditor({
); );
}} }}
disabled={readOnly} disabled={readOnly}
className="capitalize"
style={{ color: treatmentTypeTextColor }} style={{ color: treatmentTypeTextColor }}
> >
<option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }} className="capitalize">{t('typeConsultation')}</option> {treatmentCatalog.map((entry, index) => (
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }} className="capitalize">{t('typeFilling')}</option> <option
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }} className="capitalize">{t('typeEndo')}</option> key={entry.code}
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }} className="capitalize">{t('typeVisit')}</option> value={entry.code}
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }} className="capitalize">{t('typeHygiene')}</option> style={{
color: treatmentTypeColor(entry.code, index),
backgroundColor: '#14253d',
}}
>
{entry.label}
</option>
))}
</Dropdown> </Dropdown>
</div> </div>

View File

@@ -2,16 +2,20 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { PastTreatmentDetail } from '@/types/treatment'; import type { PastTreatmentDetail } from '@/types/treatment';
interface TreatmentHistoryDetailLineProps { interface TreatmentHistoryDetailLineProps {
detail: PastTreatmentDetail; detail: PastTreatmentDetail;
detailNumber: number; detailNumber: number;
treatmentCatalog: TreatmentCatalogEntry[];
} }
export function TreatmentHistoryDetailLine({ export function TreatmentHistoryDetailLine({
detail, detail,
detailNumber, detailNumber,
treatmentCatalog,
}: TreatmentHistoryDetailLineProps) { }: TreatmentHistoryDetailLineProps) {
const t = useTranslations('treatment'); const t = useTranslations('treatment');
const teeth = detail.teeth.length ? [...detail.teeth].sort().join(', ') : t('teethNone'); const teeth = detail.teeth.length ? [...detail.teeth].sort().join(', ') : t('teethNone');
@@ -20,7 +24,10 @@ export function TreatmentHistoryDetailLine({
return ( return (
<div className="flex items-center gap-2 min-w-0 text-[11px] leading-tight"> <div className="flex items-center gap-2 min-w-0 text-[11px] leading-tight">
<span className="text-text-muted tabular-nums shrink-0">{detailNumber}.</span> <span className="text-text-muted tabular-nums shrink-0">{detailNumber}.</span>
<TreatmentTypeBadge type={detail.treatmentType} /> <TreatmentTypeBadge
type={detail.treatmentType}
label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)}
/>
<span className="text-text-secondary truncate min-w-0">{teeth}</span> <span className="text-text-secondary truncate min-w-0">{teeth}</span>
{attachmentCount > 0 && ( {attachmentCount > 0 && (
<span className="text-text-muted shrink-0 tabular-nums"> <span className="text-text-muted shrink-0 tabular-nums">

View File

@@ -4,10 +4,12 @@ import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow'; import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow';
import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment'; import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
interface TreatmentPreviewCardProps { interface TreatmentPreviewCardProps {
treatment: PastTreatment | null; treatment: PastTreatment | null;
labDependentCodes: Set<string>; labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
orgs?: LinkedOrganizationOption[]; orgs?: LinkedOrganizationOption[];
openDisabled?: boolean; openDisabled?: boolean;
onOpen: () => void; onOpen: () => void;
@@ -16,6 +18,7 @@ interface TreatmentPreviewCardProps {
export function TreatmentPreviewCard({ export function TreatmentPreviewCard({
treatment, treatment,
labDependentCodes, labDependentCodes,
treatmentCatalog,
orgs, orgs,
openDisabled = false, openDisabled = false,
onOpen, onOpen,
@@ -53,6 +56,7 @@ export function TreatmentPreviewCard({
detail={detail} detail={detail}
detailNumber={idx + 1} detailNumber={idx + 1}
labDependentCodes={labDependentCodes} labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
orgs={orgs} orgs={orgs}
compact compact
/> />

View File

@@ -1,25 +1,22 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { TREATMENT_TYPE_KEYS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay'; import { formatCodeAsLabel } from '@/components/ui/treatment/treatmentTypeDisplay';
interface TreatmentTypeBadgeProps { interface TreatmentTypeBadgeProps {
type: string; type: string;
label?: string;
className?: string; className?: string;
} }
export function TreatmentTypeBadge({ type, className = '' }: TreatmentTypeBadgeProps) { export function TreatmentTypeBadge({ type, label, className = '' }: TreatmentTypeBadgeProps) {
const t = useTranslations('treatment'); const display = label ?? formatCodeAsLabel(type);
const typeKey = treatmentTypeLabelKey(type);
const label =
type in TREATMENT_TYPE_KEYS ? t(typeKey as 'typeEndo') : type;
return ( return (
<span <span
className={`inline-flex items-center justify-center box-border rounded-md border min-h-[1.75rem] px-2.5 py-1 text-xs font-medium capitalize leading-none shrink-0 ${purposeStyle(type)} ${className}`.trim()} className={`inline-flex items-center justify-center box-border rounded-md border min-h-[1.75rem] px-2.5 py-1 text-xs font-medium leading-none shrink-0 ${purposeStyle(type)} ${className}`.trim()}
> >
{label} {display}
</span> </span>
); );
} }

View File

@@ -9,7 +9,7 @@ import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPan
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor'; import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard'; import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
import { ToastStack } from '@/components/ui/shared/Toast'; import { ToastStack } from '@/components/ui/shared/Toast';
import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay'; import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
import { import {
addCalendarDays, addCalendarDays,
compareLocalDayStart, compareLocalDayStart,
@@ -25,6 +25,7 @@ import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useToast } from '@/lib/hooks/useToast'; import { useToast } from '@/lib/hooks/useToast';
import type { Organization } from '@/types/organization'; import type { Organization } from '@/types/organization';
import type { AppointmentRecord } from '@/types/appointment'; import type { AppointmentRecord } from '@/types/appointment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { import type {
FdiToothId, FdiToothId,
LabCaseDraft, LabCaseDraft,
@@ -93,7 +94,7 @@ function newDetail(): TreatmentDetailDraft {
typeof crypto !== 'undefined' && 'randomUUID' in crypto typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID() ? crypto.randomUUID()
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, : `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: 'consultation', treatmentType: 'restoration',
teeth: [], teeth: [],
comment: '', comment: '',
attachmentMetas: [], attachmentMetas: [],
@@ -111,6 +112,7 @@ function newLabCaseDraft(): LabCaseDraft {
destinationOrganizationId: null, destinationOrganizationId: null,
labComment: '', labComment: '',
detailClientIds: [], detailClientIds: [],
toothProsthesis: [],
sentAt: null, sentAt: null,
sends: [], sends: [],
}; };
@@ -145,12 +147,20 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
} }
function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft { function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
const detailClientById = new Map(lc.details.map((d) => [d.id, d.clientId]));
return { return {
clientId: lc.clientId, clientId: lc.clientId,
id: lc.id, id: lc.id,
destinationOrganizationId: lc.destinationOrganizationId, destinationOrganizationId: lc.destinationOrganizationId,
labComment: lc.labComment ?? '', labComment: lc.labComment ?? '',
detailClientIds: lc.details.map((d) => d.clientId), detailClientIds: lc.details.map((d) => d.clientId),
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
detailClientId:
detailClientById.get(tp.treatmentDetailId) ?? tp.treatmentDetailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
})),
sentAt: lc.sentAt ?? null, sentAt: lc.sentAt ?? null,
sends: lc.sends ?? [], sends: lc.sends ?? [],
}; };
@@ -231,6 +241,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [orgs, setOrgs] = useState<LinkedOrganizationOption[]>([]); const [orgs, setOrgs] = useState<LinkedOrganizationOption[]>([]);
const [labDependentCodes, setLabDependentCodes] = useState<Set<string>>(new Set()); const [labDependentCodes, setLabDependentCodes] = useState<Set<string>>(new Set());
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const [details, setDetails] = useState<TreatmentDetailDraft[]>(() => [newDetail()]); const [details, setDetails] = useState<TreatmentDetailDraft[]>(() => [newDetail()]);
const [labCaseDrafts, setLabCaseDrafts] = useState<LabCaseDraft[]>([]); const [labCaseDrafts, setLabCaseDrafts] = useState<LabCaseDraft[]>([]);
@@ -414,6 +425,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
]); ]);
if (cancelled) return; if (cancelled) return;
setOrgs(orgsResponse.data); setOrgs(orgsResponse.data);
setTreatmentCatalog(catalogResponse.data);
setLabDependentCodes( setLabDependentCodes(
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)), new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
); );
@@ -765,6 +777,17 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
treatmentDetailIds: lc.detailClientIds treatmentDetailIds: lc.detailClientIds
.map((clientId) => detailIdByClientId.get(clientId)) .map((clientId) => detailIdByClientId.get(clientId))
.filter((id): id is string => Boolean(id)), .filter((id): id is string => Boolean(id)),
toothProsthesis: lc.toothProsthesis
.map((tp) => {
const detailId = detailIdByClientId.get(tp.detailClientId);
if (!detailId) return null;
return {
treatmentDetailId: detailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
};
})
.filter((row): row is { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string } => row !== null),
})); }));
if (payload.length === 0) { if (payload.length === 0) {
@@ -881,6 +904,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
appointments={appointments} appointments={appointments}
selectedAppointmentId={selectedAppointmentId} selectedAppointmentId={selectedAppointmentId}
onSelectAppointment={onPickAppointment} onSelectAppointment={onPickAppointment}
treatmentCatalog={treatmentCatalog}
loading={apptsLoading} loading={apptsLoading}
/> />
@@ -906,10 +930,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
</p> </p>
<p className="text-[11px] text-text-secondary"> <p className="text-[11px] text-text-secondary">
{t('purposeLabel')}{' '} {t('purposeLabel')}{' '}
<span className="capitalize text-text-primary"> <span className="text-text-primary">
{t( {treatmentTypeLabelFromCatalog(selectedAppointment.purpose, treatmentCatalog)}
treatmentTypeLabelKey(selectedAppointment.purpose) as 'typeConsultation',
)}
</span> </span>
</p> </p>
</div> </div>
@@ -922,6 +944,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<TreatmentPreviewCard <TreatmentPreviewCard
treatment={previewTreatment} treatment={previewTreatment}
labDependentCodes={labDependentCodes} labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
orgs={orgs} orgs={orgs}
openDisabled={isPreviewAlreadyOpen} openDisabled={isPreviewAlreadyOpen}
onOpen={handleOpenTreatment} onOpen={handleOpenTreatment}
@@ -929,6 +952,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<PastTreatmentsPanel <PastTreatmentsPanel
items={historyPanelItems} items={historyPanelItems}
treatmentCatalog={treatmentCatalog}
loading={historyLoading} loading={historyLoading}
selectedPreviewId={selectedPreviewId} selectedPreviewId={selectedPreviewId}
onSelectTreatment={handleSelectPreviewTreatment} onSelectTreatment={handleSelectPreviewTreatment}
@@ -960,6 +984,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
onDetailsChange={setDetails} onDetailsChange={setDetails}
isDetailLocked={isDetailLocked} isDetailLocked={isDetailLocked}
labDependentCodes={labDependentCodes} labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
disabled={!canEditTreatmentForDay} disabled={!canEditTreatmentForDay}
canEdit={canEdit} canEdit={canEdit}
saveStatus={saveStatus} saveStatus={saveStatus}
@@ -976,6 +1001,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
details={details} details={details}
labCases={labCaseDrafts} labCases={labCaseDrafts}
labDependentCodes={labDependentCodes} labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
activeLabCaseId={activeLabCaseId} activeLabCaseId={activeLabCaseId}
onActiveLabCaseChange={setActiveLabCaseId} onActiveLabCaseChange={setActiveLabCaseId}
onLabCasesChange={setLabCaseDrafts} onLabCasesChange={setLabCaseDrafts}

View File

@@ -1,19 +1,41 @@
export const TREATMENT_TYPE_KEYS = { import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
consultation: 'typeConsultation',
filling: 'typeFilling',
endo: 'typeEndo',
visit: 'typeVisit',
hygiene: 'typeHygiene',
} as const;
export const TREATMENT_TYPE_COLORS: Record<string, string> = { const TREATMENT_TYPE_COLORS: Record<string, string> = {
consultation: '#ddd6fe', restoration: '#fed7aa',
filling: '#fed7aa', specialized_restoration: '#fdba74',
radiography: '#e2e8f0',
endo: '#fecaca', endo: '#fecaca',
visit: '#bae6fd', surgery: '#fca5a5',
hygiene: '#d9f99d', prosthesis: '#c4b5fd',
implant: '#a5b4fc',
orthodontics: '#93c5fd',
perio: '#86efac',
pediatrics: '#fde68a',
extraction: '#f87171',
clinic_visit: '#bae6fd',
}; };
export function treatmentTypeLabelKey(code: string): string { const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d'];
return TREATMENT_TYPE_KEYS[code as keyof typeof TREATMENT_TYPE_KEYS] ?? code;
export function treatmentTypeColor(code: string, index = 0): string {
return TREATMENT_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
}
export function treatmentTypeLabelFromCatalog(
code: string,
catalog: TreatmentCatalogEntry[],
): string {
return catalog.find((e) => e.code === code)?.label ?? formatCodeAsLabel(code);
}
export function formatCodeAsLabel(code: string): string {
return code
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
/** @deprecated Use treatmentTypeLabelFromCatalog with API catalog */
export function treatmentTypeLabelKey(code: string): string {
return `type_${code}`;
} }

View File

@@ -0,0 +1,13 @@
import { apiClient } from './client';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
export const prosthesisCatalogApi = {
list: async (
labOrganizationId?: string,
): Promise<{ success: boolean; data: ProsthesisCatalogEntry[] }> => {
const response = await apiClient.get('/prosthesis-catalog', {
params: labOrganizationId ? { labOrganizationId } : undefined,
});
return response.data;
},
};

View File

@@ -18,6 +18,9 @@ export interface LabCaseTask {
id: string; id: string;
tooth: string; tooth: string;
treatmentType: string; treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
workflowStepCode?: string;
stepOrder: number; stepOrder: number;
stepLabel: string; stepLabel: string;
status: LabTaskStatus; status: LabTaskStatus;
@@ -31,6 +34,8 @@ export interface LabCaseTask {
export interface LabCaseTasksByTooth { export interface LabCaseTasksByTooth {
tooth: string; tooth: string;
treatmentType: string; treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
tasks: LabCaseTask[]; tasks: LabCaseTask[];
} }
@@ -100,6 +105,8 @@ export interface LabTaskListItem {
labCaseId: string; labCaseId: string;
tooth: string; tooth: string;
treatmentType: string; treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
stepOrder: number; stepOrder: number;
stepLabel: string; stepLabel: string;
status: LabTaskStatus; status: LabTaskStatus;

View File

@@ -3,4 +3,11 @@ export interface TreatmentCatalogEntry {
code: string; code: string;
labDependent: boolean; labDependent: boolean;
sortOrder: number; sortOrder: number;
label: string;
}
export interface ProsthesisCatalogEntry {
code: string;
sortOrder: number;
label: string;
} }

View File

@@ -51,15 +51,14 @@ export interface TreatmentAttachmentMeta {
sizeBytes: number; sizeBytes: number;
} }
export const TREATMENT_TYPES = [ export type TreatmentType = string;
'consultation',
'filling',
'endo',
'visit',
'hygiene',
] as const;
export type TreatmentType = (typeof TREATMENT_TYPES)[number]; export interface LabCaseToothProsthesisDraft {
/** Treatment detail client id in the UI; mapped to UUID when saving. */
detailClientId: string;
tooth: string;
prosthesisTypeCode: string;
}
export interface LabCaseSendInfo { export interface LabCaseSendInfo {
organizationId: string; organizationId: string;
@@ -99,6 +98,11 @@ export interface PastLabCase {
treatmentType: string; treatmentType: string;
teeth: FdiToothId[]; teeth: FdiToothId[];
}>; }>;
toothProsthesis?: Array<{
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
}>;
sends?: LabCaseSendInfo[]; sends?: LabCaseSendInfo[];
} }
@@ -141,6 +145,7 @@ export interface LabCaseDraft {
destinationOrganizationId: string | null; destinationOrganizationId: string | null;
labComment: string; labComment: string;
detailClientIds: string[]; detailClientIds: string[];
toothProsthesis: LabCaseToothProsthesisDraft[];
sentAt?: string | null; sentAt?: string | null;
sends?: LabCaseSendInfo[]; sends?: LabCaseSendInfo[];
} }
@@ -163,6 +168,11 @@ export interface SaveLabCasePayload {
destinationOrganizationId?: string; destinationOrganizationId?: string;
labComment?: string; labComment?: string;
treatmentDetailIds: string[]; treatmentDetailIds: string[];
toothProsthesis?: Array<{
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
}>;
} }
export interface SaveTreatmentPayload { export interface SaveTreatmentPayload {
@@ -185,4 +195,5 @@ export interface LabCaseResponse {
teeth: string[]; teeth: string[];
}>; }>;
sends: LabCaseSendInfo[]; sends: LabCaseSendInfo[];
toothProsthesis?: LabCaseToothProsthesisDraft[];
} }