diff --git a/backend/package.json b/backend/package.json index fa41833..ed0e131 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,7 +21,8 @@ "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "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": { "seed": "ts-node prisma/seed.ts" diff --git a/backend/prisma/catalog-seed-data.ts b/backend/prisma/catalog-seed-data.ts new file mode 100644 index 0000000..bfb7a7e --- /dev/null +++ b/backend/prisma/catalog-seed-data.ts @@ -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> = { + 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> = { + 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> = { + 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>, +): 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), +]; diff --git a/backend/prisma/migrations/20260706120000_prosthesis_catalog/migration.sql b/backend/prisma/migrations/20260706120000_prosthesis_catalog/migration.sql new file mode 100644 index 0000000..cf1dfee --- /dev/null +++ b/backend/prisma/migrations/20260706120000_prosthesis_catalog/migration.sql @@ -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"); diff --git a/backend/prisma/reset-treatment-data.ts b/backend/prisma/reset-treatment-data.ts new file mode 100644 index 0000000..1b93fdc --- /dev/null +++ b/backend/prisma/reset-treatment-data.ts @@ -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(); + }); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e453a29..828cc38 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -155,6 +155,7 @@ model TreatmentDetail { attachments TreatmentDetailAttachment[] labCaseLink LabCaseDetail? labCaseTasks LabCaseTask[] + toothProsthesis LabCaseToothProsthesis[] @@index([treatmentId, sortOrder]) @@map("treatment_details") @@ -188,10 +189,11 @@ model LabCase { labComment String? sentAt DateTime? - treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) - details LabCaseDetail[] - sends LabCaseSend[] - tasks LabCaseTask[] + treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) + details LabCaseDetail[] + sends LabCaseSend[] + tasks LabCaseTask[] + toothProsthesis LabCaseToothProsthesis[] @@index([treatmentId, sortOrder]) @@map("lab_cases") @@ -226,36 +228,92 @@ model TreatmentType { code String @unique labDependent Boolean @default(false) sortOrder Int @default(0) - - workflowSteps TreatmentWorkflowStep[] + isActive Boolean @default(true) @@map("treatment_types") } -model TreatmentWorkflowStep { - id String @id @default(uuid()) - treatmentTypeId String - stepOrder Int - label String +enum CatalogEntityKind { + TREATMENT_TYPE + PROSTHESIS_TYPE + LAB_WORKFLOW_STEP +} - 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]) - @@map("treatment_workflow_steps") + @@unique([entityKind, entityCode, locale]) + @@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 { - id String @id @default(uuid()) - labCaseId String - treatmentDetailId String - tooth String - treatmentType String - stepOrder Int - stepLabel String - assigneeUserId String? - assignedAt DateTime? - priority Int @default(3) - status LabTaskStatus @default(PENDING) + id String @id @default(uuid()) + labCaseId String + treatmentDetailId String + tooth String + treatmentType String + prosthesisTypeCode String + workflowStepCode String + stepOrder Int + stepLabel String + assigneeUserId String? + assignedAt DateTime? + priority Int @default(3) + status LabTaskStatus @default(PENDING) labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) @@ -264,7 +322,7 @@ model LabCaseTask { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([labCaseId, tooth, treatmentType, stepOrder]) + @@unique([labCaseId, treatmentDetailId, tooth, stepOrder]) @@index([labCaseId, status]) @@index([assigneeUserId, priority, createdAt]) @@index([assignedAt, labCaseId, priority]) diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index ad968ed..bbe550c 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -3,6 +3,14 @@ import { PrismaClient } from '@prisma/client'; import { randomUUID } from 'crypto'; import { config } from 'dotenv'; 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 const envPath = path.join(__dirname, '..', '.env'); @@ -128,57 +136,118 @@ async function main() { } console.log('✅ Created features and permissions'); - const workflowSteps = [ - { code: 'endo', stepOrder: 1, label: 'Access review' }, - { 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) { + for (const type of [...TREATMENT_TYPES, ...LEGACY_TREATMENT_TYPES]) { + const isActive = TREATMENT_TYPES.some((t) => t.code === type.code); await prisma.treatmentType.upsert({ where: { code: type.code }, - update: { labDependent: type.labDependent, sortOrder: type.sortOrder }, + update: { + labDependent: type.labDependent, + sortOrder: type.sortOrder, + isActive, + }, create: { id: randomUUID(), code: type.code, labDependent: type.labDependent, sortOrder: type.sortOrder, + isActive, }, }); } console.log('✅ Seeded treatment type catalog'); - for (const step of workflowSteps) { - const treatmentType = await prisma.treatmentType.findUniqueOrThrow({ + for (const step of LAB_WORKFLOW_STEPS) { + await prisma.labWorkflowStep.upsert({ where: { code: step.code }, - select: { id: true }, - }); - - await prisma.treatmentWorkflowStep.upsert({ - where: { - treatmentTypeId_stepOrder: { - treatmentTypeId: treatmentType.id, - stepOrder: step.stepOrder, - }, - }, - update: { label: step.label }, + update: { sortOrder: step.sortOrder }, create: { id: randomUUID(), - treatmentTypeId: treatmentType.id, - stepOrder: step.stepOrder, - label: step.label, + code: step.code, + sortOrder: step.sortOrder, }, }); } 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!'); } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e67e7f2..6c2aab6 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -14,6 +14,8 @@ import { TreatmentsModule } from './modules/treatments/treatments.module'; import { CasesModule } from './modules/cases/cases.module'; import { TasksModule } from './modules/tasks/tasks.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({ imports: [ @@ -22,7 +24,9 @@ import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-ca load: [configurations], }), PrismaModule, // ✅ ADD THIS + CatalogModule, TreatmentCatalogModule, + ProsthesisCatalogModule, AuthModule, PatientsModule, AppointmentsModule, diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts index d463022..58ec144 100644 --- a/backend/src/modules/cases/cases.controller.ts +++ b/backend/src/modules/cases/cases.controller.ts @@ -46,7 +46,7 @@ export class CasesController { @ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' }) getOne(@Param('id') id: string, @Req() req) { 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') @@ -58,6 +58,6 @@ export class CasesController { @Req() req, ) { 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); } } diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 401e4d9..733a478 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -4,9 +4,13 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { LabTaskStatus, Prisma } from '@prisma/client'; +import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { normalizeMobile } from '../../common/phone'; +import { + CatalogLabelService, + normalizeCatalogLocale, +} from '../catalog/catalog-label.service'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { normalizeTeeth } from '../treatments/treatment.utils'; import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto'; @@ -52,6 +56,7 @@ export class CasesService { constructor( private readonly prisma: PrismaService, private readonly treatmentCatalog: TreatmentCatalogService, + private readonly catalogLabels: CatalogLabelService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { @@ -142,8 +147,8 @@ export class CasesService { } } - const treatmentTypes = this.treatmentCatalog - .list() + const catalog = await this.treatmentCatalog.list(); + const treatmentTypes = catalog .filter((entry) => entry.labDependent && typeCodes.has(entry.code)) .map((entry) => ({ code: entry.code, labDependent: entry.labDependent })); @@ -218,6 +223,7 @@ export class CasesService { labCaseId: string, clinicOrganizationId: string, labOrganizationId: string, + localeInput?: string | null, ) { const labCase = await this.prisma.labCase.findFirst({ where: { @@ -233,10 +239,18 @@ export class CasesService { 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); const labCase = await this.prisma.labCase.findFirst({ @@ -252,7 +266,7 @@ export class CasesService { throw new NotFoundException('Case not found'); } - return { success: true, data: this.mapLabCaseDetail(labCase) }; + return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) }; } async updateTask( @@ -261,6 +275,7 @@ export class CasesService { dto: UpdateLabCaseTaskDto, labOrganizationId: string, actorUserId: string, + localeInput?: string | null, ) { 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) { @@ -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 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 { id: lc.id, @@ -454,7 +486,7 @@ export class CasesService { organizationName: s.organization.name, sentAt: s.sentAt.toISOString(), })), - tasks: lc.tasks.map((t) => this.mapTask(t)), + tasks: lc.tasks.map((t) => this.mapTask(t, prosthesisLabels)), tasksByTooth, taskProgress: { completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length, @@ -468,6 +500,7 @@ export class CasesService { id: string; tooth: string; treatmentType: string; + prosthesisTypeCode: string; stepOrder: number; stepLabel: string; status: LabTaskStatus; @@ -477,47 +510,62 @@ export class CasesService { createdAt: Date; assignee: { id: string; name: string; email: string } | null; }>, + prosthesisLabels: Map, ) { const groups = new Map< string, { tooth: string; treatmentType: string; + prosthesisTypeCode: string; + prosthesisTypeLabel: string; tasks: ReturnType[]; } >(); for (const task of tasks) { - const key = `${task.tooth}:${task.treatmentType}`; + const key = `${task.tooth}:${task.treatmentType}:${task.prosthesisTypeCode}`; const entry = groups.get(key) ?? { tooth: task.tooth, treatmentType: task.treatmentType, + prosthesisTypeCode: task.prosthesisTypeCode, + prosthesisTypeLabel: + prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode, tasks: [], }; - entry.tasks.push(this.mapTask(task)); + entry.tasks.push(this.mapTask(task, prosthesisLabels)); groups.set(key, entry); } return [...groups.values()]; } - private mapTask(task: { - id: string; - tooth: string; - treatmentType: string; - stepOrder: number; - stepLabel: string; - status: LabTaskStatus; - priority: number; - assigneeUserId: string | null; - assignedAt: Date | null; - createdAt: Date; - assignee: { id: string; name: string; email: string } | null; - }) { + private mapTask( + task: { + id: string; + tooth: string; + treatmentType: string; + prosthesisTypeCode: string; + workflowStepCode?: string; + stepOrder: number; + stepLabel: string; + status: LabTaskStatus; + priority: number; + assigneeUserId: string | null; + assignedAt: Date | null; + createdAt: Date; + assignee: { id: string; name: string; email: string } | null; + }, + prosthesisLabels: Map, + ) { return { id: task.id, tooth: task.tooth, treatmentType: task.treatmentType, + prosthesisTypeCode: task.prosthesisTypeCode, + prosthesisTypeLabel: + prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode, + workflowStepCode: task.workflowStepCode ?? '', stepOrder: task.stepOrder, stepLabel: task.stepLabel, status: task.status, diff --git a/backend/src/modules/cases/lab-case-task.generator.spec.ts b/backend/src/modules/cases/lab-case-task.generator.spec.ts new file mode 100644 index 0000000..56d7350 --- /dev/null +++ b/backend/src/modules/cases/lab-case-task.generator.spec.ts @@ -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; +}) { + 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(); + }); +}); diff --git a/backend/src/modules/cases/lab-case-task.generator.ts b/backend/src/modules/cases/lab-case-task.generator.ts index 8474b03..bac6c3e 100644 --- a/backend/src/modules/cases/lab-case-task.generator.ts +++ b/backend/src/modules/cases/lab-case-task.generator.ts @@ -1,84 +1,83 @@ -import { LabTaskStatus, Prisma } from '@prisma/client'; -import { normalizeTeeth } from '../treatments/treatment.utils'; +import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client'; +import { normalizeCatalogLocale } from '../catalog/catalog-label.service'; type TransactionClient = Prisma.TransactionClient; export async function generateLabCaseTasks( tx: TransactionClient, labCaseId: string, + localeInput?: string | null, ): Promise { const existingCount = await tx.labCaseTask.count({ where: { labCaseId } }); if (existingCount > 0) { return 0; } - const labCase = await tx.labCase.findUnique({ - where: { id: labCaseId }, + const locale = normalizeCatalogLocale(localeInput); + + const toothProsthesisRows = await tx.labCaseToothProsthesis.findMany({ + where: { labCaseId }, include: { - details: { - include: { - detail: { - select: { id: true, treatmentType: true, teeth: true }, - }, - }, + detail: { select: { id: true, treatmentType: 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) { - return 0; - } + const stepsByProsthesisCode = new Map( + 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({ - 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(); - 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 stepLabels = await resolveStepLabels(tx, allStepCodes, locale); const taskRows: Prisma.LabCaseTaskCreateManyInput[] = []; - for (const link of labCase.details) { - const detail = link.detail; - if (!labDependentCodes.has(detail.treatmentType)) { + for (const row of toothProsthesisRows) { + const typeSteps = stepsByProsthesisCode.get(row.prosthesisTypeCode) ?? []; + if (typeSteps.length === 0) { continue; } - const teeth = normalizeTeeth(detail.teeth); - const typeSteps = stepsByTypeCode.get(detail.treatmentType) ?? []; - - for (const tooth of teeth) { - for (const step of typeSteps) { - taskRows.push({ - labCaseId, - treatmentDetailId: detail.id, - tooth, - treatmentType: detail.treatmentType, - stepOrder: step.stepOrder, - stepLabel: step.label, - status: LabTaskStatus.PENDING, - }); - } + for (const step of typeSteps) { + taskRows.push({ + labCaseId, + treatmentDetailId: row.treatmentDetailId, + tooth: row.tooth, + treatmentType: row.detail.treatmentType, + prosthesisTypeCode: row.prosthesisTypeCode, + workflowStepCode: step.workflowStepCode, + stepOrder: step.stepOrder, + stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode, + status: LabTaskStatus.PENDING, + }); } } @@ -89,3 +88,37 @@ export async function generateLabCaseTasks( await tx.labCaseTask.createMany({ data: taskRows }); return taskRows.length; } + +async function resolveStepLabels( + tx: TransactionClient, + stepCodes: string[], + locale: string, +): Promise> { + 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(); + 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(); + for (const code of stepCodes) { + const entry = byCode.get(code); + result.set(code, entry?.locale ?? entry?.en ?? code); + } + return result; +} diff --git a/backend/src/modules/catalog/catalog-label.service.ts b/backend/src/modules/catalog/catalog-label.service.ts new file mode 100644 index 0000000..7eb4206 --- /dev/null +++ b/backend/src/modules/catalog/catalog-label.service.ts @@ -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> { + 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(); + 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(); + 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 { + 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(' '); +} diff --git a/backend/src/modules/catalog/catalog.module.ts b/backend/src/modules/catalog/catalog.module.ts new file mode 100644 index 0000000..da02200 --- /dev/null +++ b/backend/src/modules/catalog/catalog.module.ts @@ -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 {} diff --git a/backend/src/modules/organization/organization.controller.ts b/backend/src/modules/organization/organization.controller.ts index 90db4e4..9f3ff7d 100644 --- a/backend/src/modules/organization/organization.controller.ts +++ b/backend/src/modules/organization/organization.controller.ts @@ -143,7 +143,7 @@ export class OrganizationController { @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Get one case exchanged with a connected organization' }) getConnectionCase( - @Req() req: { user: { id: string; organizationId?: string } }, + @Req() req: { user: { id: string; organizationId?: string; language?: string | null } }, @Param('connectionId') connectionId: string, @Param('caseId') caseId: string, ) { @@ -153,6 +153,7 @@ export class OrganizationController { organizationId, connectionId, caseId, + req.user.language, ); } diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index fc13621..6abb5b1 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -379,6 +379,7 @@ export class OrganizationService { organizationId: string, connectionId: string, caseId: string, + localeInput?: string | null, ) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditOrganizations(actor)) { @@ -392,6 +393,7 @@ export class OrganizationService { caseId, clinicOrganizationId, labOrganizationId, + localeInput, ); return { diff --git a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.controller.ts b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.controller.ts new file mode 100644 index 0000000..f0c7bf5 --- /dev/null +++ b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.controller.ts @@ -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, + })); + } +} diff --git a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.module.ts b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.module.ts new file mode 100644 index 0000000..17933b1 --- /dev/null +++ b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.module.ts @@ -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 {} diff --git a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts new file mode 100644 index 0000000..7a0f0d2 --- /dev/null +++ b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts @@ -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(); + + constructor( + private readonly prisma: PrismaService, + private readonly catalogLabels: CatalogLabelService, + ) {} + + async onModuleInit() { + await this.refresh(); + } + + async refresh(): Promise { + 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 { + 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 { + 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> { + return this.catalogLabels.resolveLabels( + CatalogEntityKind.LAB_WORKFLOW_STEP, + stepCodes, + locale, + ); + } + + private ensureLoaded() { + if (!this.loaded) { + throw new Error('Prosthesis catalog is not loaded yet'); + } + } +} diff --git a/backend/src/modules/tasks/tasks.controller.ts b/backend/src/modules/tasks/tasks.controller.ts index a2125e4..0cce614 100644 --- a/backend/src/modules/tasks/tasks.controller.ts +++ b/backend/src/modules/tasks/tasks.controller.ts @@ -16,7 +16,7 @@ export class TasksController { @ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' }) list(@Query() query: ListLabTasksDto, @Req() req) { 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') @@ -27,6 +27,12 @@ export class TasksController { @Req() req, ) { 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, + ); } } diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index ce7872a..66fd7c4 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -4,8 +4,12 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { LabTaskStatus, Prisma } from '@prisma/client'; +import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; +import { + CatalogLabelService, + normalizeCatalogLocale, +} from '../catalog/catalog-label.service'; import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto'; const taskListInclude = { @@ -24,7 +28,10 @@ const taskListInclude = { @Injectable() export class TasksService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly catalogLabels: CatalogLabelService, + ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { @@ -33,7 +40,12 @@ export class TasksService { 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); const membership = await this.getMembership(actorUserId, labOrganizationId); @@ -71,10 +83,18 @@ export class TasksService { 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 { success: true, data: { - items: items.map((task) => this.mapTaskListItem(task)), + items: items.map((task) => this.mapTaskListItem(task, prosthesisLabels)), pagination: { page, limit, @@ -90,6 +110,7 @@ export class TasksService { dto: UpdateLabTaskDto, labOrganizationId: string, actorUserId: string, + localeInput?: string | null, ) { await this.assertCanEditTasks(actorUserId, labOrganizationId); @@ -123,17 +144,28 @@ export class TasksService { 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( task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>, + prosthesisLabels: Map, ) { return { id: task.id, labCaseId: task.labCaseId, tooth: task.tooth, treatmentType: task.treatmentType, + prosthesisTypeCode: task.prosthesisTypeCode, + prosthesisTypeLabel: + prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode, stepOrder: task.stepOrder, stepLabel: task.stepLabel, status: task.status, diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts b/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts index 7e26db5..062a603 100644 --- a/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts +++ b/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts @@ -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 { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { TreatmentCatalogService } from './treatment-catalog.service'; @@ -11,11 +11,9 @@ export class TreatmentCatalogController { constructor(private readonly treatmentCatalogService: TreatmentCatalogService) {} @Get() - @ApiOperation({ summary: 'List treatment types from the catalog (data-driven)' }) - list() { - return { - success: true, - data: this.treatmentCatalogService.list(), - }; + @ApiOperation({ summary: 'List active treatment types with localized labels' }) + async list(@Req() req: { user?: { language?: string | null } }) { + const data = await this.treatmentCatalogService.list(req.user?.language); + return { success: true, data }; } } diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.service.ts b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts index e6c8c9e..7baf921 100644 --- a/backend/src/modules/treatment-catalog/treatment-catalog.service.ts +++ b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts @@ -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 { + CatalogLabelService, + normalizeCatalogLocale, +} from '../catalog/catalog-label.service'; export type TreatmentTypeCatalogEntry = { id: string; code: string; labDependent: boolean; sortOrder: number; + label: string; }; @Injectable() @@ -13,7 +20,10 @@ export class TreatmentCatalogService implements OnModuleInit { private loaded = false; private byCode = new Map(); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly catalogLabels: CatalogLabelService, + ) {} async onModuleInit() { await this.refresh(); @@ -21,17 +31,46 @@ export class TreatmentCatalogService implements OnModuleInit { async refresh(): Promise { const rows = await this.prisma.treatmentType.findMany({ + where: { isActive: true }, orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }], 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; } - list(): TreatmentTypeCatalogEntry[] { + async list(localeInput?: string | null): Promise { + 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(); - 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 { diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts index 3c0312e..0b78f62 100644 --- a/backend/src/modules/treatments/dto/treatment.dto.ts +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -45,6 +45,19 @@ export class SaveTreatmentDraftDto { details: SaveTreatmentDetailDto[]; } +export class LabCaseToothProsthesisDto { + @IsUUID() + treatmentDetailId: string; + + @IsString() + @MaxLength(8) + tooth: string; + + @IsString() + @MaxLength(64) + prosthesisTypeCode: string; +} + export class SaveLabCaseDto { @IsString() @MaxLength(64) @@ -67,6 +80,12 @@ export class SaveLabCaseDto { @ArrayMinSize(1) @IsUUID(undefined, { each: true }) treatmentDetailIds: string[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LabCaseToothProsthesisDto) + toothProsthesis?: LabCaseToothProsthesisDto[]; } export class SaveTreatmentLabCasesDto { diff --git a/backend/src/modules/treatments/lab-case-send.validation.spec.ts b/backend/src/modules/treatments/lab-case-send.validation.spec.ts new file mode 100644 index 0000000..3bab06e --- /dev/null +++ b/backend/src/modules/treatments/lab-case-send.validation.spec.ts @@ -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'); + }); +}); diff --git a/backend/src/modules/treatments/lab-case-send.validation.ts b/backend/src/modules/treatments/lab-case-send.validation.ts new file mode 100644 index 0000000..4a28382 --- /dev/null +++ b/backend/src/modules/treatments/lab-case-send.validation.ts @@ -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})`, + ); + } + } + } +} diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts index 8a9eb85..da02dcb 100644 --- a/backend/src/modules/treatments/treatments.controller.ts +++ b/backend/src/modules/treatments/treatments.controller.ts @@ -184,9 +184,14 @@ export class TreatmentsController { @ApiOperation({ summary: 'Send a lab case to its destination organization (TAB_TREATMENT_EDIT)' }) sendLabCase( @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); - return this.treatmentsService.sendLabCase(labCaseId, organizationId, req.user.id); + return this.treatmentsService.sendLabCase( + labCaseId, + organizationId, + req.user.id, + req.user.language, + ); } } diff --git a/backend/src/modules/treatments/treatments.module.ts b/backend/src/modules/treatments/treatments.module.ts index 47646b1..a587c2d 100644 --- a/backend/src/modules/treatments/treatments.module.ts +++ b/backend/src/modules/treatments/treatments.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; +import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module'; import { TreatmentsController } from './treatments.controller'; import { TreatmentsService } from './treatments.service'; @Module({ + imports: [ProsthesisCatalogModule], controllers: [TreatmentsController], providers: [TreatmentsService, PrismaService, ClinicOrgGuard], }) diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index 51e96a7..e2aed47 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -10,6 +10,7 @@ import { join } from 'path'; import { randomUUID } from 'crypto'; import { PrismaService } from '../../../prisma/prisma.service'; 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 { SaveTreatmentDraftDto, @@ -19,6 +20,7 @@ import { generateTreatmentTitle, normalizeTeeth, } from './treatment.utils'; +import { assertCompleteToothProsthesisMap } from './lab-case-send.validation'; const treatmentInclude = { details: { @@ -53,6 +55,7 @@ const treatmentInclude = { orderBy: [{ sentAt: 'asc' as const }], include: { organization: { select: { id: true, name: true } } }, }, + toothProsthesis: true, }, }, }; @@ -64,6 +67,7 @@ export class TreatmentsService { constructor( private readonly prisma: PrismaService, private readonly treatmentCatalog: TreatmentCatalogService, + private readonly prosthesisCatalog: ProsthesisCatalogService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { @@ -326,7 +330,7 @@ export class TreatmentsService { const details = await this.prisma.treatmentDetail.findMany({ where: { treatmentId: treatment.id, id: { in: detailIds } }, - select: { id: true, treatmentType: true }, + select: { id: true, treatmentType: true, teeth: true }, }); if (details.length !== uniqueDetailIds.size) { throw new BadRequestException('One or more treatment details were not found'); @@ -336,12 +340,30 @@ export class TreatmentsService { this.treatmentCatalog.assertLabDependentTreatmentType(detail.treatmentType); } + const detailById = new Map(details.map((d) => [d.id, d])); const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); for (const lc of dto.labCases) { if (lc.destinationOrganizationId && !linkedOrgIds.has(lc.destinationOrganizationId)) { 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) => { @@ -395,6 +417,18 @@ export class TreatmentsService { 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({ @@ -410,6 +444,7 @@ export class TreatmentsService { labCaseId: string, organizationId: string, actorUserId: string, + actorLanguage?: string | null, ) { await this.assertCanEditTreatment(actorUserId, organizationId); @@ -421,7 +456,12 @@ export class TreatmentsService { include: { treatment: { select: { providerUserId: 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'); } + assertCompleteToothProsthesisMap(labCase); + if (labCase.treatment.providerUserId !== actorUserId) { const membership = await this.getMembership(actorUserId, organizationId); 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({ @@ -490,6 +532,7 @@ export class TreatmentsService { orderBy: [{ sentAt: 'asc' }], include: { organization: { select: { id: true, name: true } } }, }, + toothProsthesis: true, }, }); @@ -722,6 +765,11 @@ export class TreatmentsService { sentAt: Date; organization?: { id: string; name: string }; }>; + toothProsthesis?: Array<{ + treatmentDetailId: string; + tooth: string; + prosthesisTypeCode: string; + }>; }) { return { id: lc.id, @@ -736,6 +784,11 @@ export class TreatmentsService { treatmentType: d.detail?.treatmentType ?? '', teeth: d.detail ? normalizeTeeth(d.detail.teeth) : [], })), + toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({ + treatmentDetailId: tp.treatmentDetailId, + tooth: tp.tooth, + prosthesisTypeCode: tp.prosthesisTypeCode, + })), sends: lc.sends?.map((s) => ({ organizationId: s.organizationId, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a8fcaab..93c9679 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -330,7 +330,7 @@ "treatmentDetails": "Treatment details", "teethLabel": "Teeth", "tasksByTooth": "Tasks by tooth", - "toothGroupTitle": "Tooth {tooth} · {type}", + "toothGroupTitle": "Tooth {tooth} · {prosthesis} · {type}", "noTasks": "No tasks were generated for this case.", "unassigned": "Unassigned", "statusPending": "Pending", @@ -497,8 +497,14 @@ "labShipmentNoIncludedDetails": "No details were included in this shipment.", "labShipmentNoDetailsAvailable": "All lab details are already in other shipments or have been sent.", "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.", + "prosthesisTypesTitle": "Prosthesis types", + "prosthesisApplyAll": "Apply to all teeth", + "prosthesisSelectPlaceholder": "Select prosthesis type…", + "prosthesisColTooth": "Tooth", + "prosthesisColDetail": "Detail", + "prosthesisColType": "Prosthesis type", "labComment": "Message for the lab", "labCommentPlaceholder": "Optional instructions for this shipment…", "selectLab": "Destination lab", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 36e2021..388c300 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -330,7 +330,7 @@ "treatmentDetails": "جزئیات درمان", "teethLabel": "دندان‌ها", "tasksByTooth": "وظایف به تفکیک دندان", - "toothGroupTitle": "دندان {tooth} · {type}", + "toothGroupTitle": "دندان {tooth} · {prosthesis} · {type}", "noTasks": "برای این پرونده وظیفه‌ای ایجاد نشده است.", "unassigned": "بدون مسئول", "statusPending": "در انتظار", @@ -497,8 +497,14 @@ "labShipmentNoIncludedDetails": "جزئیاتی در این محموله گنجانده نشده است.", "labShipmentNoDetailsAvailable": "همه جزئیات لاب در محموله‌های دیگر هستند یا ارسال شده‌اند.", "labDetailLine": "جزئیات {n} · {type} · {teeth}", - "noLabDetails": "هنوز جزئیات وابسته به لاب وجود ندارد. نوع لاب (مثلاً اندو) در جزئیات درمان بالا اضافه کنید.", + "noLabDetails": "هنوز جزئیات پروتز وجود ندارد. پروتز را در جزئیات درمان بالا اضافه کنید.", "labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروه‌بندی و ارسال کنید.", + "prosthesisTypesTitle": "انواع پروتز", + "prosthesisApplyAll": "اعمال برای همه دندان‌ها", + "prosthesisSelectPlaceholder": "نوع پروتز را انتخاب کنید…", + "prosthesisColTooth": "دندان", + "prosthesisColDetail": "جزئیات", + "prosthesisColType": "نوع پروتز", "labComment": "پیام برای لابراتوار", "labCommentPlaceholder": "دستورالعمل اختیاری برای این محموله…", "selectLab": "لابراتوار مقصد", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 0097a5a..ab69c7a 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -330,7 +330,7 @@ "treatmentDetails": "Behandeldetails", "teethLabel": "Tanden", "tasksByTooth": "Taken per tand", - "toothGroupTitle": "Tand {tooth} · {type}", + "toothGroupTitle": "Tand {tooth} · {prosthesis} · {type}", "noTasks": "Er zijn geen taken gegenereerd voor dit dossier.", "unassigned": "Niet toegewezen", "statusPending": "In afwachting", @@ -497,8 +497,14 @@ "labShipmentNoIncludedDetails": "Geen details opgenomen in deze zending.", "labShipmentNoDetailsAvailable": "Alle labdetails zitten al in andere zendingen of zijn verzonden.", "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.", + "prosthesisTypesTitle": "Prothesetypes", + "prosthesisApplyAll": "Toepassen op alle tanden", + "prosthesisSelectPlaceholder": "Selecteer prothesetype…", + "prosthesisColTooth": "Tand", + "prosthesisColDetail": "Detail", + "prosthesisColType": "Prothesetype", "labComment": "Bericht voor het lab", "labCommentPlaceholder": "Optionele instructies voor deze zending…", "selectLab": "Bestemmingslab", diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx index 9dddf11..31bf060 100644 --- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -10,9 +10,12 @@ import { useToast } from '@/lib/hooks/useToast'; import { canEditCases } from '@/components/shared/permissions'; import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge'; 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 { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; import { SearchBar } from '@/components/ui/shared/SearchBar'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { AssignableMember, CasesFilterOptions, @@ -22,14 +25,6 @@ import type { PaginatedLabCases, } from '@/types/cases'; -const TREATMENT_TYPE_KEYS = { - consultation: 'typeConsultation', - filling: 'typeFilling', - endo: 'typeEndo', - visit: 'typeVisit', - hygiene: 'typeHygiene', -} as const; - const PAGE_SIZE = 20; const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const; @@ -101,6 +96,7 @@ export default function CasesPage() { clinics: [], treatmentTypes: [], }); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); const [selectedCaseId, setSelectedCaseId] = useState(null); const [selectedCase, setSelectedCase] = useState(null); @@ -113,11 +109,8 @@ export default function CasesPage() { const locale = user?.language ?? 'en'; const treatmentLabel = useCallback( - (type: string) => { - const key = TREATMENT_TYPE_KEYS[type as keyof typeof TREATMENT_TYPE_KEYS]; - return key ? tTreatment(key) : type; - }, - [tTreatment], + (type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog), + [treatmentCatalog], ); const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( @@ -179,6 +172,7 @@ export default function CasesPage() { useEffect(() => { void casesApi.listFilterOptions().then((r) => setFilterOptions(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 }, []); @@ -490,6 +484,7 @@ export default function CasesPage() {
{t('toothGroupTitle', { tooth: group.tooth, + prosthesis: group.prosthesisTypeLabel, type: treatmentLabel(group.treatmentType), })}
diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx index 775321c..9ef5763 100644 --- a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx @@ -12,7 +12,10 @@ import { canEditTasks, canViewTasks } from '@/components/shared/permissions'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; 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 { TreatmentCatalogEntry } from '@/types/treatment-catalog'; const PAGE_SIZE = 50; @@ -46,6 +49,7 @@ export default function TasksPage() { const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [updatingTaskId, setUpdatingTaskId] = useState(null); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); const canView = canViewTasks(currentOrganization); const canEdit = canEditTasks(currentOrganization); @@ -64,6 +68,10 @@ export default function TasksPage() { [t], ); + useEffect(() => { + void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); + }, []); + useEffect(() => { if (!canView) return; @@ -166,6 +174,7 @@ export default function TasksPage() {

{t('fromClinic', { name: task.clinic.name })} ·{' '} {formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })} + {task.prosthesisTypeLabel ? ` · ${task.prosthesisTypeLabel}` : ''}

{t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })} @@ -213,7 +222,10 @@ export default function TasksPage() { {t('priorityLabel', { n: task.priority })} - + ); diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx index b95dc33..a148755 100644 --- a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx +++ b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx @@ -6,20 +6,15 @@ import { formatApiErrorMessage } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; 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 { Button } from '@/components/ui/shared/Button'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import { ToastStack } from '@/components/ui/shared/Toast'; import type { CounterpartItemDto } from '@/lib/api/organization'; import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases'; - -const TREATMENT_TYPE_KEYS = { - consultation: 'typeConsultation', - filling: 'typeFilling', - endo: 'typeEndo', - visit: 'typeVisit', - hygiene: 'typeHygiene', -} as const; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; const PAGE_SIZE = 20; @@ -78,7 +73,6 @@ export function ConnectionCaseHistoryContent({ }: ConnectionCaseHistoryContentProps) { const t = useTranslations('organizations'); const tCases = useTranslations('cases'); - const tTreatment = useTranslations('treatment'); const tCommon = useTranslations('common'); const { currentOrganization, user } = useAuth(); const { showError, setError, messages: toastMessages } = useToast(); @@ -94,6 +88,7 @@ export function ConnectionCaseHistoryContent({ }); const [selectedCaseId, setSelectedCaseId] = useState(null); const [selectedCase, setSelectedCase] = useState(null); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); const [loadingList, setLoadingList] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false); @@ -104,13 +99,14 @@ export function ConnectionCaseHistoryContent({ tRef.current = t; const treatmentLabel = useCallback( - (type: string) => { - const key = TREATMENT_TYPE_KEYS[type as keyof typeof TREATMENT_TYPE_KEYS]; - return key ? tTreatment(key) : type; - }, - [tTreatment], + (type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog), + [treatmentCatalog], ); + useEffect(() => { + void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); + }, []); + const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( () => [ { value: 'PENDING', label: tCases('statusPending') }, @@ -375,6 +371,7 @@ export function ConnectionCaseHistoryContent({

{tCases('toothGroupTitle', { tooth: group.tooth, + prosthesis: group.prosthesisTypeLabel, type: treatmentLabel(group.treatmentType), })}
diff --git a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx index 20f4f29..527b9ee 100644 --- a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx +++ b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx @@ -6,16 +6,10 @@ import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeSty import { Card } from '@/components/ui/shared/Card'; import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; 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'; -const TREATMENT_TYPE_KEYS = { - consultation: 'typeConsultation', - filling: 'typeFilling', - endo: 'typeEndo', - visit: 'typeVisit', - hygiene: 'typeHygiene', -} as const; - interface AppointmentsStripProps { stripHidden: boolean; onToggleStripHidden: () => void; @@ -24,6 +18,7 @@ interface AppointmentsStripProps { appointments: TreatmentAppointment[]; selectedAppointmentId: string | null; onSelectAppointment: (id: string) => void; + treatmentCatalog: TreatmentCatalogEntry[]; loading?: boolean; } @@ -35,6 +30,7 @@ export function AppointmentsStrip({ appointments, selectedAppointmentId, onSelectAppointment, + treatmentCatalog, loading = false, }: AppointmentsStripProps) { const t = useTranslations('treatment'); @@ -96,7 +92,7 @@ export function AppointmentsStrip({ minute: '2-digit', })}`; 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 ( {a.patientFirstName} {a.patientLastName}

-

- {purposeKey ? t(purposeKey) : a.purpose} -

+

{purposeLabel}

); })} diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx index a2ab79c..4b2965a 100644 --- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx +++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx @@ -1,20 +1,24 @@ 'use client'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Dropdown } from '@/components/ui/shared/Dropdown'; +import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel'; 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'; interface LabCasesDispatchPanelProps { details: TreatmentDetailDraft[]; labCases: LabCaseDraft[]; labDependentCodes: Set; + treatmentCatalog: TreatmentCatalogEntry[]; activeLabCaseId: string | null; onActiveLabCaseChange: (id: string) => void; onLabCasesChange: (labCases: LabCaseDraft[]) => void; @@ -52,7 +56,6 @@ function detailInOtherDraftShipment( ); } -/** Lab-dependent details not yet sent to any lab. */ function unsentLabDetails( details: TreatmentDetailDraft[], labCases: LabCaseDraft[], @@ -62,7 +65,6 @@ function unsentLabDetails( return details.filter((d) => labDependentCodes.has(d.treatmentType) && !sent.has(d.clientId)); } -/** Unsent lab details not already assigned to another draft shipment. */ function detailsAvailableForNewShipment( details: TreatmentDetailDraft[], labCases: LabCaseDraft[], @@ -73,7 +75,6 @@ function detailsAvailableForNewShipment( ); } -/** Details the user can pick for the active draft shipment. */ function selectableDetailsForDraftShipment( details: TreatmentDetailDraft[], 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({ details, labCases, labDependentCodes, + treatmentCatalog, activeLabCaseId, onActiveLabCaseChange, onLabCasesChange, @@ -108,6 +139,9 @@ export function LabCasesDispatchPanel({ onSendLabCase, }: LabCasesDispatchPanelProps) { const t = useTranslations('treatment'); + const [prosthesisOptions, setProsthesisOptions] = useState([]); + const [applyAllProsthesis, setApplyAllProsthesis] = useState(''); + const activeLinkedOrganizations = orgs.filter((o) => o.active); const filteredOrganizations = (() => { const q = organizationSearch.trim().toLowerCase(); @@ -136,17 +170,39 @@ export function LabCasesDispatchPanel({ ? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name : 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) { const idx = details.findIndex((row) => row.clientId === d.clientId); return idx >= 0 ? idx + 1 : 0; } function detailSummary(d: TreatmentDetailDraft) { - const typeKey = treatmentTypeLabelKey(d.treatmentType); - const typeLabel = - d.treatmentType in TREATMENT_TYPE_KEYS - ? t(typeKey as 'typeEndo') - : d.treatmentType; + const typeLabel = treatmentTypeLabelFromCatalog(d.treatmentType, treatmentCatalog); const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone'); 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) { if (!activeLabCase || sent) return; @@ -169,7 +250,12 @@ export function LabCasesDispatchPanel({ const set = new Set(lc.detailClientIds); if (checked) set.add(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) { @@ -375,11 +461,14 @@ export function LabCasesDispatchPanel({ )} + onChange={(e) => { + const nextOrgId = e.target.value || null; updateActiveLabCase({ - destinationOrganizationId: e.target.value || null, - }) - } + destinationOrganizationId: nextOrgId, + toothProsthesis: [], + }); + setApplyAllProsthesis(''); + }} disabled={disabled || filteredOrganizations.length === 0} > @@ -394,6 +483,84 @@ export function LabCasesDispatchPanel({ )} + {prosthesisRows.length > 0 && activeLabCase.destinationOrganizationId ? ( +
+

+ {t('prosthesisTypesTitle')} +

+ +
+ + + + + + + + + + {prosthesisRows.map((row) => { + const current = + activeLabCase.toothProsthesis.find( + (tp) => + tp.detailClientId === row.detailClientId && + tp.tooth === row.tooth, + )?.prosthesisTypeCode ?? ''; + return ( + + + + + + ); + })} + +
{t('prosthesisColTooth')}{t('prosthesisColDetail')}{t('prosthesisColType')}
{row.tooth} + {t('detailLabel', { n: row.detailNumber })} + + +
+
+
+ ) : null} +
))} diff --git a/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx b/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx index ea16c8a..1b617ac 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx @@ -3,12 +3,15 @@ import { useTranslations } from 'next-intl'; import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge'; 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'; interface TreatmentDetailSummaryRowProps { detail: PastTreatmentDetail; detailNumber: number; labDependentCodes: Set; + treatmentCatalog: TreatmentCatalogEntry[]; orgs?: LinkedOrganizationOption[]; compact?: boolean; } @@ -17,6 +20,7 @@ export function TreatmentDetailSummaryRow({ detail, detailNumber, labDependentCodes, + treatmentCatalog, orgs, compact = false, }: TreatmentDetailSummaryRowProps) { @@ -35,7 +39,10 @@ export function TreatmentDetailSummaryRow({ {t('detailLabel', { n: detailNumber })} - + diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx index ab4b388..aa867af 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx @@ -10,7 +10,8 @@ import { labSentBannerClass, } from '@/components/ui/treatment/treatmentStatusStyles'; 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 { details: TreatmentDetailDraft[]; @@ -19,6 +20,7 @@ interface TreatmentDetailsEditorProps { onDetailsChange: (details: TreatmentDetailDraft[]) => void; isDetailLocked: (detail: TreatmentDetailDraft) => boolean; labDependentCodes: Set; + treatmentCatalog: TreatmentCatalogEntry[]; disabled: boolean; canEdit: boolean; saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error'; @@ -34,6 +36,7 @@ export function TreatmentDetailsEditor({ onDetailsChange, isDetailLocked, labDependentCodes, + treatmentCatalog, disabled, canEdit, saveStatus, @@ -49,7 +52,10 @@ export function TreatmentDetailsEditor({ const locked = isDetailLocked(activeDetail); 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 showPendingLabHint = isLabDependent && !locked && !readOnly; @@ -125,14 +131,20 @@ export function TreatmentDetailsEditor({ ); }} disabled={readOnly} - className="capitalize" style={{ color: treatmentTypeTextColor }} > - - - - - + {treatmentCatalog.map((entry, index) => ( + + ))}
diff --git a/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx b/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx index d549190..606190b 100644 --- a/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx +++ b/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx @@ -2,16 +2,20 @@ import { useTranslations } from 'next-intl'; 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'; interface TreatmentHistoryDetailLineProps { detail: PastTreatmentDetail; detailNumber: number; + treatmentCatalog: TreatmentCatalogEntry[]; } export function TreatmentHistoryDetailLine({ detail, detailNumber, + treatmentCatalog, }: TreatmentHistoryDetailLineProps) { const t = useTranslations('treatment'); const teeth = detail.teeth.length ? [...detail.teeth].sort().join(', ') : t('teethNone'); @@ -20,7 +24,10 @@ export function TreatmentHistoryDetailLine({ return (
{detailNumber}. - + {teeth} {attachmentCount > 0 && ( diff --git a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx index 4349be3..67385c0 100644 --- a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx +++ b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx @@ -4,10 +4,12 @@ import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow'; import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; interface TreatmentPreviewCardProps { treatment: PastTreatment | null; labDependentCodes: Set; + treatmentCatalog: TreatmentCatalogEntry[]; orgs?: LinkedOrganizationOption[]; openDisabled?: boolean; onOpen: () => void; @@ -16,6 +18,7 @@ interface TreatmentPreviewCardProps { export function TreatmentPreviewCard({ treatment, labDependentCodes, + treatmentCatalog, orgs, openDisabled = false, onOpen, @@ -53,6 +56,7 @@ export function TreatmentPreviewCard({ detail={detail} detailNumber={idx + 1} labDependentCodes={labDependentCodes} + treatmentCatalog={treatmentCatalog} orgs={orgs} compact /> diff --git a/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx b/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx index 9c01bb6..8f8cc55 100644 --- a/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx +++ b/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx @@ -1,25 +1,22 @@ 'use client'; -import { useTranslations } from 'next-intl'; 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 { type: string; + label?: string; className?: string; } -export function TreatmentTypeBadge({ type, className = '' }: TreatmentTypeBadgeProps) { - const t = useTranslations('treatment'); - const typeKey = treatmentTypeLabelKey(type); - const label = - type in TREATMENT_TYPE_KEYS ? t(typeKey as 'typeEndo') : type; +export function TreatmentTypeBadge({ type, label, className = '' }: TreatmentTypeBadgeProps) { + const display = label ?? formatCodeAsLabel(type); return ( - {label} + {display} ); } diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 8d0d40e..b38cb1f 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -9,7 +9,7 @@ import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPan import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor'; import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard'; import { ToastStack } from '@/components/ui/shared/Toast'; -import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; import { addCalendarDays, compareLocalDayStart, @@ -25,6 +25,7 @@ import { formatApiErrorMessage } from '@/components/shared/formatApiError'; import { useToast } from '@/lib/hooks/useToast'; import type { Organization } from '@/types/organization'; import type { AppointmentRecord } from '@/types/appointment'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { FdiToothId, LabCaseDraft, @@ -93,7 +94,7 @@ function newDetail(): TreatmentDetailDraft { typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - treatmentType: 'consultation', + treatmentType: 'restoration', teeth: [], comment: '', attachmentMetas: [], @@ -111,6 +112,7 @@ function newLabCaseDraft(): LabCaseDraft { destinationOrganizationId: null, labComment: '', detailClientIds: [], + toothProsthesis: [], sentAt: null, sends: [], }; @@ -145,12 +147,20 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft { } function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft { + const detailClientById = new Map(lc.details.map((d) => [d.id, d.clientId])); + return { clientId: lc.clientId, id: lc.id, destinationOrganizationId: lc.destinationOrganizationId, labComment: lc.labComment ?? '', 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, sends: lc.sends ?? [], }; @@ -231,6 +241,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor const [orgs, setOrgs] = useState([]); const [labDependentCodes, setLabDependentCodes] = useState>(new Set()); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); const [details, setDetails] = useState(() => [newDetail()]); const [labCaseDrafts, setLabCaseDrafts] = useState([]); @@ -414,6 +425,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor ]); if (cancelled) return; setOrgs(orgsResponse.data); + setTreatmentCatalog(catalogResponse.data); setLabDependentCodes( 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 .map((clientId) => detailIdByClientId.get(clientId)) .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) { @@ -881,6 +904,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor appointments={appointments} selectedAppointmentId={selectedAppointmentId} onSelectAppointment={onPickAppointment} + treatmentCatalog={treatmentCatalog} loading={apptsLoading} /> @@ -906,10 +930,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor

{t('purposeLabel')}{' '} - - {t( - treatmentTypeLabelKey(selectedAppointment.purpose) as 'typeConsultation', - )} + + {treatmentTypeLabelFromCatalog(selectedAppointment.purpose, treatmentCatalog)}

@@ -922,6 +944,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor = { - consultation: '#ddd6fe', - filling: '#fed7aa', +const TREATMENT_TYPE_COLORS: Record = { + restoration: '#fed7aa', + specialized_restoration: '#fdba74', + radiography: '#e2e8f0', endo: '#fecaca', - visit: '#bae6fd', - hygiene: '#d9f99d', + surgery: '#fca5a5', + prosthesis: '#c4b5fd', + implant: '#a5b4fc', + orthodontics: '#93c5fd', + perio: '#86efac', + pediatrics: '#fde68a', + extraction: '#f87171', + clinic_visit: '#bae6fd', }; -export function treatmentTypeLabelKey(code: string): string { - return TREATMENT_TYPE_KEYS[code as keyof typeof TREATMENT_TYPE_KEYS] ?? code; +const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d']; + +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}`; } diff --git a/frontend/src/lib/api/prosthesis-catalog.ts b/frontend/src/lib/api/prosthesis-catalog.ts new file mode 100644 index 0000000..1e89e0a --- /dev/null +++ b/frontend/src/lib/api/prosthesis-catalog.ts @@ -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; + }, +}; diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts index 316d664..ba18314 100644 --- a/frontend/src/types/cases.ts +++ b/frontend/src/types/cases.ts @@ -18,6 +18,9 @@ export interface LabCaseTask { id: string; tooth: string; treatmentType: string; + prosthesisTypeCode: string; + prosthesisTypeLabel: string; + workflowStepCode?: string; stepOrder: number; stepLabel: string; status: LabTaskStatus; @@ -31,6 +34,8 @@ export interface LabCaseTask { export interface LabCaseTasksByTooth { tooth: string; treatmentType: string; + prosthesisTypeCode: string; + prosthesisTypeLabel: string; tasks: LabCaseTask[]; } @@ -100,6 +105,8 @@ export interface LabTaskListItem { labCaseId: string; tooth: string; treatmentType: string; + prosthesisTypeCode: string; + prosthesisTypeLabel: string; stepOrder: number; stepLabel: string; status: LabTaskStatus; diff --git a/frontend/src/types/treatment-catalog.ts b/frontend/src/types/treatment-catalog.ts index 5ecb3d8..51b071a 100644 --- a/frontend/src/types/treatment-catalog.ts +++ b/frontend/src/types/treatment-catalog.ts @@ -3,4 +3,11 @@ export interface TreatmentCatalogEntry { code: string; labDependent: boolean; sortOrder: number; + label: string; +} + +export interface ProsthesisCatalogEntry { + code: string; + sortOrder: number; + label: string; } diff --git a/frontend/src/types/treatment.ts b/frontend/src/types/treatment.ts index e1ab7f1..6c5d5a4 100644 --- a/frontend/src/types/treatment.ts +++ b/frontend/src/types/treatment.ts @@ -51,15 +51,14 @@ export interface TreatmentAttachmentMeta { sizeBytes: number; } -export const TREATMENT_TYPES = [ - 'consultation', - 'filling', - 'endo', - 'visit', - 'hygiene', -] as const; +export type TreatmentType = string; -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 { organizationId: string; @@ -99,6 +98,11 @@ export interface PastLabCase { treatmentType: string; teeth: FdiToothId[]; }>; + toothProsthesis?: Array<{ + treatmentDetailId: string; + tooth: string; + prosthesisTypeCode: string; + }>; sends?: LabCaseSendInfo[]; } @@ -141,6 +145,7 @@ export interface LabCaseDraft { destinationOrganizationId: string | null; labComment: string; detailClientIds: string[]; + toothProsthesis: LabCaseToothProsthesisDraft[]; sentAt?: string | null; sends?: LabCaseSendInfo[]; } @@ -163,6 +168,11 @@ export interface SaveLabCasePayload { destinationOrganizationId?: string; labComment?: string; treatmentDetailIds: string[]; + toothProsthesis?: Array<{ + treatmentDetailId: string; + tooth: string; + prosthesisTypeCode: string; + }>; } export interface SaveTreatmentPayload { @@ -185,4 +195,5 @@ export interface LabCaseResponse { teeth: string[]; }>; sends: LabCaseSendInfo[]; + toothProsthesis?: LabCaseToothProsthesisDraft[]; }