diff --git a/backend/package.json b/backend/package.json index 36eada7..3fd9825 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,7 +21,9 @@ "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:regenerate-tasks": "ts-node prisma/regenerate-lab-tasks.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..bdf5508 --- /dev/null +++ b/backend/prisma/catalog-seed-data.ts @@ -0,0 +1,299 @@ +import { CatalogEntityKind } from '@prisma/client'; + +export type CatalogTranslationSeed = { + entityKind: CatalogEntityKind; + entityCode: string; + locale: string; + label: string; +}; + +export type TreatmentTypeSeed = { + code: string; + labDependent: boolean; + sortOrder: number; + /** Selectable when booking an appointment. Defaults to true. */ + availableInAppointments?: boolean; + /** Selectable as a treatment plan detail. Defaults to true. */ + availableInTreatment?: boolean; +}; + +export const TREATMENT_TYPES: readonly TreatmentTypeSeed[] = [ + { 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 }, + // Appointment-only: not real treatment plan details. + { + code: 'clinic_visit', + labDependent: false, + sortOrder: 12, + availableInTreatment: false, + }, + { + code: 'continue_treatment', + labDependent: false, + sortOrder: 13, + availableInTreatment: false, + }, +] as const; + +/** Legacy codes kept for historical rows; hidden from catalog. */ +export const LEGACY_TREATMENT_TYPES: readonly TreatmentTypeSeed[] = [ + { 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' }, + continue_treatment: { en: 'Continue Treatment', fa: 'ادامه درمان', nl: 'Behandeling Voortzetten' }, + 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/20260628120000_global_patients_mobile/migration.sql b/backend/prisma/migrations/20260628120000_global_patients_mobile/migration.sql new file mode 100644 index 0000000..9625d89 --- /dev/null +++ b/backend/prisma/migrations/20260628120000_global_patients_mobile/migration.sql @@ -0,0 +1,28 @@ +-- Global patients: mobile is cloud-wide unique identity; org scope removed. +-- Test/dev data only — clear patient-linked rows before reshape. + +DELETE FROM "treatment_case_sends"; +DELETE FROM "treatment_case_attachments"; +DELETE FROM "treatment_cases"; +DELETE FROM "treatments"; +DELETE FROM "appointments"; +DELETE FROM "patients"; + +ALTER TABLE "patients" DROP CONSTRAINT IF EXISTS "patients_organizationId_fkey"; + +DROP INDEX IF EXISTS "patients_organizationId_createdAt_idx"; +DROP INDEX IF EXISTS "patients_organizationId_lastName_firstName_idx"; + +ALTER TABLE "patients" DROP COLUMN "organizationId"; +ALTER TABLE "patients" DROP COLUMN "phone"; + +ALTER TABLE "patients" ADD COLUMN "mobile" TEXT NOT NULL; +ALTER TABLE "patients" ADD COLUMN "createdByOrganizationId" TEXT; + +CREATE UNIQUE INDEX "patients_mobile_key" ON "patients"("mobile"); +CREATE INDEX "patients_lastName_firstName_idx" ON "patients"("lastName", "firstName"); + +ALTER TABLE "patients" + ADD CONSTRAINT "patients_createdByOrganizationId_fkey" + FOREIGN KEY ("createdByOrganizationId") REFERENCES "organizations"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260628130000_add_cases_permissions/migration.sql b/backend/prisma/migrations/20260628130000_add_cases_permissions/migration.sql new file mode 100644 index 0000000..e44243d --- /dev/null +++ b/backend/prisma/migrations/20260628130000_add_cases_permissions/migration.sql @@ -0,0 +1,15 @@ +-- Add Cases tab permissions for lab organizations + +INSERT INTO "features" ("id", "name", "description", "organizationTypeId") +VALUES (gen_random_uuid(), 'Cases', 'Lab cases inbox', NULL) +ON CONFLICT ("name") DO NOTHING; + +INSERT INTO "permissions" ("id", "name", "description", "featureId") +SELECT gen_random_uuid(), v.name, NULL, f.id +FROM (VALUES + ('TAB_CASES_READ'), + ('TAB_CASES_EDIT') +) AS v(name) +CROSS JOIN "features" f +WHERE f.name = 'Cases' +ON CONFLICT ("name") DO NOTHING; diff --git a/backend/prisma/migrations/20260628140000_treatment_details_lab_cases/migration.sql b/backend/prisma/migrations/20260628140000_treatment_details_lab_cases/migration.sql new file mode 100644 index 0000000..d983434 --- /dev/null +++ b/backend/prisma/migrations/20260628140000_treatment_details_lab_cases/migration.sql @@ -0,0 +1,99 @@ +-- Split treatment_cases into treatment_details + lab_cases (test data cleared). + +DELETE FROM "treatment_case_sends"; +DELETE FROM "treatment_case_attachments"; +DELETE FROM "treatment_cases"; +DELETE FROM "treatments"; + +DROP TABLE IF EXISTS "treatment_case_sends"; +DROP TABLE IF EXISTS "treatment_case_attachments"; +DROP TABLE IF EXISTS "treatment_cases"; + +CREATE TABLE "treatment_details" ( + "id" TEXT NOT NULL, + "treatmentId" TEXT NOT NULL, + "clientKey" TEXT, + "sortOrder" INTEGER NOT NULL, + "treatmentType" TEXT NOT NULL, + "teeth" JSONB NOT NULL, + "comment" TEXT, + + CONSTRAINT "treatment_details_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "treatment_detail_attachments" ( + "id" TEXT NOT NULL, + "detailId" TEXT, + "appointmentId" TEXT, + "detailClientKey" TEXT, + "fileName" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "sizeBytes" INTEGER NOT NULL, + "storagePath" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "treatment_detail_attachments_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "lab_cases" ( + "id" TEXT NOT NULL, + "treatmentId" TEXT NOT NULL, + "clientKey" TEXT, + "sortOrder" INTEGER NOT NULL, + "destinationOrganizationId" TEXT, + "labComment" TEXT, + "sentAt" TIMESTAMP(3), + + CONSTRAINT "lab_cases_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "lab_case_details" ( + "labCaseId" TEXT NOT NULL, + "treatmentDetailId" TEXT NOT NULL, + + CONSTRAINT "lab_case_details_pkey" PRIMARY KEY ("labCaseId", "treatmentDetailId") +); + +CREATE TABLE "lab_case_sends" ( + "id" TEXT NOT NULL, + "labCaseId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "lab_case_sends_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "treatment_details_treatmentId_sortOrder_idx" ON "treatment_details"("treatmentId", "sortOrder"); +CREATE INDEX "treatment_detail_attachments_appointmentId_detailClientKey_idx" ON "treatment_detail_attachments"("appointmentId", "detailClientKey"); +CREATE INDEX "treatment_detail_attachments_detailId_idx" ON "treatment_detail_attachments"("detailId"); +CREATE INDEX "lab_cases_treatmentId_sortOrder_idx" ON "lab_cases"("treatmentId", "sortOrder"); +CREATE UNIQUE INDEX "lab_case_details_treatmentDetailId_key" ON "lab_case_details"("treatmentDetailId"); +CREATE UNIQUE INDEX "lab_case_sends_labCaseId_organizationId_key" ON "lab_case_sends"("labCaseId", "organizationId"); + +ALTER TABLE "treatment_details" + ADD CONSTRAINT "treatment_details_treatmentId_fkey" + FOREIGN KEY ("treatmentId") REFERENCES "treatments"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "treatment_detail_attachments" + ADD CONSTRAINT "treatment_detail_attachments_detailId_fkey" + FOREIGN KEY ("detailId") REFERENCES "treatment_details"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_cases" + ADD CONSTRAINT "lab_cases_treatmentId_fkey" + FOREIGN KEY ("treatmentId") REFERENCES "treatments"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_details" + ADD CONSTRAINT "lab_case_details_labCaseId_fkey" + FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_details" + ADD CONSTRAINT "lab_case_details_treatmentDetailId_fkey" + FOREIGN KEY ("treatmentDetailId") REFERENCES "treatment_details"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_sends" + ADD CONSTRAINT "lab_case_sends_labCaseId_fkey" + FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_sends" + ADD CONSTRAINT "lab_case_sends_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260628150000_lab_case_tasks/migration.sql b/backend/prisma/migrations/20260628150000_lab_case_tasks/migration.sql new file mode 100644 index 0000000..4fc9f0c --- /dev/null +++ b/backend/prisma/migrations/20260628150000_lab_case_tasks/migration.sql @@ -0,0 +1,46 @@ +-- Treatment workflow steps + lab case tasks + +CREATE TYPE "LabTaskStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED'); + +CREATE TABLE "treatment_workflow_steps" ( + "id" TEXT NOT NULL, + "treatmentType" TEXT NOT NULL, + "stepOrder" INTEGER NOT NULL, + "label" TEXT NOT NULL, + + CONSTRAINT "treatment_workflow_steps_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "treatment_workflow_steps_treatmentType_stepOrder_key" + ON "treatment_workflow_steps"("treatmentType", "stepOrder"); + +CREATE TABLE "lab_case_tasks" ( + "id" TEXT NOT NULL, + "labCaseId" TEXT NOT NULL, + "treatmentDetailId" TEXT NOT NULL, + "tooth" TEXT NOT NULL, + "treatmentType" TEXT NOT NULL, + "stepOrder" INTEGER NOT NULL, + "stepLabel" TEXT NOT NULL, + "assigneeUserId" TEXT, + "status" "LabTaskStatus" NOT NULL DEFAULT 'PENDING', + + CONSTRAINT "lab_case_tasks_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "lab_case_tasks_labCaseId_tooth_treatmentType_stepOrder_key" + ON "lab_case_tasks"("labCaseId", "tooth", "treatmentType", "stepOrder"); + +CREATE INDEX "lab_case_tasks_labCaseId_status_idx" ON "lab_case_tasks"("labCaseId", "status"); + +ALTER TABLE "lab_case_tasks" + ADD CONSTRAINT "lab_case_tasks_labCaseId_fkey" + FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_tasks" + ADD CONSTRAINT "lab_case_tasks_treatmentDetailId_fkey" + FOREIGN KEY ("treatmentDetailId") REFERENCES "treatment_details"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_tasks" + ADD CONSTRAINT "lab_case_tasks_assigneeUserId_fkey" + FOREIGN KEY ("assigneeUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260628160000_treatment_type_catalog/migration.sql b/backend/prisma/migrations/20260628160000_treatment_type_catalog/migration.sql new file mode 100644 index 0000000..9580aab --- /dev/null +++ b/backend/prisma/migrations/20260628160000_treatment_type_catalog/migration.sql @@ -0,0 +1,49 @@ +-- Treatment type catalog (data-driven; business logic reads from here) + +CREATE TABLE "treatment_types" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "labDependent" BOOLEAN NOT NULL DEFAULT false, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "treatment_types_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "treatment_types_code_key" ON "treatment_types"("code"); + +-- Temporary catalog (will be replaced with 14 real-world types later) +INSERT INTO "treatment_types" ("id", "code", "labDependent", "sortOrder") VALUES + ('tt-consultation', 'consultation', false, 1), + ('tt-filling', 'filling', false, 2), + ('tt-endo', 'endo', true, 3), + ('tt-visit', 'visit', false, 4), + ('tt-hygiene', 'hygiene', false, 5); + +-- Re-link workflow steps to catalog rows +ALTER TABLE "treatment_workflow_steps" ADD COLUMN "treatmentTypeId" TEXT; + +UPDATE "treatment_workflow_steps" AS w +SET "treatmentTypeId" = t."id" +FROM "treatment_types" AS t +WHERE t."code" = w."treatmentType"; + +-- Drop steps for clinic-only types; only lab-dependent types keep workflows +DELETE FROM "treatment_workflow_steps" AS w +USING "treatment_types" AS t +WHERE w."treatmentTypeId" = t."id" AND t."labDependent" = false; + +DELETE FROM "treatment_workflow_steps" WHERE "treatmentTypeId" IS NULL; + +ALTER TABLE "treatment_workflow_steps" DROP CONSTRAINT IF EXISTS "treatment_workflow_steps_treatmentType_stepOrder_key"; +DROP INDEX IF EXISTS "treatment_workflow_steps_treatmentType_stepOrder_key"; + +ALTER TABLE "treatment_workflow_steps" DROP COLUMN "treatmentType"; + +ALTER TABLE "treatment_workflow_steps" ALTER COLUMN "treatmentTypeId" SET NOT NULL; + +ALTER TABLE "treatment_workflow_steps" + ADD CONSTRAINT "treatment_workflow_steps_treatmentTypeId_fkey" + FOREIGN KEY ("treatmentTypeId") REFERENCES "treatment_types"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE UNIQUE INDEX "treatment_workflow_steps_treatmentTypeId_stepOrder_key" + ON "treatment_workflow_steps"("treatmentTypeId", "stepOrder"); diff --git a/backend/prisma/migrations/20260628170000_remove_treatment_status/migration.sql b/backend/prisma/migrations/20260628170000_remove_treatment_status/migration.sql new file mode 100644 index 0000000..512b217 --- /dev/null +++ b/backend/prisma/migrations/20260628170000_remove_treatment_status/migration.sql @@ -0,0 +1,8 @@ +-- DropIndex +DROP INDEX IF EXISTS "treatments_organizationId_status_idx"; + +-- AlterTable +ALTER TABLE "treatments" DROP COLUMN "status"; + +-- DropEnum +DROP TYPE "TreatmentStatus"; diff --git a/backend/prisma/migrations/20260628180000_lab_task_priority_and_tasks_permissions/migration.sql b/backend/prisma/migrations/20260628180000_lab_task_priority_and_tasks_permissions/migration.sql new file mode 100644 index 0000000..1b2937d --- /dev/null +++ b/backend/prisma/migrations/20260628180000_lab_task_priority_and_tasks_permissions/migration.sql @@ -0,0 +1,22 @@ +-- Add task priority, timestamps, and Tasks tab permissions + +ALTER TABLE "lab_case_tasks" ADD COLUMN "priority" INTEGER NOT NULL DEFAULT 3; +ALTER TABLE "lab_case_tasks" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE "lab_case_tasks" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +CREATE INDEX "lab_case_tasks_assigneeUserId_priority_createdAt_idx" + ON "lab_case_tasks"("assigneeUserId", "priority", "createdAt"); + +INSERT INTO "features" ("id", "name", "description", "organizationTypeId") +VALUES (gen_random_uuid(), 'Tasks', 'Lab task inbox', NULL) +ON CONFLICT ("name") DO NOTHING; + +INSERT INTO "permissions" ("id", "name", "description", "featureId") +SELECT gen_random_uuid(), v.name, NULL, f.id +FROM (VALUES + ('TAB_TASKS_READ'), + ('TAB_TASKS_EDIT') +) AS v(name) +CROSS JOIN "features" f +WHERE f.name = 'Tasks' +ON CONFLICT ("name") DO NOTHING; diff --git a/backend/prisma/migrations/20260628190000_lab_task_assigned_at/migration.sql b/backend/prisma/migrations/20260628190000_lab_task_assigned_at/migration.sql new file mode 100644 index 0000000..e58a202 --- /dev/null +++ b/backend/prisma/migrations/20260628190000_lab_task_assigned_at/migration.sql @@ -0,0 +1,6 @@ +-- Track when a task was assigned (for sorting and display) + +ALTER TABLE "lab_case_tasks" ADD COLUMN "assignedAt" TIMESTAMP(3); + +CREATE INDEX "lab_case_tasks_assignedAt_labCaseId_priority_idx" + ON "lab_case_tasks"("assignedAt" DESC, "labCaseId" ASC, "priority" DESC); 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/migrations/20260707120000_treatment_type_context/migration.sql b/backend/prisma/migrations/20260707120000_treatment_type_context/migration.sql new file mode 100644 index 0000000..5224edb --- /dev/null +++ b/backend/prisma/migrations/20260707120000_treatment_type_context/migration.sql @@ -0,0 +1,3 @@ +-- Treatment type context flags: control which selection contexts each type appears in. +ALTER TABLE "treatment_types" ADD COLUMN "availableInAppointments" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "treatment_types" ADD COLUMN "availableInTreatment" BOOLEAN NOT NULL DEFAULT true; diff --git a/backend/prisma/migrations/20260707130000_lab_workflow_refactor/migration.sql b/backend/prisma/migrations/20260707130000_lab_workflow_refactor/migration.sql new file mode 100644 index 0000000..a2e2384 --- /dev/null +++ b/backend/prisma/migrations/20260707130000_lab_workflow_refactor/migration.sql @@ -0,0 +1,83 @@ +-- Lab workflow refactor: remove task assignment/priority, group tasks by prosthesis, +-- add importance flag, status timeline, and per-case comments. +-- Local dev data only: existing tasks are truncated and regenerated on next dispatch/send. + +-- 1. Clear existing task data (task shape changes: tooth -> teeth[]). +TRUNCATE TABLE "lab_case_tasks" CASCADE; + +-- 2. Drop assignment / priority machinery. +ALTER TABLE "lab_case_tasks" DROP CONSTRAINT IF EXISTS "lab_case_tasks_assigneeUserId_fkey"; +DROP INDEX IF EXISTS "lab_case_tasks_assigneeUserId_priority_createdAt_idx"; +DROP INDEX IF EXISTS "lab_case_tasks_assignedAt_labCaseId_priority_idx"; +DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_tooth_stepOrder_key"; + +ALTER TABLE "lab_case_tasks" + DROP COLUMN IF EXISTS "assigneeUserId", + DROP COLUMN IF EXISTS "assignedAt", + DROP COLUMN IF EXISTS "priority", + DROP COLUMN IF EXISTS "tooth"; + +-- 3. Rebuild LabTaskStatus enum without PENDING. +ALTER TABLE "lab_case_tasks" ALTER COLUMN "status" DROP DEFAULT; +ALTER TYPE "LabTaskStatus" RENAME TO "LabTaskStatus_old"; +CREATE TYPE "LabTaskStatus" AS ENUM ('IN_PROGRESS', 'COMPLETED'); +ALTER TABLE "lab_case_tasks" + ALTER COLUMN "status" TYPE "LabTaskStatus" USING ("status"::text::"LabTaskStatus"); +ALTER TABLE "lab_case_tasks" ALTER COLUMN "status" SET DEFAULT 'IN_PROGRESS'; +DROP TYPE "LabTaskStatus_old"; + +-- 4. New task columns. +ALTER TABLE "lab_case_tasks" ADD COLUMN "teeth" JSONB NOT NULL DEFAULT '[]'; +ALTER TABLE "lab_case_tasks" ALTER COLUMN "teeth" DROP DEFAULT; +ALTER TABLE "lab_case_tasks" ADD COLUMN "isImportant" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "lab_case_tasks" ADD COLUMN "lastStatusChangedByUserId" TEXT; +ALTER TABLE "lab_case_tasks" ADD COLUMN "lastStatusChangedAt" TIMESTAMP(3); + +-- 5. New unique + indexes. +CREATE UNIQUE INDEX "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_stepOrder_key" + ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "prosthesisTypeCode", "stepOrder"); +CREATE INDEX "lab_case_tasks_labCaseId_isImportant_idx" + ON "lab_case_tasks"("labCaseId", "isImportant"); + +ALTER TABLE "lab_case_tasks" ADD CONSTRAINT "lab_case_tasks_lastStatusChangedByUserId_fkey" + FOREIGN KEY ("lastStatusChangedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- 6. Task status timeline. +CREATE TABLE "lab_case_task_status_events" ( + "id" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "fromStatus" "LabTaskStatus", + "toStatus" "LabTaskStatus" NOT NULL, + "changedByUserId" TEXT, + "changedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "lab_case_task_status_events_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "lab_case_task_status_events_taskId_changedAt_idx" + ON "lab_case_task_status_events"("taskId", "changedAt"); +ALTER TABLE "lab_case_task_status_events" ADD CONSTRAINT "lab_case_task_status_events_taskId_fkey" + FOREIGN KEY ("taskId") REFERENCES "lab_case_tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "lab_case_task_status_events" ADD CONSTRAINT "lab_case_task_status_events_changedByUserId_fkey" + FOREIGN KEY ("changedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- 7. Per-case comments. +CREATE TYPE "LabCaseCommentSide" AS ENUM ('LAB', 'CLINIC'); +CREATE TABLE "lab_case_comments" ( + "id" TEXT NOT NULL, + "labCaseId" TEXT NOT NULL, + "authorUserId" TEXT, + "authorOrganizationId" TEXT, + "authorSide" "LabCaseCommentSide" NOT NULL, + "body" TEXT NOT NULL, + "visibleToClinic" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "lab_case_comments_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "lab_case_comments_labCaseId_createdAt_idx" + ON "lab_case_comments"("labCaseId", "createdAt"); +ALTER TABLE "lab_case_comments" ADD CONSTRAINT "lab_case_comments_labCaseId_fkey" + FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "lab_case_comments" ADD CONSTRAINT "lab_case_comments_authorUserId_fkey" + FOREIGN KEY ("authorUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "lab_case_comments" ADD CONSTRAINT "lab_case_comments_authorOrganizationId_fkey" + FOREIGN KEY ("authorOrganizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260707140000_unify_lab_case_comments/migration.sql b/backend/prisma/migrations/20260707140000_unify_lab_case_comments/migration.sql new file mode 100644 index 0000000..52128f9 --- /dev/null +++ b/backend/prisma/migrations/20260707140000_unify_lab_case_comments/migration.sql @@ -0,0 +1,23 @@ +-- Migrate legacy single-string labComment into per-case comment rows, then drop the column. + +INSERT INTO "lab_case_comments" ( + "id", + "labCaseId", + "authorSide", + "body", + "visibleToClinic", + "createdAt", + "updatedAt" +) +SELECT + gen_random_uuid()::text, + lc."id", + 'CLINIC'::"LabCaseCommentSide", + trim(lc."labComment"), + true, + COALESCE(lc."sentAt", NOW()), + NOW() +FROM "lab_cases" lc +WHERE lc."labComment" IS NOT NULL AND trim(lc."labComment") <> ''; + +ALTER TABLE "lab_cases" DROP COLUMN "labComment"; diff --git a/backend/prisma/migrations/20260707160000_lab_case_attachments/migration.sql b/backend/prisma/migrations/20260707160000_lab_case_attachments/migration.sql new file mode 100644 index 0000000..ed71309 --- /dev/null +++ b/backend/prisma/migrations/20260707160000_lab_case_attachments/migration.sql @@ -0,0 +1,16 @@ +-- Per-shipment attachment selection: only checked files are visible to the lab. +CREATE TABLE "lab_case_attachments" ( + "labCaseId" TEXT NOT NULL, + "attachmentId" TEXT NOT NULL, + CONSTRAINT "lab_case_attachments_pkey" PRIMARY KEY ("labCaseId", "attachmentId") +); + +CREATE INDEX "lab_case_attachments_attachmentId_idx" ON "lab_case_attachments"("attachmentId"); + +ALTER TABLE "lab_case_attachments" + ADD CONSTRAINT "lab_case_attachments_labCaseId_fkey" + FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_attachments" + ADD CONSTRAINT "lab_case_attachments_attachmentId_fkey" + FOREIGN KEY ("attachmentId") REFERENCES "treatment_detail_attachments"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260708120000_case_level_important/migration.sql b/backend/prisma/migrations/20260708120000_case_level_important/migration.sql new file mode 100644 index 0000000..ee816ed --- /dev/null +++ b/backend/prisma/migrations/20260708120000_case_level_important/migration.sql @@ -0,0 +1,13 @@ +-- Move the "important" flag from individual tasks to the case as a whole. +ALTER TABLE "lab_cases" ADD COLUMN "isImportant" BOOLEAN NOT NULL DEFAULT false; + +-- Carry over existing importance: a case is important if any of its tasks were. +UPDATE "lab_cases" lc +SET "isImportant" = true +WHERE EXISTS ( + SELECT 1 FROM "lab_case_tasks" t + WHERE t."labCaseId" = lc."id" AND t."isImportant" = true +); + +DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_isImportant_idx"; +ALTER TABLE "lab_case_tasks" DROP COLUMN "isImportant"; diff --git a/backend/prisma/migrations/20260710140000_lab_case_detail_one_to_one/migration.sql b/backend/prisma/migrations/20260710140000_lab_case_detail_one_to_one/migration.sql new file mode 100644 index 0000000..311ac6e --- /dev/null +++ b/backend/prisma/migrations/20260710140000_lab_case_detail_one_to_one/migration.sql @@ -0,0 +1,11 @@ +-- Enforce one treatment detail per lab case (1:1 via join table). +-- Keep the earliest-linked detail when duplicate rows exist for the same case. + +DELETE FROM "lab_case_details" lcd +WHERE lcd.ctid NOT IN ( + SELECT MIN(inner_lcd.ctid) + FROM "lab_case_details" inner_lcd + GROUP BY inner_lcd."labCaseId" +); + +CREATE UNIQUE INDEX "lab_case_details_labCaseId_key" ON "lab_case_details"("labCaseId"); diff --git a/backend/prisma/regenerate-lab-tasks.ts b/backend/prisma/regenerate-lab-tasks.ts new file mode 100644 index 0000000..31cf1ae --- /dev/null +++ b/backend/prisma/regenerate-lab-tasks.ts @@ -0,0 +1,60 @@ +/** + * Dev-only: regenerate lab case tasks from existing LabCaseToothProsthesis rows. + * Usage: npx ts-node prisma/regenerate-lab-tasks.ts + * + * The lab workflow refactor truncated lab_case_tasks. This rebuilds task sets + * (grouped by treatment detail + prosthesis type, one set per workflow step) + * for every already-sent case that still has prosthesis selections. + */ +import { PrismaClient } from '@prisma/client'; +import { config } from 'dotenv'; +import path from 'path'; +import { generateLabCaseTasks } from '../src/modules/cases/lab-case-task.generator'; + +const envPath = path.join(__dirname, '..', '.env'); +config({ path: envPath }); + +if (process.env.NODE_ENV === 'production') { + console.error('regenerate-lab-tasks is not allowed in production'); + process.exit(1); +} + +const prisma = new PrismaClient(); + +async function main() { + const cases = await prisma.labCase.findMany({ + where: { + sentAt: { not: null }, + toothProsthesis: { some: {} }, + }, + select: { + id: true, + treatment: { select: { organization: { select: { owner: { select: { language: true } } } } } }, + }, + }); + + console.log(`Regenerating tasks for ${cases.length} sent case(s)...`); + + let total = 0; + for (const labCase of cases) { + const locale = labCase.treatment.organization.owner.language ?? 'en'; + // Clear any stale tasks first so the generator's "already exists" guard passes. + await prisma.labCaseTask.deleteMany({ where: { labCaseId: labCase.id } }); + const created = await prisma.$transaction((tx) => + generateLabCaseTasks(tx, labCase.id, locale), + ); + total += created; + console.log(` - ${labCase.id}: ${created} task(s)`); + } + + console.log(`Done. ${total} task(s) created.`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/prisma/reset-treatment-data.ts b/backend/prisma/reset-treatment-data.ts new file mode 100644 index 0000000..4098bf0 --- /dev/null +++ b/backend/prisma/reset-treatment-data.ts @@ -0,0 +1,75 @@ +/** + * Dev-only: truncate treatment and lab case data (preserves catalog tables). + * Usage: npx ts-node prisma/reset-treatment-data.ts + * + * Safe to run before or after `prisma migrate deploy`: tables that do not yet + * exist are skipped instead of throwing. + */ +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(); + +// FK-safe order: children before parents. +const TABLES_IN_ORDER = [ + 'lab_case_task_status_events', + 'lab_case_comments', + 'lab_case_attachments', + 'lab_case_tasks', + 'lab_case_sends', + 'lab_case_tooth_prosthesis', + 'lab_case_details', + 'lab_cases', + 'treatment_detail_attachments', + 'treatment_details', + 'treatments', + 'appointments', +]; + +async function tableExists(table: string): Promise { + const rows = await prisma.$queryRawUnsafe>( + `SELECT to_regclass('public."${table}"')::text AS exists`, + ); + return rows[0]?.exists != null; +} + +async function main() { + console.log('Truncating treatment and lab case data...'); + + const existing: string[] = []; + for (const table of TABLES_IN_ORDER) { + if (await tableExists(table)) { + existing.push(table); + } else { + console.log(` - skipping "${table}" (does not exist yet)`); + } + } + + if (existing.length === 0) { + console.log('No target tables exist yet. Run `prisma migrate deploy` first.'); + return; + } + + const targets = existing.map((t) => `"${t}"`).join(', '); + await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${targets} 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 64a0b78..06bf90a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -24,6 +24,9 @@ model User { sessions Session[] // 👈 ADD THIS - opposite relation for Session sentStaffInvites StaffInvitation[] sentOrganizationInvitations OrganizationInvitation[] + statusChangedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskLastStatusChangedBy") + labCaseTaskStatusEvents LabCaseTaskStatusEvent[] + labCaseComments LabCaseComment[] phoneVerificationCodes PhoneVerificationCode[] createdAt DateTime @default(now()) @@ -78,10 +81,11 @@ model Organization { sharedWithMe OrganizationLink[] @relation("OrganizationB") sharedWithOthers OrganizationLink[] @relation("OrganizationA") sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter") - patients Patient[] + createdPatients Patient[] @relation("PatientCreatedBy") appointments Appointment[] treatments Treatment[] - caseSends TreatmentCaseSend[] + labCaseSends LabCaseSend[] + labCaseComments LabCaseComment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -90,24 +94,23 @@ model Organization { } model Patient { - id String @id @default(uuid()) - organizationId String - firstName String - lastName String - phone String? - email String? - dateOfBirth DateTime? - notes String? - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + firstName String + lastName String + mobile String @unique + email String? + dateOfBirth DateTime? + notes String? + isActive Boolean @default(true) + createdByOrganizationId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - organization Organization @relation(fields: [organizationId], references: [id]) - treatments Treatment[] - appointments Appointment[] + createdByOrganization Organization? @relation("PatientCreatedBy", fields: [createdByOrganizationId], references: [id], onDelete: SetNull) + treatments Treatment[] + appointments Appointment[] - @@index([organizationId, createdAt]) - @@index([organizationId, lastName, firstName]) + @@index([lastName, firstName]) @@map("patients") } @@ -132,82 +135,273 @@ model Appointment { @@map("appointments") } -enum TreatmentStatus { - DRAFT +enum LabTaskStatus { + IN_PROGRESS COMPLETED } +enum LabCaseCommentSide { + LAB + CLINIC +} + model Treatment { - id String @id @default(uuid()) + id String @id @default(uuid()) organizationId String patientId String - appointmentId String? @unique + appointmentId String? @unique providerUserId String title String - status TreatmentStatus @default(DRAFT) treatmentAt DateTime organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull) - cases TreatmentCase[] + details TreatmentDetail[] + labCases LabCase[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([patientId, treatmentAt]) - @@index([organizationId, status]) @@map("treatments") } -model TreatmentCase { - id String @id @default(uuid()) +model TreatmentDetail { + id String @id @default(uuid()) treatmentId String clientKey String? sortOrder Int treatmentType String teeth Json comment String? - sentAt DateTime? - treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) - attachments TreatmentCaseAttachment[] - sends TreatmentCaseSend[] + treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) + attachments TreatmentDetailAttachment[] + labCaseLink LabCaseDetail? + labCaseTasks LabCaseTask[] + toothProsthesis LabCaseToothProsthesis[] @@index([treatmentId, sortOrder]) - @@map("treatment_cases") + @@map("treatment_details") } -model TreatmentCaseAttachment { - id String @id @default(uuid()) - caseId String? - appointmentId String? - caseClientKey String? - fileName String - mimeType String - sizeBytes Int - storagePath String +model TreatmentDetailAttachment { + id String @id @default(uuid()) + detailId String? + appointmentId String? + detailClientKey String? + fileName String + mimeType String + sizeBytes Int + storagePath String - case TreatmentCase? @relation(fields: [caseId], references: [id], onDelete: Cascade) + detail TreatmentDetail? @relation(fields: [detailId], references: [id], onDelete: Cascade) + labCaseLinks LabCaseAttachment[] createdAt DateTime @default(now()) - @@index([appointmentId, caseClientKey]) - @@index([caseId]) - @@map("treatment_case_attachments") + @@index([appointmentId, detailClientKey]) + @@index([detailId]) + @@map("treatment_detail_attachments") } -model TreatmentCaseSend { +model LabCase { + id String @id @default(uuid()) + treatmentId String + clientKey String? + sortOrder Int + destinationOrganizationId String? + sentAt DateTime? + isImportant Boolean @default(false) + + treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) + details LabCaseDetail[] + sends LabCaseSend[] + tasks LabCaseTask[] + toothProsthesis LabCaseToothProsthesis[] + comments LabCaseComment[] + attachments LabCaseAttachment[] + + @@index([treatmentId, sortOrder]) + @@map("lab_cases") +} + +model LabCaseAttachment { + labCaseId String + attachmentId String + + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + attachment TreatmentDetailAttachment @relation(fields: [attachmentId], references: [id], onDelete: Cascade) + + @@id([labCaseId, attachmentId]) + @@index([attachmentId]) + @@map("lab_case_attachments") +} + +model LabCaseDetail { + labCaseId String @unique + treatmentDetailId String @unique + + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) + + @@id([labCaseId, treatmentDetailId]) + @@map("lab_case_details") +} + +model LabCaseSend { id String @id @default(uuid()) - caseId String + labCaseId String organizationId String sentAt DateTime @default(now()) - case TreatmentCase @relation(fields: [caseId], references: [id], onDelete: Cascade) - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - @@unique([caseId, organizationId]) - @@map("treatment_case_sends") + @@unique([labCaseId, organizationId]) + @@map("lab_case_sends") +} + +model TreatmentType { + id String @id @default(uuid()) + code String @unique + labDependent Boolean @default(false) + sortOrder Int @default(0) + isActive Boolean @default(true) + availableInAppointments Boolean @default(true) + availableInTreatment Boolean @default(true) + + @@map("treatment_types") +} + +enum CatalogEntityKind { + TREATMENT_TYPE + PROSTHESIS_TYPE + LAB_WORKFLOW_STEP +} + +model CatalogTranslation { + id String @id @default(uuid()) + entityKind CatalogEntityKind + entityCode String + locale String + label String + + @@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 + teeth Json + treatmentType String + prosthesisTypeCode String + workflowStepCode String + stepOrder Int + stepLabel String + status LabTaskStatus @default(IN_PROGRESS) + lastStatusChangedByUserId String? + lastStatusChangedAt DateTime? + + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) + lastStatusChangedBy User? @relation("LabCaseTaskLastStatusChangedBy", fields: [lastStatusChangedByUserId], references: [id], onDelete: SetNull) + statusEvents LabCaseTaskStatusEvent[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder]) + @@index([labCaseId, status]) + @@map("lab_case_tasks") +} + +model LabCaseTaskStatusEvent { + id String @id @default(uuid()) + taskId String + fromStatus LabTaskStatus? + toStatus LabTaskStatus + changedByUserId String? + changedAt DateTime @default(now()) + + task LabCaseTask @relation(fields: [taskId], references: [id], onDelete: Cascade) + changedBy User? @relation(fields: [changedByUserId], references: [id], onDelete: SetNull) + + @@index([taskId, changedAt]) + @@map("lab_case_task_status_events") +} + +model LabCaseComment { + id String @id @default(uuid()) + labCaseId String + authorUserId String? + authorOrganizationId String? + authorSide LabCaseCommentSide + body String + visibleToClinic Boolean @default(false) + + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + authorUser User? @relation(fields: [authorUserId], references: [id], onDelete: SetNull) + authorOrganization Organization? @relation(fields: [authorOrganizationId], references: [id], onDelete: SetNull) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([labCaseId, createdAt]) + @@map("lab_case_comments") } model Plan { diff --git a/backend/prisma/scripts/clear-clinical-test-data.ts b/backend/prisma/scripts/clear-clinical-test-data.ts new file mode 100644 index 0000000..99cee7c --- /dev/null +++ b/backend/prisma/scripts/clear-clinical-test-data.ts @@ -0,0 +1,60 @@ +/** + * One-off cleanup: remove appointments, treatments, lab cases, and related rows. + * Keeps patients, organizations, users, and catalog data intact. + * + * Usage: npx ts-node prisma/scripts/clear-clinical-test-data.ts + */ +import { PrismaClient } from '@prisma/client'; +import { config } from 'dotenv'; +import path from 'path'; + +config({ path: path.join(__dirname, '..', '..', '.env') }); + +const prisma = new PrismaClient(); + +async function main() { + const counts = { + labCaseTaskStatusEvents: await prisma.labCaseTaskStatusEvent.count(), + labCaseComments: await prisma.labCaseComment.count(), + labCaseAttachments: await prisma.labCaseAttachment.count(), + labCaseTasks: await prisma.labCaseTask.count(), + labCaseSends: await prisma.labCaseSend.count(), + labCaseToothProsthesis: await prisma.labCaseToothProsthesis.count(), + labCaseDetails: await prisma.labCaseDetail.count(), + labCases: await prisma.labCase.count(), + attachments: await prisma.treatmentDetailAttachment.count(), + treatmentDetails: await prisma.treatmentDetail.count(), + treatments: await prisma.treatment.count(), + appointments: await prisma.appointment.count(), + }; + + console.log('Current row counts:', counts); + + await prisma.$transaction([ + prisma.labCaseTaskStatusEvent.deleteMany(), + prisma.labCaseComment.deleteMany(), + prisma.labCaseAttachment.deleteMany(), + prisma.labCaseTask.deleteMany(), + prisma.labCaseSend.deleteMany(), + prisma.labCaseToothProsthesis.deleteMany(), + prisma.labCaseDetail.deleteMany(), + prisma.labCase.deleteMany(), + prisma.treatmentDetailAttachment.deleteMany(), + prisma.treatmentDetail.deleteMany(), + prisma.treatment.deleteMany(), + prisma.appointment.deleteMany(), + ]); + + console.log( + '✅ Cleared appointments, treatments, lab cases, tasks, comments, attachments, and related rows.', + ); +} + +main() + .catch((error) => { + console.error('❌ Cleanup failed:', error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index d176750..024d958 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -1,7 +1,16 @@ // backend/prisma/seed.ts 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 .env when running locally; Docker injects DATABASE_URL via env_file. if (!process.env.DATABASE_URL) { @@ -88,6 +97,14 @@ async function main() { name: 'Treatment', permissions: ['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'], }, + { + name: 'Cases', + permissions: ['TAB_CASES_READ', 'TAB_CASES_EDIT'], + }, + { + name: 'Tasks', + permissions: ['TAB_TASKS_READ', 'TAB_TASKS_EDIT'], + }, { name: 'Billing', permissions: ['TAB_BILLING_READ', 'TAB_BILLING_EDIT'], @@ -118,6 +135,124 @@ async function main() { } console.log('✅ Created features and permissions'); + for (const type of [...TREATMENT_TYPES, ...LEGACY_TREATMENT_TYPES]) { + const isActive = TREATMENT_TYPES.some((t) => t.code === type.code); + const availableInAppointments = type.availableInAppointments ?? true; + const availableInTreatment = type.availableInTreatment ?? true; + await prisma.treatmentType.upsert({ + where: { code: type.code }, + update: { + labDependent: type.labDependent, + sortOrder: type.sortOrder, + isActive, + availableInAppointments, + availableInTreatment, + }, + create: { + id: randomUUID(), + code: type.code, + labDependent: type.labDependent, + sortOrder: type.sortOrder, + isActive, + availableInAppointments, + availableInTreatment, + }, + }); + } + console.log('✅ Seeded treatment type catalog'); + + for (const step of LAB_WORKFLOW_STEPS) { + await prisma.labWorkflowStep.upsert({ + where: { code: step.code }, + update: { sortOrder: step.sortOrder }, + create: { + id: randomUUID(), + 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 70c3e1c..11390d4 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -11,6 +11,12 @@ import { StaffModule } from './modules/staff/staff.module'; import { OrganizationModule } from './modules/organization/organization.module'; import { AppointmentsModule } from './modules/appointments/appointments.module'; 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'; +import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module'; @Module({ imports: [ @@ -19,10 +25,16 @@ import { TreatmentsModule } from './modules/treatments/treatments.module'; load: [configurations], }), PrismaModule, // ✅ ADD THIS + CatalogModule, + TreatmentCatalogModule, + ProsthesisCatalogModule, AuthModule, PatientsModule, AppointmentsModule, TreatmentsModule, + CasesModule, + TasksModule, + LabCaseCommentsModule, StaffModule, OrganizationModule, AdminModule.forRoot(), diff --git a/backend/src/common/guards/clinic-org.guard.ts b/backend/src/common/guards/clinic-org.guard.ts new file mode 100644 index 0000000..5b16b09 --- /dev/null +++ b/backend/src/common/guards/clinic-org.guard.ts @@ -0,0 +1,25 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { assertClinicOrganization } from '../../common/organization-type'; + +@Injectable() +export class ClinicOrgGuard implements CanActivate { + constructor(private readonly prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest<{ user?: { organizationId?: string } }>(); + const organizationId = request.user?.organizationId; + + if (!organizationId) { + throw new UnauthorizedException('Organization is not selected'); + } + + await assertClinicOrganization(this.prisma, organizationId); + return true; + } +} diff --git a/backend/src/common/guards/lab-org.guard.ts b/backend/src/common/guards/lab-org.guard.ts new file mode 100644 index 0000000..69bc829 --- /dev/null +++ b/backend/src/common/guards/lab-org.guard.ts @@ -0,0 +1,25 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { assertLabOrganization } from '../../common/organization-type'; + +@Injectable() +export class LabOrgGuard implements CanActivate { + constructor(private readonly prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest<{ user?: { organizationId?: string } }>(); + const organizationId = request.user?.organizationId; + + if (!organizationId) { + throw new UnauthorizedException('Organization is not selected'); + } + + await assertLabOrganization(this.prisma, organizationId); + return true; + } +} diff --git a/backend/src/common/organization-type.ts b/backend/src/common/organization-type.ts new file mode 100644 index 0000000..6ddc33a --- /dev/null +++ b/backend/src/common/organization-type.ts @@ -0,0 +1,105 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { ALL_TAB_PERMISSIONS, normalizeTabPermissions } from './permissions'; + +export type OrganizationTypeName = 'CLINIC' | 'LAB'; + +const CLINIC_ONLY_PERMISSIONS = new Set([ + 'TAB_PATIENTS_READ', + 'TAB_PATIENTS_EDIT', + 'TAB_APPOINTMENTS_READ', + 'TAB_APPOINTMENTS_EDIT', + 'TAB_TREATMENT_READ', + 'TAB_TREATMENT_EDIT', +]); + +const LAB_ONLY_PERMISSIONS = new Set([ + 'TAB_CASES_READ', + 'TAB_CASES_EDIT', + 'TAB_TASKS_READ', + 'TAB_TASKS_EDIT', +]); + +const SHARED_PERMISSIONS = ALL_TAB_PERMISSIONS.filter( + (p) => !CLINIC_ONLY_PERMISSIONS.has(p) && !LAB_ONLY_PERMISSIONS.has(p), +); + +export const CLINIC_TAB_PERMISSIONS = [ + ...SHARED_PERMISSIONS, + ...CLINIC_ONLY_PERMISSIONS, +] as const; + +export const LAB_TAB_PERMISSIONS = [ + ...SHARED_PERMISSIONS, + ...LAB_ONLY_PERMISSIONS, +] as const; + +const CLINIC_TAB_SET = new Set(CLINIC_TAB_PERMISSIONS); +const LAB_TAB_SET = new Set(LAB_TAB_PERMISSIONS); + +export function permissionsAllowedForOrgType(orgType: OrganizationTypeName): Set { + return orgType === 'LAB' ? LAB_TAB_SET : CLINIC_TAB_SET; +} + +export function filterPermissionsForOrgType( + names: string[], + orgType: OrganizationTypeName, +): string[] { + const allowed = permissionsAllowedForOrgType(orgType); + return normalizeTabPermissions(names.filter((n) => allowed.has(n))); +} + +export function ownerPermissionsForOrgType( + orgType: OrganizationTypeName, + hasActivePlan: boolean, +): string[] { + if (hasActivePlan) { + return orgType === 'LAB' ? [...LAB_TAB_PERMISSIONS] : [...CLINIC_TAB_PERMISSIONS]; + } + + const readOnly = (perms: readonly string[]) => + normalizeTabPermissions(perms.filter((p) => p.endsWith('_READ'))); + + return orgType === 'LAB' ? readOnly(LAB_TAB_PERMISSIONS) : readOnly(CLINIC_TAB_PERMISSIONS); +} + +export async function getOrganizationTypeName( + prisma: PrismaService, + organizationId: string, +): Promise { + const org = await prisma.organization.findUnique({ + where: { id: organizationId }, + select: { type: { select: { name: true } } }, + }); + + if (!org) { + throw new NotFoundException('Organization not found'); + } + + const name = org.type.name; + if (name !== 'CLINIC' && name !== 'LAB') { + throw new ForbiddenException('Unknown organization type'); + } + + return name; +} + +export async function assertClinicOrganization( + prisma: PrismaService, + organizationId: string, +): Promise { + const type = await getOrganizationTypeName(prisma, organizationId); + if (type !== 'CLINIC') { + throw new ForbiddenException('This action is only available for clinic organizations'); + } +} + +export async function assertLabOrganization( + prisma: PrismaService, + organizationId: string, +): Promise { + const type = await getOrganizationTypeName(prisma, organizationId); + if (type !== 'LAB') { + throw new ForbiddenException('This action is only available for lab organizations'); + } +} diff --git a/backend/src/common/permissions.ts b/backend/src/common/permissions.ts index 5d0b172..bd85f5b 100644 --- a/backend/src/common/permissions.ts +++ b/backend/src/common/permissions.ts @@ -12,6 +12,10 @@ export const ALL_TAB_PERMISSIONS = [ 'TAB_APPOINTMENTS_EDIT', 'TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT', + 'TAB_CASES_READ', + 'TAB_CASES_EDIT', + 'TAB_TASKS_READ', + 'TAB_TASKS_EDIT', 'TAB_BILLING_READ', 'TAB_BILLING_EDIT', 'TAB_REPORTS_READ', @@ -40,6 +44,8 @@ const EDIT_TO_READ: Record = { TAB_STAFF_EDIT: 'TAB_STAFF_READ', TAB_ORGANIZATIONS_EDIT: 'TAB_ORGANIZATIONS_READ', TAB_TREATMENT_EDIT: 'TAB_TREATMENT_READ', + TAB_CASES_EDIT: 'TAB_CASES_READ', + TAB_TASKS_EDIT: 'TAB_TASKS_READ', TAB_BILLING_EDIT: 'TAB_BILLING_READ', TAB_REPORTS_EDIT: 'TAB_REPORTS_READ', }; diff --git a/backend/src/common/phone.spec.ts b/backend/src/common/phone.spec.ts new file mode 100644 index 0000000..1838bd0 --- /dev/null +++ b/backend/src/common/phone.spec.ts @@ -0,0 +1,52 @@ +import { + formatMobileForDisplay, + isValidMobile, + mobileSearchDigits, + normalizeMobile, +} from './phone'; + +describe('normalizeMobile', () => { + it('normalizes 09-prefixed numbers', () => { + expect(normalizeMobile('09121234567')).toBe('+989121234567'); + }); + + it('normalizes without leading zero', () => { + expect(normalizeMobile('9121234567')).toBe('+989121234567'); + }); + + it('normalizes +98 prefix', () => { + expect(normalizeMobile('+989121234567')).toBe('+989121234567'); + }); + + it('normalizes 0098 prefix', () => { + expect(normalizeMobile('00989121234567')).toBe('+989121234567'); + }); + + it('normalizes spaced input', () => { + expect(normalizeMobile('0912 123 4567')).toBe('+989121234567'); + }); + + it('rejects invalid numbers', () => { + expect(normalizeMobile('123')).toBeNull(); + expect(normalizeMobile('')).toBeNull(); + }); +}); + +describe('isValidMobile', () => { + it('validates normalized mobile', () => { + expect(isValidMobile('+989121234567')).toBe(true); + expect(isValidMobile('09121234567')).toBe(false); + }); +}); + +describe('formatMobileForDisplay', () => { + it('formats E.164 to local spaced form', () => { + expect(formatMobileForDisplay('+989121234567')).toBe('0912 123 4567'); + }); +}); + +describe('mobileSearchDigits', () => { + it('strips non-digits', () => { + expect(mobileSearchDigits('+98 912-123-4567')).toBe('989121234567'); + }); +}); diff --git a/backend/src/common/phone.ts b/backend/src/common/phone.ts new file mode 100644 index 0000000..b2b0ada --- /dev/null +++ b/backend/src/common/phone.ts @@ -0,0 +1,54 @@ +/** Canonical Iran mobile: +989XXXXXXXXX (12 chars). */ +export const IR_MOBILE_REGEX = /^\+989\d{9}$/; + +/** + * Normalize user-entered mobile to E.164 for Iran (+98…). + * Accepts 09…, 9…, +98…, 0098… with optional spaces/dashes. + */ +export function normalizeMobile(input: string): string | null { + const trimmed = input?.trim(); + if (!trimmed) { + return null; + } + + let digits = trimmed.replace(/[^\d+]/g, ''); + if (digits.startsWith('+')) { + digits = digits.slice(1); + } + + digits = digits.replace(/\D/g, ''); + + if (digits.startsWith('0098')) { + digits = digits.slice(4); + } else if (digits.startsWith('98') && digits.length >= 12) { + digits = digits.slice(2); + } + + if (digits.startsWith('0') && digits.length === 11) { + digits = digits.slice(1); + } + + if (digits.length === 10 && digits.startsWith('9')) { + return `+98${digits}`; + } + + return null; +} + +export function isValidMobile(normalized: string): boolean { + return IR_MOBILE_REGEX.test(normalized); +} + +/** Display-friendly local format: 09XX XXX XXXX */ +export function formatMobileForDisplay(normalized: string): string { + if (!isValidMobile(normalized)) { + return normalized; + } + const local = `0${normalized.slice(3)}`; + return `${local.slice(0, 4)} ${local.slice(4, 7)} ${local.slice(7)}`; +} + +/** Strip to digits only for partial search matching. */ +export function mobileSearchDigits(input: string): string { + return input.replace(/\D/g, ''); +} diff --git a/backend/src/modules/appointments/appointments.controller.ts b/backend/src/modules/appointments/appointments.controller.ts index f6dfa7f..12a50f2 100644 --- a/backend/src/modules/appointments/appointments.controller.ts +++ b/backend/src/modules/appointments/appointments.controller.ts @@ -11,6 +11,7 @@ import { UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { AppointmentsService } from './appointments.service'; import { ColumnProvidersQueryDto } from './dto/column-providers-query.dto'; @@ -20,7 +21,7 @@ import { UpdateAppointmentDto } from './dto/update-appointment.dto'; @ApiTags('appointments') @ApiBearerAuth('JWT-auth') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, ClinicOrgGuard) @Controller('appointments') export class AppointmentsController { constructor(private readonly appointmentsService: AppointmentsService) {} diff --git a/backend/src/modules/appointments/appointments.module.ts b/backend/src/modules/appointments/appointments.module.ts index e6f0b5f..e51d71f 100644 --- a/backend/src/modules/appointments/appointments.module.ts +++ b/backend/src/modules/appointments/appointments.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; +import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { StaffModule } from '../staff/staff.module'; import { AppointmentsController } from './appointments.controller'; import { AppointmentsService } from './appointments.service'; @@ -7,6 +8,6 @@ import { AppointmentsService } from './appointments.service'; @Module({ imports: [StaffModule], controllers: [AppointmentsController], - providers: [AppointmentsService, PrismaService], + providers: [AppointmentsService, PrismaService, ClinicOrgGuard], }) export class AppointmentsModule {} diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 2293082..ef1040e 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -14,6 +14,7 @@ import { StaffWorkingHoursService } from '../staff/staff-working-hours.service'; import { CreateAppointmentDto } from './dto/create-appointment.dto'; import { ListAppointmentsDto } from './dto/list-appointments.dto'; import { UpdateAppointmentDto } from './dto/update-appointment.dto'; +import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; const MS_PER_DAY = 86_400_000; @@ -22,6 +23,7 @@ export class AppointmentsService { constructor( private readonly prisma: PrismaService, private readonly staffWorkingHoursService: StaffWorkingHoursService, + private readonly treatmentCatalog: TreatmentCatalogService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { @@ -96,7 +98,7 @@ export class AppointmentsService { }, include: { patient: { - select: { id: true, firstName: true, lastName: true, phone: true }, + select: { id: true, firstName: true, lastName: true, mobile: true }, }, }, orderBy: [{ startAt: 'asc' }], @@ -134,6 +136,7 @@ export class AppointmentsService { await this.ensurePatientInOrg(dto.patientId, organizationId); await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId); + this.treatmentCatalog.assertKnownTreatmentType(dto.purpose); await this.ensureAppointmentWithinProviderWorkingHours( dto.providerUserId, organizationId, @@ -152,7 +155,7 @@ export class AppointmentsService { }, include: { patient: { - select: { id: true, firstName: true, lastName: true, phone: true }, + select: { id: true, firstName: true, lastName: true, mobile: true }, }, }, }); @@ -195,6 +198,8 @@ export class AppointmentsService { const providerUserId = dto.providerUserId ?? existing.providerUserId; const purpose = dto.purpose ?? existing.purpose; + this.treatmentCatalog.assertKnownTreatmentType(purpose); + await this.ensurePatientInOrg(patientId, organizationId); await this.ensureProviderIsTreatmentEditor(providerUserId, organizationId); await this.ensureAppointmentWithinProviderWorkingHours( @@ -215,7 +220,7 @@ export class AppointmentsService { }, include: { patient: { - select: { id: true, firstName: true, lastName: true, phone: true }, + select: { id: true, firstName: true, lastName: true, mobile: true }, }, }, }); @@ -300,9 +305,9 @@ export class AppointmentsService { } } - private async ensurePatientInOrg(patientId: string, organizationId: string) { - const patient = await this.prisma.patient.findFirst({ - where: { id: patientId, organizationId }, + private async ensurePatientInOrg(patientId: string, _organizationId: string) { + const patient = await this.prisma.patient.findUnique({ + where: { id: patientId }, select: { id: true }, }); if (!patient) { diff --git a/backend/src/modules/appointments/dto/create-appointment.dto.ts b/backend/src/modules/appointments/dto/create-appointment.dto.ts index 19b293b..fb34434 100644 --- a/backend/src/modules/appointments/dto/create-appointment.dto.ts +++ b/backend/src/modules/appointments/dto/create-appointment.dto.ts @@ -1,9 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsDateString, IsIn, IsUUID } from 'class-validator'; - -const APPOINTMENT_PURPOSES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const; - -export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number]; +import { IsDateString, IsString, IsUUID, MaxLength } from 'class-validator'; export class CreateAppointmentDto { @ApiProperty() @@ -22,7 +18,11 @@ export class CreateAppointmentDto { @IsDateString() endAt: string; - @ApiProperty({ enum: APPOINTMENT_PURPOSES }) - @IsIn([...APPOINTMENT_PURPOSES]) - purpose: AppointmentPurpose; + @ApiProperty({ + description: 'Treatment type code from the treatment catalog', + example: 'consultation', + }) + @IsString() + @MaxLength(64) + purpose: string; } diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 87538d2..b1132d3 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -20,6 +20,7 @@ import { UpdateLanguageDto, } from './dto/update-language.dto'; import { JwtPayload } from './interfaces/jwt-payload.interface'; +import { ownerPermissionsForOrgType, type OrganizationTypeName } from '../../common/organization-type'; import { SmsService } from '../sms/sms.service'; import { ForgotPasswordSendCodeDto, @@ -46,6 +47,10 @@ const ALL_PERMISSIONS = [ 'TAB_APPOINTMENTS_EDIT', 'TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT', + 'TAB_CASES_READ', + 'TAB_CASES_EDIT', + 'TAB_TASKS_READ', + 'TAB_TASKS_EDIT', 'TAB_BILLING_READ', 'TAB_BILLING_EDIT', 'TAB_REPORTS_READ', @@ -994,11 +999,15 @@ export class AuthService { isOwner: boolean; organization: { plan?: { name: string; maxUsers: number; price: number } | null; + type?: { name: string }; }; permissions?: Array<{ permission: { name: string } }>; }): string[] { if (membership.isOwner) { - return membership.organization.plan ? ALL_PERMISSIONS : READ_ONLY_PERMISSIONS; + const orgType = (membership.organization.type?.name === 'LAB' + ? 'LAB' + : 'CLINIC') as OrganizationTypeName; + return ownerPermissionsForOrgType(orgType, Boolean(membership.organization.plan)); } return membership.permissions?.map((p) => p.permission.name) || []; } diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts new file mode 100644 index 0000000..d54a7b1 --- /dev/null +++ b/backend/src/modules/cases/cases.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Query, + Req, + Res, + UseGuards, +} from '@nestjs/common'; +import type { Response } from 'express'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { LabOrgGuard } from '../../common/guards/lab-org.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CasesService } from './cases.service'; +import { ListLabCasesDto, UpdateLabCaseImportantDto } from './dto/cases.dto'; + +@ApiTags('cases') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard, LabOrgGuard) +@Controller('cases') +export class CasesController { + constructor(private readonly casesService: CasesService) {} + + @Get() + @ApiOperation({ summary: 'List lab cases received by this organization' }) + list(@Query() query: ListLabCasesDto, @Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.list(organizationId, req.user.id, query); + } + + @Get('filter-options') + @ApiOperation({ summary: 'Clinics and treatment types for inbox filters' }) + listFilterOptions(@Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.listFilterOptions(organizationId, req.user.id); + } + + @Get(':id') + @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, req.user.language); + } + + @Get(':id/attachments/:attachmentId/file') + @ApiOperation({ summary: 'Download an attachment shared with this lab case' }) + async downloadAttachment( + @Param('id') id: string, + @Param('attachmentId') attachmentId: string, + @Req() req, + @Res() res: Response, + ) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + const file = await this.casesService.streamCaseAttachment( + id, + attachmentId, + organizationId, + req.user.id, + ); + res.setHeader('Content-Type', file.mimeType); + res.setHeader('Content-Disposition', `inline; filename="${file.fileName}"`); + file.stream.pipe(res); + } + + @Patch(':id/important') + @ApiOperation({ summary: 'Toggle the important flag for a whole case' }) + setCaseImportant( + @Param('id') id: string, + @Body() dto: UpdateLabCaseImportantDto, + @Req() req, + ) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.setCaseImportant(id, dto, organizationId, req.user.id, req.user.language); + } +} diff --git a/backend/src/modules/cases/cases.module.ts b/backend/src/modules/cases/cases.module.ts new file mode 100644 index 0000000..6bd4a71 --- /dev/null +++ b/backend/src/modules/cases/cases.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { LabOrgGuard } from '../../common/guards/lab-org.guard'; +import { TreatmentCatalogModule } from '../treatment-catalog/treatment-catalog.module'; +import { CasesController } from './cases.controller'; +import { CasesService } from './cases.service'; + +@Module({ + imports: [TreatmentCatalogModule], + controllers: [CasesController], + providers: [CasesService, PrismaService, LabOrgGuard], + exports: [CasesService], +}) +export class CasesModule {} diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts new file mode 100644 index 0000000..ea5fe64 --- /dev/null +++ b/backend/src/modules/cases/cases.service.ts @@ -0,0 +1,629 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { createReadStream, existsSync } from 'fs'; +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, UpdateLabCaseImportantDto } from './dto/cases.dto'; +import { normalizeTaskTeeth } from './lab-case-task.util'; + +const labCaseListInclude = { + treatment: { + include: { + organization: { select: { id: true, name: true } }, + patient: { select: { id: true, firstName: true, lastName: true, mobile: true } }, + appointment: { select: { startAt: true } }, + }, + }, + details: { + include: { + detail: { + select: { + id: true, + treatmentType: true, + teeth: true, + comment: true, + }, + }, + }, + }, + sends: { + orderBy: [{ sentAt: 'asc' as const }], + include: { organization: { select: { id: true, name: true } } }, + }, + tasks: { + orderBy: [ + { treatmentDetailId: 'asc' as const }, + { prosthesisTypeCode: 'asc' as const }, + { stepOrder: 'asc' as const }, + ], + include: { + lastStatusChangedBy: { select: { id: true, name: true } }, + statusEvents: { + orderBy: { changedAt: 'asc' as const }, + include: { changedBy: { select: { id: true, name: true } } }, + }, + }, + }, + toothProsthesis: true, + attachments: { + include: { + attachment: { + select: { + id: true, + fileName: true, + mimeType: true, + sizeBytes: true, + createdAt: true, + }, + }, + }, + }, +} satisfies Prisma.LabCaseInclude; + +type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{ + include: { + lastStatusChangedBy: { select: { id: true; name: true } }; + statusEvents: { + include: { changedBy: { select: { id: true; name: true } } }; + }; + }; +}>; + +@Injectable() +export class CasesService { + constructor( + private readonly prisma: PrismaService, + private readonly treatmentCatalog: TreatmentCatalogService, + private readonly catalogLabels: CatalogLabelService, + ) {} + + getOrganizationIdFromUser(user: { organizationId?: string }) { + if (!user?.organizationId) { + throw new BadRequestException('Organization is not selected'); + } + return user.organizationId; + } + + async list(labOrganizationId: string, actorUserId: string, query: ListLabCasesDto) { + await this.assertCanReadCases(actorUserId, labOrganizationId); + + if (query.treatmentType) { + this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType); + } + + const page = query.page ?? 1; + const limit = Math.min(Math.max(query.limit ?? 20, 1), 100); + const skip = (page - 1) * limit; + + const where = this.buildListWhere(labOrganizationId, query); + + const [items, total] = await Promise.all([ + this.prisma.labCase.findMany({ + where, + include: { + treatment: { + include: { + organization: { select: { id: true, name: true } }, + patient: { select: { id: true, firstName: true, lastName: true, mobile: true } }, + }, + }, + details: { + include: { + detail: { select: { treatmentType: true } }, + }, + }, + tasks: { select: { id: true, status: true } }, + }, + orderBy: [{ sentAt: 'desc' }], + skip, + take: limit, + }), + this.prisma.labCase.count({ where }), + ]); + + return { + success: true, + data: { + items: items.map((lc) => this.mapLabCaseListItem(lc)), + pagination: { + page, + limit, + total, + totalPages: Math.max(1, Math.ceil(total / limit)), + }, + }, + }; + } + + async listFilterOptions(labOrganizationId: string, actorUserId: string) { + await this.assertCanReadCases(actorUserId, labOrganizationId); + + const rows = await this.prisma.labCase.findMany({ + where: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + select: { + treatment: { + select: { + organization: { select: { id: true, name: true } }, + }, + }, + details: { + select: { detail: { select: { treatmentType: true } } }, + }, + }, + }); + + const clinicsById = new Map(); + const typeCodes = new Set(); + + for (const row of rows) { + clinicsById.set(row.treatment.organization.id, row.treatment.organization); + for (const link of row.details) { + typeCodes.add(link.detail.treatmentType); + } + } + + 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 })); + + return { + success: true, + data: { + clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)), + treatmentTypes, + }, + }; + } + + /** Cases exchanged between one clinic and one lab (Organizations connection history). */ + async listBetweenOrganizations( + clinicOrganizationId: string, + labOrganizationId: string, + query: ListLabCasesDto, + ) { + if (query.treatmentType) { + this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType); + } + + const page = query.page ?? 1; + const limit = Math.min(Math.max(query.limit ?? 20, 1), 100); + const skip = (page - 1) * limit; + + const where: Prisma.LabCaseWhereInput = { + ...this.buildListWhere(labOrganizationId, query), + treatment: { organizationId: clinicOrganizationId }, + sends: { some: { organizationId: labOrganizationId } }, + }; + + const [items, total] = await Promise.all([ + this.prisma.labCase.findMany({ + where, + include: { + treatment: { + include: { + organization: { select: { id: true, name: true } }, + patient: { select: { id: true, firstName: true, lastName: true, mobile: true } }, + }, + }, + details: { + include: { + detail: { select: { treatmentType: true } }, + }, + }, + tasks: { select: { id: true, status: true } }, + }, + orderBy: [{ sentAt: 'desc' }], + skip, + take: limit, + }), + this.prisma.labCase.count({ where }), + ]); + + return { + success: true, + data: { + items: items.map((lc) => this.mapLabCaseListItem(lc)), + pagination: { + page, + limit, + total, + totalPages: Math.max(1, Math.ceil(total / limit)), + }, + }, + }; + } + + async getOneBetweenOrganizations( + labCaseId: string, + clinicOrganizationId: string, + labOrganizationId: string, + localeInput?: string | null, + ) { + const labCase = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + sentAt: { not: null }, + treatment: { organizationId: clinicOrganizationId }, + sends: { some: { organizationId: labOrganizationId } }, + }, + include: labCaseListInclude, + }); + + if (!labCase) { + throw new NotFoundException('Case not found'); + } + + return { + success: true, + data: await this.mapLabCaseDetail(labCase, localeInput), + }; + } + + async getOne( + labCaseId: string, + labOrganizationId: string, + actorUserId: string, + localeInput?: string | null, + ) { + await this.assertCanReadCases(actorUserId, labOrganizationId); + + const labCase = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + include: labCaseListInclude, + }); + + if (!labCase) { + throw new NotFoundException('Case not found'); + } + + return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) }; + } + + async streamCaseAttachment( + labCaseId: string, + attachmentId: string, + labOrganizationId: string, + actorUserId: string, + ) { + await this.assertCanReadCases(actorUserId, labOrganizationId); + + const link = await this.prisma.labCaseAttachment.findFirst({ + where: { + labCaseId, + attachmentId, + labCase: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + }, + include: { + attachment: { select: { storagePath: true, fileName: true, mimeType: true } }, + }, + }); + + if (!link?.attachment) { + throw new NotFoundException('Attachment not found'); + } + + if (!existsSync(link.attachment.storagePath)) { + throw new NotFoundException('Attachment file is missing on disk'); + } + + return { + stream: createReadStream(link.attachment.storagePath), + fileName: link.attachment.fileName, + mimeType: link.attachment.mimeType, + }; + } + + async setCaseImportant( + labCaseId: string, + dto: UpdateLabCaseImportantDto, + labOrganizationId: string, + actorUserId: string, + localeInput?: string | null, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + + const existing = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + select: { id: true }, + }); + + if (!existing) { + throw new NotFoundException('Case not found'); + } + + await this.prisma.labCase.update({ + where: { id: labCaseId }, + data: { isImportant: dto.isImportant }, + }); + + const labCase = await this.prisma.labCase.findFirstOrThrow({ + where: { id: labCaseId }, + include: labCaseListInclude, + }); + + return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) }; + } + + private buildListWhere( + labOrganizationId: string, + query: ListLabCasesDto, + ): Prisma.LabCaseWhereInput { + const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null }; + + if (query.sentFrom) { + const from = new Date(query.sentFrom); + if (Number.isNaN(from.getTime())) { + throw new BadRequestException('Invalid sentFrom date'); + } + sentAtFilter.gte = from; + } + + if (query.sentTo) { + const to = new Date(query.sentTo); + if (Number.isNaN(to.getTime())) { + throw new BadRequestException('Invalid sentTo date'); + } + to.setHours(23, 59, 59, 999); + sentAtFilter.lte = to; + } + + return { + sentAt: sentAtFilter, + sends: { some: { organizationId: labOrganizationId } }, + ...(query.clinicOrganizationId + ? { treatment: { organizationId: query.clinicOrganizationId } } + : {}), + ...(query.treatmentType + ? { + details: { + some: { detail: { treatmentType: query.treatmentType } }, + }, + } + : {}), + ...(query.q?.trim() ? this.buildSearchWhere(query.q.trim()) : {}), + }; + } + + private buildSearchWhere(q: string): Prisma.LabCaseWhereInput { + const orConditions: Prisma.LabCaseWhereInput[] = [ + { + treatment: { + patient: { + OR: [ + { firstName: { contains: q, mode: 'insensitive' } }, + { lastName: { contains: q, mode: 'insensitive' } }, + ], + }, + }, + }, + { + treatment: { + organization: { name: { contains: q, mode: 'insensitive' } }, + }, + }, + ]; + + const normalized = normalizeMobile(q); + if (normalized) { + orConditions.push({ + treatment: { patient: { mobile: normalized } }, + }); + } + + return { OR: orConditions }; + } + + private mapLabCaseListItem(lc: { + id: string; + sentAt: Date | null; + treatment: { + organization: { id: string; name: string }; + patient: { id: string; firstName: string; lastName: string; mobile: string }; + }; + details: Array<{ detail: { treatmentType: string } }>; + tasks: Array<{ id: string; status: LabTaskStatus }>; + }) { + const treatmentType = lc.details[0]?.detail.treatmentType ?? null; + const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length; + + return { + id: lc.id, + sentAt: lc.sentAt?.toISOString() ?? null, + clinic: lc.treatment.organization, + patient: { + id: lc.treatment.patient.id, + firstName: lc.treatment.patient.firstName, + lastName: lc.treatment.patient.lastName, + mobile: lc.treatment.patient.mobile, + }, + treatmentType, + taskProgress: { + completed: completedTasks, + total: lc.tasks.length, + }, + }; + } + + private async mapLabCaseDetail( + lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>, + localeInput?: string | null, + ) { + const locale = normalizeCatalogLocale(localeInput); + const treatmentType = lc.details[0]?.detail.treatmentType ?? null; + const link = lc.details[0]; + 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.groupTasks(lc.tasks, prosthesisLabels); + + return { + id: lc.id, + sentAt: lc.sentAt?.toISOString() ?? null, + isImportant: lc.isImportant, + clinic: lc.treatment.organization, + patient: lc.treatment.patient, + appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null, + treatmentType, + detail: link + ? { + id: link.detail.id, + treatmentType: link.detail.treatmentType, + teeth: normalizeTeeth(link.detail.teeth), + comment: link.detail.comment, + } + : null, + toothProsthesis: lc.toothProsthesis.map((row) => ({ + treatmentDetailId: row.treatmentDetailId, + tooth: row.tooth, + prosthesisTypeCode: row.prosthesisTypeCode, + })), + attachments: lc.attachments.map((row) => ({ + id: row.attachment.id, + fileName: row.attachment.fileName, + mimeType: row.attachment.mimeType, + sizeBytes: row.attachment.sizeBytes, + createdAt: row.attachment.createdAt.toISOString(), + })), + sends: lc.sends.map((s) => ({ + organizationId: s.organizationId, + organizationName: s.organization.name, + sentAt: s.sentAt.toISOString(), + })), + tasks: lc.tasks.map((t) => this.mapTask(t, prosthesisLabels)), + tasksByTooth, + taskProgress: { + completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length, + total: lc.tasks.length, + }, + }; + } + + private groupTasks( + tasks: LabCaseTaskWithRelations[], + prosthesisLabels: Map, + ) { + const groups = new Map< + string, + { + treatmentDetailId: string; + teeth: string[]; + treatmentType: string; + prosthesisTypeCode: string; + prosthesisTypeLabel: string; + tasks: ReturnType[]; + } + >(); + + for (const task of tasks) { + const key = `${task.treatmentDetailId}:${task.prosthesisTypeCode}`; + const entry = groups.get(key) ?? { + treatmentDetailId: task.treatmentDetailId, + teeth: normalizeTaskTeeth(task.teeth), + treatmentType: task.treatmentType, + prosthesisTypeCode: task.prosthesisTypeCode, + prosthesisTypeLabel: + prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode, + tasks: [], + }; + entry.tasks.push(this.mapTask(task, prosthesisLabels)); + groups.set(key, entry); + } + + return [...groups.values()]; + } + + private mapTask( + task: LabCaseTaskWithRelations, + prosthesisLabels: Map, + ) { + return { + id: task.id, + treatmentDetailId: task.treatmentDetailId, + teeth: normalizeTaskTeeth(task.teeth), + 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, + createdAt: task.createdAt.toISOString(), + lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null, + lastStatusChangedBy: task.lastStatusChangedBy + ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name } + : null, + timeline: task.statusEvents.map((event) => ({ + id: event.id, + fromStatus: event.fromStatus, + toStatus: event.toStatus, + changedAt: event.changedAt.toISOString(), + changedBy: event.changedBy + ? { id: event.changedBy.id, name: event.changedBy.name } + : null, + })), + }; + } + + private async assertCanReadCases(userId: string, organizationId: string) { + const m = await this.getMembership(userId, organizationId); + if (!m) { + throw new ForbiddenException('You are not a member of this organization'); + } + if (m.isOwner) return; + const names = m.permissions.map((p) => p.permission.name); + if (names.includes('TAB_CASES_READ') || names.includes('TAB_CASES_EDIT')) { + return; + } + throw new ForbiddenException('You do not have access to cases'); + } + + private async assertCanEditCases(userId: string, organizationId: string) { + const m = await this.getMembership(userId, organizationId); + if (!m) { + throw new ForbiddenException('You are not a member of this organization'); + } + if (m.isOwner) return; + const names = m.permissions.map((p) => p.permission.name); + if (names.includes('TAB_CASES_EDIT')) { + return; + } + throw new ForbiddenException('You cannot update cases'); + } + + private async getMembership(userId: string, organizationId: string) { + return this.prisma.membership.findFirst({ + where: { userId, organizationId, isActive: true }, + include: { permissions: { include: { permission: true } } }, + }); + } +} diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts new file mode 100644 index 0000000..afa969b --- /dev/null +++ b/backend/src/modules/cases/dto/cases.dto.ts @@ -0,0 +1,42 @@ +import { Transform } from 'class-transformer'; +import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; + +export class UpdateLabCaseImportantDto { + @IsBoolean() + isImportant: boolean; +} + +export class ListLabCasesDto { + @IsOptional() + @IsString() + q?: string; + + @IsOptional() + @IsUUID() + clinicOrganizationId?: string; + + @IsOptional() + @IsString() + treatmentType?: string; + + @IsOptional() + @IsDateString() + sentFrom?: string; + + @IsOptional() + @IsDateString() + sentTo?: string; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + @Max(100) + limit = 20; +} 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..31abb0e --- /dev/null +++ b/backend/src/modules/cases/lab-case-task.generator.spec.ts @@ -0,0 +1,184 @@ +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({ + teeth: ['14'], + prosthesisTypeCode: 'pfm_crown', + workflowStepCode: 'intraoral_scan', + stepLabel: 'Intraoral Scan', + status: 'IN_PROGRESS', + }); + 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('groups teeth sharing a prosthesis in one detail, and keeps other prosthesis separate', async () => { + const pfmSteps = stepsFromSeed('pfm_crown'); + const zirconiaSteps = stepsFromSeed('monolithic_zirconia'); + const { tx, created } = buildMockTx({ + toothProsthesisRows: [ + { treatmentDetailId: 'detail-1', tooth: '15', prosthesisTypeCode: 'pfm_crown' }, + { treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'pfm_crown' }, + { treatmentDetailId: 'detail-1', tooth: '16', prosthesisTypeCode: 'monolithic_zirconia' }, + ], + prosthesisTypes: [ + { code: 'pfm_crown', steps: pfmSteps }, + { code: 'monolithic_zirconia', steps: zirconiaSteps }, + ], + }); + + const count = await generateLabCaseTasks(tx as never, 'lab-case-group', 'en'); + + expect(count).toBe(pfmSteps.length + zirconiaSteps.length); + const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>; + const pfmRows = rows.filter((r) => r.prosthesisTypeCode === 'pfm_crown'); + const zirconiaRows = rows.filter((r) => r.prosthesisTypeCode === 'monolithic_zirconia'); + + expect(pfmRows).toHaveLength(pfmSteps.length); + expect(zirconiaRows).toHaveLength(zirconiaSteps.length); + // Teeth sharing the prosthesis in the same detail are merged and sorted. + expect(pfmRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15']))).toBe(true); + expect(zirconiaRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['16']))).toBe(true); + }); + + 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 new file mode 100644 index 0000000..3279291 --- /dev/null +++ b/backend/src/modules/cases/lab-case-task.generator.ts @@ -0,0 +1,154 @@ +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 locale = normalizeCatalogLocale(localeInput); + + const toothProsthesisRows = await tx.labCaseToothProsthesis.findMany({ + where: { labCaseId }, + include: { + 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 } } }, + }, + }, + }); + + const stepsByProsthesisCode = new Map( + prosthesisTypes.map((type) => [ + type.code, + type.steps.map((s) => ({ + stepOrder: s.stepOrder, + workflowStepCode: s.labWorkflowStep.code, + })), + ]), + ); + + const allStepCodes = [ + ...new Set( + prosthesisTypes.flatMap((type) => + type.steps.map((s) => s.labWorkflowStep.code), + ), + ), + ]; + + const stepLabels = await resolveStepLabels(tx, allStepCodes, locale); + + // Group teeth that share the same (treatment detail + prosthesis type): one task set + // per group, with each step covering every tooth in that group. + const groups = new Map< + string, + { treatmentDetailId: string; treatmentType: string; prosthesisTypeCode: string; teeth: string[] } + >(); + + for (const row of toothProsthesisRows) { + const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`; + const group = groups.get(key) ?? { + treatmentDetailId: row.treatmentDetailId, + treatmentType: row.detail.treatmentType, + prosthesisTypeCode: row.prosthesisTypeCode, + teeth: [], + }; + group.teeth.push(row.tooth); + groups.set(key, group); + } + + const taskRows: Prisma.LabCaseTaskCreateManyInput[] = []; + + for (const group of groups.values()) { + const typeSteps = stepsByProsthesisCode.get(group.prosthesisTypeCode) ?? []; + if (typeSteps.length === 0) { + continue; + } + + const teeth = sortTeeth(group.teeth); + + for (const step of typeSteps) { + taskRows.push({ + labCaseId, + treatmentDetailId: group.treatmentDetailId, + teeth, + treatmentType: group.treatmentType, + prosthesisTypeCode: group.prosthesisTypeCode, + workflowStepCode: step.workflowStepCode, + stepOrder: step.stepOrder, + stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode, + status: LabTaskStatus.IN_PROGRESS, + }); + } + } + + if (taskRows.length === 0) { + return 0; + } + + await tx.labCaseTask.createMany({ data: taskRows }); + return taskRows.length; +} + +function sortTeeth(teeth: string[]): string[] { + return [...new Set(teeth)].sort((a, b) => { + const na = Number(a); + const nb = Number(b); + if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb; + return a.localeCompare(b); + }); +} + +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/cases/lab-case-task.util.ts b/backend/src/modules/cases/lab-case-task.util.ts new file mode 100644 index 0000000..b445412 --- /dev/null +++ b/backend/src/modules/cases/lab-case-task.util.ts @@ -0,0 +1,11 @@ +import { Prisma } from '@prisma/client'; + +/** Normalize the JSON `teeth` column of a lab case task into a clean string[]. */ +export function normalizeTaskTeeth(value: Prisma.JsonValue | null | undefined): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .filter((v): v is string | number => typeof v === 'string' || typeof v === 'number') + .map((v) => String(v)); +} 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/lab-case-comments/dto/lab-case-comment.dto.ts b/backend/src/modules/lab-case-comments/dto/lab-case-comment.dto.ts new file mode 100644 index 0000000..773f158 --- /dev/null +++ b/backend/src/modules/lab-case-comments/dto/lab-case-comment.dto.ts @@ -0,0 +1,17 @@ +import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class CreateLabCaseCommentDto { + @IsString() + @MinLength(1) + @MaxLength(2000) + body: string; + + @IsOptional() + @IsBoolean() + visibleToClinic?: boolean; +} + +export class SetCommentVisibilityDto { + @IsBoolean() + visibleToClinic: boolean; +} diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts b/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts new file mode 100644 index 0000000..0328c03 --- /dev/null +++ b/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts @@ -0,0 +1,61 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { LabOrgGuard } from '../../common/guards/lab-org.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + CreateLabCaseCommentDto, + SetCommentVisibilityDto, +} from './dto/lab-case-comment.dto'; +import { LabCaseCommentsService } from './lab-case-comments.service'; + +@ApiTags('case-comments') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard, LabOrgGuard) +@Controller('case-comments') +export class LabCaseCommentsController { + constructor(private readonly service: LabCaseCommentsService) {} + + private orgId(req: { user: { organizationId?: string } }) { + return req.user.organizationId as string; + } + + @Get(':caseId') + @ApiOperation({ summary: 'List comments for a lab case (lab side)' }) + list(@Param('caseId') caseId: string, @Req() req) { + return this.service.listForLab(caseId, this.orgId(req), req.user.id); + } + + @Post(':caseId') + @ApiOperation({ summary: 'Add a comment to a lab case (lab side)' }) + add( + @Param('caseId') caseId: string, + @Body() dto: CreateLabCaseCommentDto, + @Req() req, + ) { + return this.service.addForLab(caseId, this.orgId(req), req.user.id, dto); + } + + @Patch('item/:commentId/visibility') + @ApiOperation({ summary: 'Toggle whether a comment is visible to the clinic' }) + setVisibility( + @Param('commentId') commentId: string, + @Body() dto: SetCommentVisibilityDto, + @Req() req, + ) { + return this.service.setVisibility( + commentId, + this.orgId(req), + req.user.id, + dto.visibleToClinic, + ); + } +} diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.module.ts b/backend/src/modules/lab-case-comments/lab-case-comments.module.ts new file mode 100644 index 0000000..e6f8abd --- /dev/null +++ b/backend/src/modules/lab-case-comments/lab-case-comments.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { LabOrgGuard } from '../../common/guards/lab-org.guard'; +import { LabCaseCommentsController } from './lab-case-comments.controller'; +import { LabCaseCommentsService } from './lab-case-comments.service'; + +@Module({ + controllers: [LabCaseCommentsController], + providers: [LabCaseCommentsService, PrismaService, LabOrgGuard], + exports: [LabCaseCommentsService], +}) +export class LabCaseCommentsModule {} diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts new file mode 100644 index 0000000..e69a68c --- /dev/null +++ b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts @@ -0,0 +1,260 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { LabCaseCommentSide, Prisma } from '@prisma/client'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto'; + +const commentInclude = { + authorUser: { select: { id: true, name: true } }, + authorOrganization: { select: { id: true, name: true } }, +} satisfies Prisma.LabCaseCommentInclude; + +type CommentWithRelations = Prisma.LabCaseCommentGetPayload<{ + include: typeof commentInclude; +}>; + +@Injectable() +export class LabCaseCommentsService { + constructor(private readonly prisma: PrismaService) {} + + // ---------- Lab side (TAB_TASKS_EDIT) ---------- + + async listForLab(caseId: string, labOrganizationId: string, actorUserId: string) { + await this.assertLabCanComment(caseId, labOrganizationId, actorUserId); + const comments = await this.fetchComments(caseId); + return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.LAB)) }; + } + + async addForLab( + caseId: string, + labOrganizationId: string, + actorUserId: string, + dto: CreateLabCaseCommentDto, + ) { + await this.assertLabCanComment(caseId, labOrganizationId, actorUserId); + const created = await this.prisma.labCaseComment.create({ + data: { + labCaseId: caseId, + authorUserId: actorUserId, + authorOrganizationId: labOrganizationId, + authorSide: LabCaseCommentSide.LAB, + body: dto.body.trim(), + visibleToClinic: dto.visibleToClinic ?? false, + }, + include: commentInclude, + }); + return { success: true, data: this.mapComment(created, LabCaseCommentSide.LAB) }; + } + + async setVisibility( + commentId: string, + labOrganizationId: string, + actorUserId: string, + visibleToClinic: boolean, + ) { + const comment = await this.prisma.labCaseComment.findUnique({ + where: { id: commentId }, + select: { id: true, labCaseId: true, authorSide: true }, + }); + if (!comment) { + throw new NotFoundException('Comment not found'); + } + await this.assertLabCanComment(comment.labCaseId, labOrganizationId, actorUserId); + if (comment.authorSide !== LabCaseCommentSide.LAB) { + throw new ForbiddenException('Only lab comments can change visibility'); + } + const updated = await this.prisma.labCaseComment.update({ + where: { id: commentId }, + data: { visibleToClinic }, + include: commentInclude, + }); + return { success: true, data: this.mapComment(updated, LabCaseCommentSide.LAB) }; + } + + // ---------- Clinic side (connection history) ---------- + + async listForClinic(caseId: string, clinicOrganizationId: string) { + await this.assertClinicOwnsCase(caseId, clinicOrganizationId, { requireSent: true }); + const comments = await this.fetchCommentsForClinicViewer(caseId); + return { + success: true, + data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.CLINIC)), + }; + } + + async addForClinic( + caseId: string, + clinicOrganizationId: string, + actorUserId: string, + dto: CreateLabCaseCommentDto, + ) { + await this.assertClinicOwnsCase(caseId, clinicOrganizationId, { requireSent: true }); + const created = await this.prisma.labCaseComment.create({ + data: { + labCaseId: caseId, + authorUserId: actorUserId, + authorOrganizationId: clinicOrganizationId, + authorSide: LabCaseCommentSide.CLINIC, + body: dto.body.trim(), + visibleToClinic: true, + }, + include: commentInclude, + }); + return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) }; + } + + // ---------- Clinic side (treatment dispatch — unsent cases allowed) ---------- + + async listForClinicTreatmentCase( + caseId: string, + clinicOrganizationId: string, + actorUserId: string, + ) { + await this.assertClinicTreatmentAccess(caseId, clinicOrganizationId, actorUserId); + const comments = await this.fetchCommentsForClinicViewer(caseId); + return { + success: true, + data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.CLINIC)), + }; + } + + async addForClinicTreatmentCase( + caseId: string, + clinicOrganizationId: string, + actorUserId: string, + dto: CreateLabCaseCommentDto, + ) { + await this.assertClinicTreatmentAccess(caseId, clinicOrganizationId, actorUserId); + const created = await this.prisma.labCaseComment.create({ + data: { + labCaseId: caseId, + authorUserId: actorUserId, + authorOrganizationId: clinicOrganizationId, + authorSide: LabCaseCommentSide.CLINIC, + body: dto.body.trim(), + visibleToClinic: true, + }, + include: commentInclude, + }); + return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) }; + } + + async countForCase(caseId: string) { + const count = await this.prisma.labCaseComment.count({ where: { labCaseId: caseId } }); + return { success: true, data: { count } }; + } + + // ---------- Helpers ---------- + + private fetchCommentsForClinicViewer(caseId: string) { + return this.prisma.labCaseComment.findMany({ + where: { + labCaseId: caseId, + OR: [{ visibleToClinic: true }, { authorSide: LabCaseCommentSide.CLINIC }], + }, + include: commentInclude, + orderBy: { createdAt: 'asc' }, + }); + } + + private fetchComments(caseId: string, opts?: { visibleOnly?: boolean }) { + return this.prisma.labCaseComment.findMany({ + where: { + labCaseId: caseId, + ...(opts?.visibleOnly ? { visibleToClinic: true } : {}), + }, + include: commentInclude, + orderBy: { createdAt: 'asc' }, + }); + } + + private mapComment(comment: CommentWithRelations, viewerSide: LabCaseCommentSide) { + const showVisibilityStatus = viewerSide === LabCaseCommentSide.LAB; + return { + id: comment.id, + body: comment.body, + authorSide: comment.authorSide, + authorName: comment.authorUser?.name ?? null, + authorOrganizationName: comment.authorOrganization?.name ?? null, + visibleToClinic: comment.visibleToClinic, + createdAt: comment.createdAt.toISOString(), + canToggleVisibility: + viewerSide === LabCaseCommentSide.LAB && + comment.authorSide === LabCaseCommentSide.LAB, + showVisibilityStatus, + }; + } + + private async assertLabCanComment( + caseId: string, + labOrganizationId: string, + actorUserId: string, + ) { + const labCase = await this.prisma.labCase.findFirst({ + where: { + id: caseId, + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + select: { id: true }, + }); + if (!labCase) { + throw new NotFoundException('Case not found'); + } + + const membership = await this.prisma.membership.findFirst({ + where: { userId: actorUserId, organizationId: labOrganizationId, isActive: true }, + include: { permissions: { include: { permission: true } } }, + }); + if (!membership) { + throw new ForbiddenException('You are not a member of this organization'); + } + if (membership.isOwner) return; + const names = membership.permissions.map((p) => p.permission.name); + if (!names.includes('TAB_TASKS_EDIT')) { + throw new ForbiddenException('You do not have access to task comments'); + } + } + + private async assertClinicOwnsCase( + caseId: string, + clinicOrganizationId: string, + opts?: { requireSent?: boolean }, + ) { + const labCase = await this.prisma.labCase.findFirst({ + where: { + id: caseId, + ...(opts?.requireSent ? { sentAt: { not: null } } : {}), + treatment: { organizationId: clinicOrganizationId }, + }, + select: { id: true }, + }); + if (!labCase) { + throw new NotFoundException('Case not found'); + } + } + + private async assertClinicTreatmentAccess( + caseId: string, + clinicOrganizationId: string, + actorUserId: string, + ) { + await this.assertClinicOwnsCase(caseId, clinicOrganizationId); + const membership = await this.prisma.membership.findFirst({ + where: { userId: actorUserId, organizationId: clinicOrganizationId, isActive: true }, + include: { permissions: { include: { permission: true } } }, + }); + if (!membership) { + throw new ForbiddenException('You are not a member of this organization'); + } + if (membership.isOwner) return; + const names = membership.permissions.map((p) => p.permission.name); + if (names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT')) { + return; + } + throw new ForbiddenException('You do not have access to treatment cases'); + } +} diff --git a/backend/src/modules/organization/organization.controller.ts b/backend/src/modules/organization/organization.controller.ts index 7f0fc68..548dd3c 100644 --- a/backend/src/modules/organization/organization.controller.ts +++ b/backend/src/modules/organization/organization.controller.ts @@ -18,6 +18,8 @@ import { InviteOrganizationDto } from './dto/invite-organization.dto'; import { PreviewOrganizationInviteDto } from './dto/preview-organization-invite.dto'; import { RespondConnectionRequestDto } from './dto/respond-connection-request.dto'; import { OrganizationService } from './organization.service'; +import { ListLabCasesDto } from '../cases/dto/cases.dto'; +import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto'; /** * Counterpart orgs (clinic↔lab). @@ -121,6 +123,77 @@ export class OrganizationController { return this.organizationService.deleteConnection(req.user.id, organizationId, connectionId); } + @Get('connections/:connectionId/cases') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'List cases exchanged with a connected organization' }) + listConnectionCases( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('connectionId') connectionId: string, + @Query() query: ListLabCasesDto, + ) { + const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); + return this.organizationService.listConnectionCases( + req.user.id, + organizationId, + connectionId, + query, + ); + } + + @Get('connections/:connectionId/cases/:caseId') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Get one case exchanged with a connected organization' }) + getConnectionCase( + @Req() req: { user: { id: string; organizationId?: string; language?: string | null } }, + @Param('connectionId') connectionId: string, + @Param('caseId') caseId: string, + ) { + const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); + return this.organizationService.getConnectionCase( + req.user.id, + organizationId, + connectionId, + caseId, + req.user.language, + ); + } + + @Get('connections/:connectionId/cases/:caseId/comments') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'List clinic-visible comments for a connection case' }) + listConnectionCaseComments( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('connectionId') connectionId: string, + @Param('caseId') caseId: string, + ) { + const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); + return this.organizationService.listConnectionCaseComments( + req.user.id, + organizationId, + connectionId, + caseId, + ); + } + + @Post('connections/:connectionId/cases/:caseId/comments') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Reply to a connection case as the clinic' }) + addConnectionCaseComment( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('connectionId') connectionId: string, + @Param('caseId') caseId: string, + @Body() dto: CreateLabCaseCommentDto, + ) { + const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); + return this.organizationService.addConnectionCaseComment( + req.user.id, + organizationId, + connectionId, + caseId, + dto, + ); + } + @Post('invitations/:invitationId/link') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Get a shareable invite link for a pending invitation' }) diff --git a/backend/src/modules/organization/organization.module.ts b/backend/src/modules/organization/organization.module.ts index d36aef2..18a7d93 100644 --- a/backend/src/modules/organization/organization.module.ts +++ b/backend/src/modules/organization/organization.module.ts @@ -1,9 +1,12 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; +import { CasesModule } from '../cases/cases.module'; +import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module'; import { OrganizationController } from './organization.controller'; import { OrganizationService } from './organization.service'; @Module({ + imports: [CasesModule, LabCaseCommentsModule], controllers: [OrganizationController], providers: [OrganizationService, PrismaService], }) diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index 6945d09..65b2184 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -9,6 +9,10 @@ import { LinkStatus } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { createHash, randomBytes } from 'crypto'; import { PrismaService } from '../../../prisma/prisma.service'; +import { ListLabCasesDto } from '../cases/dto/cases.dto'; +import { CasesService } from '../cases/cases.service'; +import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service'; +import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto'; import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto'; import { CreateConnectionRequestDto } from './dto/create-connection-request.dto'; import { InviteOrganizationDto } from './dto/invite-organization.dto'; @@ -28,7 +32,11 @@ import { RespondConnectionRequestDto } from './dto/respond-connection-request.dt */ @Injectable() export class OrganizationService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly casesService: CasesService, + private readonly commentsService: LabCaseCommentsService, + ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { @@ -340,6 +348,115 @@ export class OrganizationService { }; } + async listConnectionCases( + userId: string, + organizationId: string, + connectionId: string, + query: ListLabCasesDto, + ) { + const actor = await this.getActorMembership(userId, organizationId); + if (!actor || !this.canEditOrganizations(actor)) { + throw new ForbiddenException('You do not have permission to manage organizations'); + } + + const { clinicOrganizationId, labOrganizationId, counterpart } = + await this.resolveActiveConnectionParties(connectionId, organizationId, actor); + + const result = await this.casesService.listBetweenOrganizations( + clinicOrganizationId, + labOrganizationId, + query, + ); + + return { + ...result, + data: { + ...result.data, + counterpart, + }, + }; + } + + async getConnectionCase( + userId: string, + organizationId: string, + connectionId: string, + caseId: string, + localeInput?: string | null, + ) { + const actor = await this.getActorMembership(userId, organizationId); + if (!actor || !this.canEditOrganizations(actor)) { + throw new ForbiddenException('You do not have permission to manage organizations'); + } + + const { clinicOrganizationId, labOrganizationId, counterpart } = + await this.resolveActiveConnectionParties(connectionId, organizationId, actor); + + const result = await this.casesService.getOneBetweenOrganizations( + caseId, + clinicOrganizationId, + labOrganizationId, + localeInput, + ); + + return { + ...result, + data: { + ...result.data, + counterpart, + }, + }; + } + + async listConnectionCaseComments( + userId: string, + organizationId: string, + connectionId: string, + caseId: string, + ) { + const { clinicOrganizationId } = await this.resolveClinicConnection( + userId, + organizationId, + connectionId, + ); + return this.commentsService.listForClinic(caseId, clinicOrganizationId); + } + + async addConnectionCaseComment( + userId: string, + organizationId: string, + connectionId: string, + caseId: string, + dto: CreateLabCaseCommentDto, + ) { + const { clinicOrganizationId } = await this.resolveClinicConnection( + userId, + organizationId, + connectionId, + ); + return this.commentsService.addForClinic(caseId, clinicOrganizationId, userId, dto); + } + + /** + * Clinic comment surfaces require the actor to belong to the clinic side of the connection. + * Only clinic-side members may read/reply to case comments from the connection history. + */ + private async resolveClinicConnection( + userId: string, + organizationId: string, + connectionId: string, + ) { + const actor = await this.getActorMembership(userId, organizationId); + if (!actor || !this.canEditOrganizations(actor)) { + throw new ForbiddenException('You do not have permission to manage organizations'); + } + const parties = await this.resolveActiveConnectionParties(connectionId, organizationId, actor); + if (parties.clinicOrganizationId !== organizationId) { + throw new ForbiddenException('Only the clinic can comment on this case'); + } + return parties; + } + /** Re-issue a shareable URL for a pending invitation (rotates token; previous URL stops working). */ async getInvitationLink(userId: string, organizationId: string, invitationId: string) { const actor = await this.getActorMembership(userId, organizationId); @@ -640,6 +757,58 @@ export class OrganizationService { }; } + private async resolveActiveConnectionParties( + connectionId: string, + organizationId: string, + actor: { + organization: { type: { name: string } }; + }, + ) { + const link = await this.prisma.organizationLink.findFirst({ + where: { + id: connectionId, + status: LinkStatus.ACTIVE, + OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }], + }, + include: { + organizationA: { select: { id: true, name: true, type: true } }, + organizationB: { select: { id: true, name: true, type: true } }, + }, + }); + + if (!link) { + throw new NotFoundException('Connected organization not found'); + } + + const counterpart = + link.organizationAId === organizationId ? link.organizationB : link.organizationA; + + const orgType = actor.organization.type.name; + if (orgType === 'CLINIC') { + if (counterpart.type.name !== 'LAB') { + throw new BadRequestException('Counterpart organization is not a lab'); + } + return { + clinicOrganizationId: organizationId, + labOrganizationId: counterpart.id, + counterpart: { id: counterpart.id, name: counterpart.name }, + }; + } + + if (orgType === 'LAB') { + if (counterpart.type.name !== 'CLINIC') { + throw new BadRequestException('Counterpart organization is not a clinic'); + } + return { + clinicOrganizationId: counterpart.id, + labOrganizationId: organizationId, + counterpart: { id: counterpart.id, name: counterpart.name }, + }; + } + + throw new BadRequestException('Unknown organization type'); + } + private async getActorMembership(userId: string, organizationId: string) { return this.prisma.membership.findFirst({ where: { userId, organizationId }, diff --git a/backend/src/modules/patients/dto/create-patient.dto.ts b/backend/src/modules/patients/dto/create-patient.dto.ts index fa92769..5e72673 100644 --- a/backend/src/modules/patients/dto/create-patient.dto.ts +++ b/backend/src/modules/patients/dto/create-patient.dto.ts @@ -9,10 +9,9 @@ export class CreatePatientDto { @MaxLength(80) lastName: string; - @IsOptional() @IsString() @MaxLength(30) - phone?: string; + mobile: string; @IsOptional() @IsEmail() diff --git a/backend/src/modules/patients/patients.controller.ts b/backend/src/modules/patients/patients.controller.ts index 0e97c6e..cd6fe86 100644 --- a/backend/src/modules/patients/patients.controller.ts +++ b/backend/src/modules/patients/patients.controller.ts @@ -3,7 +3,6 @@ import { Controller, Get, Param, - ParseIntPipe, Patch, Post, Query, @@ -11,6 +10,7 @@ import { UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CreatePatientDto } from './dto/create-patient.dto'; import { ListPatientsDto } from './dto/list-patients.dto'; @@ -19,36 +19,33 @@ import { PatientsService } from './patients.service'; @ApiTags('patients') @ApiBearerAuth('JWT-auth') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, ClinicOrgGuard) @Controller('patients') export class PatientsController { constructor(private readonly patientsService: PatientsService) {} @Post() - @ApiOperation({ summary: 'Create a patient for current organization' }) + @ApiOperation({ summary: 'Create or return existing global patient by mobile' }) create(@Body() createPatientDto: CreatePatientDto, @Req() req) { const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); return this.patientsService.create(createPatientDto, organizationId); } @Get() - @ApiOperation({ summary: 'List patients with search and pagination' }) - findAll(@Query() query: ListPatientsDto, @Req() req) { - const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); - return this.patientsService.findAll(query, organizationId); + @ApiOperation({ summary: 'Search all patients globally' }) + findAll(@Query() query: ListPatientsDto) { + return this.patientsService.findAll(query); } @Get(':id') @ApiOperation({ summary: 'Get one patient by id' }) - findOne(@Param('id') id: string, @Req() req) { - const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); - return this.patientsService.findOne(id, organizationId); + findOne(@Param('id') id: string) { + return this.patientsService.findOne(id); } @Patch(':id') - @ApiOperation({ summary: 'Update patient' }) - update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto, @Req() req) { - const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); - return this.patientsService.update(id, updatePatientDto, organizationId); + @ApiOperation({ summary: 'Update global patient record' }) + update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto) { + return this.patientsService.update(id, updatePatientDto); } } diff --git a/backend/src/modules/patients/patients.module.ts b/backend/src/modules/patients/patients.module.ts index 514afd3..1f1a53d 100644 --- a/backend/src/modules/patients/patients.module.ts +++ b/backend/src/modules/patients/patients.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; +import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { PatientsController } from './patients.controller'; import { PatientsService } from './patients.service'; @Module({ controllers: [PatientsController], - providers: [PatientsService, PrismaService], + providers: [PatientsService, PrismaService, ClinicOrgGuard], }) export class PatientsModule {} diff --git a/backend/src/modules/patients/patients.service.ts b/backend/src/modules/patients/patients.service.ts index ac99936..6a1f6fc 100644 --- a/backend/src/modules/patients/patients.service.ts +++ b/backend/src/modules/patients/patients.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; +import { isValidMobile, mobileSearchDigits, normalizeMobile } from '../../common/phone'; import { CreatePatientDto } from './dto/create-patient.dto'; import { ListPatientsDto } from './dto/list-patients.dto'; import { UpdatePatientDto } from './dto/update-patient.dto'; @@ -9,34 +10,38 @@ export class PatientsService { constructor(private readonly prisma: PrismaService) {} async create(createPatientDto: CreatePatientDto, organizationId: string) { + const mobile = this.resolveMobile(createPatientDto.mobile); + + const existing = await this.prisma.patient.findUnique({ + where: { mobile }, + }); + + if (existing) { + return { success: true, data: existing, existing: true as const }; + } + const patient = await this.prisma.patient.create({ data: { - ...createPatientDto, + firstName: createPatientDto.firstName.trim(), + lastName: createPatientDto.lastName.trim(), + mobile, + email: createPatientDto.email?.trim() || null, + notes: createPatientDto.notes?.trim() || null, dateOfBirth: createPatientDto.dateOfBirth ? new Date(createPatientDto.dateOfBirth) : null, - organizationId, + createdByOrganizationId: organizationId, }, }); - return { success: true, data: patient }; + return { success: true, data: patient, existing: false as const }; } - async findAll(query: ListPatientsDto, organizationId: string) { + async findAll(query: ListPatientsDto) { const { page = 1, limit = 10, q } = query; const skip = (page - 1) * limit; - const where = { - organizationId, - ...(q - ? { - OR: [ - { firstName: { contains: q, mode: 'insensitive' as const } }, - { lastName: { contains: q, mode: 'insensitive' as const } }, - { email: { contains: q, mode: 'insensitive' as const } }, - { phone: { contains: q, mode: 'insensitive' as const } }, - ], - } - : {}), - }; + const where = q?.trim() + ? this.buildSearchWhere(q.trim()) + : {}; const [items, total] = await Promise.all([ this.prisma.patient.findMany({ @@ -62,9 +67,9 @@ export class PatientsService { }; } - async findOne(id: string, organizationId: string) { - const patient = await this.prisma.patient.findFirst({ - where: { id, organizationId }, + async findOne(id: string) { + const patient = await this.prisma.patient.findUnique({ + where: { id }, }); if (!patient) { @@ -74,35 +79,92 @@ export class PatientsService { return { success: true, data: patient }; } - async update(id: string, updatePatientDto: UpdatePatientDto, organizationId: string) { - await this.ensurePatient(id, organizationId); + async update(id: string, updatePatientDto: UpdatePatientDto) { + await this.ensurePatient(id); + + const data: { + firstName?: string; + lastName?: string; + mobile?: string; + email?: string | null; + notes?: string | null; + dateOfBirth?: Date | null; + } = {}; + + if (updatePatientDto.firstName !== undefined) { + data.firstName = updatePatientDto.firstName.trim(); + } + if (updatePatientDto.lastName !== undefined) { + data.lastName = updatePatientDto.lastName.trim(); + } + if (updatePatientDto.mobile !== undefined) { + data.mobile = this.resolveMobile(updatePatientDto.mobile); + } + if (updatePatientDto.email !== undefined) { + data.email = updatePatientDto.email?.trim() || null; + } + if (updatePatientDto.notes !== undefined) { + data.notes = updatePatientDto.notes?.trim() || null; + } + if (updatePatientDto.dateOfBirth !== undefined) { + data.dateOfBirth = updatePatientDto.dateOfBirth + ? new Date(updatePatientDto.dateOfBirth) + : null; + } const patient = await this.prisma.patient.update({ where: { id }, - data: { - ...updatePatientDto, - dateOfBirth: updatePatientDto.dateOfBirth ? new Date(updatePatientDto.dateOfBirth) : undefined, - }, + data, }); return { success: true, data: patient }; } - private async ensurePatient(id: string, organizationId: string) { - const patient = await this.prisma.patient.findFirst({ - where: { id, organizationId }, - select: { id: true }, - }); - - if (!patient) { - throw new NotFoundException('Patient not found'); - } - } - getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { throw new BadRequestException('Organization is not selected'); } return user.organizationId; } + + private buildSearchWhere(q: string) { + const orConditions: Array> = [ + { firstName: { contains: q, mode: 'insensitive' as const } }, + { lastName: { contains: q, mode: 'insensitive' as const } }, + { email: { contains: q, mode: 'insensitive' as const } }, + ]; + + const normalized = normalizeMobile(q); + if (normalized) { + orConditions.push({ mobile: normalized }); + } else { + const digits = mobileSearchDigits(q); + if (digits.length >= 3) { + orConditions.push({ mobile: { contains: digits } }); + } + } + + return { OR: orConditions }; + } + + private resolveMobile(raw: string): string { + const mobile = normalizeMobile(raw); + if (!mobile || !isValidMobile(mobile)) { + throw new BadRequestException( + 'Invalid mobile number. Use a valid Iran mobile (e.g. 09121234567 or +989121234567).', + ); + } + return mobile; + } + + private async ensurePatient(id: string) { + const patient = await this.prisma.patient.findUnique({ + where: { id }, + select: { id: true }, + }); + + if (!patient) { + throw new NotFoundException('Patient not found'); + } + } } 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/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index 60f4b23..eb476b8 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -11,6 +11,10 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto'; import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions'; +import { + filterPermissionsForOrgType, + getOrganizationTypeName, +} from '../../common/organization-type'; import { InviteStaffDto } from './dto/invite-staff.dto'; import { UpdateStaffMemberDto } from './dto/update-staff-member.dto'; @@ -96,7 +100,8 @@ export class StaffService { } const email = dto.email.trim().toLowerCase(); - const normalizedPerms = normalizeTabPermissions(dto.permissionNames); + const orgType = await getOrganizationTypeName(this.prisma, organizationId); + const normalizedPerms = filterPermissionsForOrgType(dto.permissionNames, orgType); const permissionRows = await this.prisma.permission.findMany({ where: { name: { in: normalizedPerms } }, @@ -371,7 +376,8 @@ export class StaffService { } if (dto.permissionNames !== undefined) { - const normalizedPerms = normalizeTabPermissions(dto.permissionNames); + const orgType = await getOrganizationTypeName(this.prisma, organizationId); + const normalizedPerms = filterPermissionsForOrgType(dto.permissionNames, orgType); const permissionRows = await this.prisma.permission.findMany({ where: { name: { in: normalizedPerms } }, select: { id: true, name: true }, diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts new file mode 100644 index 0000000..c1ffe75 --- /dev/null +++ b/backend/src/modules/tasks/dto/tasks.dto.ts @@ -0,0 +1,88 @@ +import { + IsBoolean, + IsDateString, + IsEnum, + IsIn, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + Min, +} from 'class-validator'; +import { Transform } from 'class-transformer'; +import { LabTaskStatus } from '@prisma/client'; + +const toBoolean = ({ value }: { value: unknown }) => { + if (typeof value === 'boolean') return value; + if (value === 'true' || value === '1') return true; + if (value === 'false' || value === '0') return false; + return value; +}; + +export class UpdateLabTaskDto { + @IsEnum(LabTaskStatus) + status: LabTaskStatus; +} + +export type TaskSortField = + | 'date' + | 'status' + | 'clinic' + | 'patient' + | 'important' + | 'prosthesis' + | 'taskType'; + +export class ListLabTasksDto { + @IsOptional() + @IsString() + q?: string; + + @IsOptional() + @IsUUID() + clinicOrganizationId?: string; + + @IsOptional() + @IsEnum(LabTaskStatus) + status?: LabTaskStatus; + + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + completed?: boolean; + + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + important?: boolean; + + @IsOptional() + @IsDateString() + sentFrom?: string; + + @IsOptional() + @IsDateString() + sentTo?: string; + + @IsOptional() + @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType']) + sortBy?: TaskSortField; + + @IsOptional() + @IsIn(['asc', 'desc']) + sortDir?: 'asc' | 'desc'; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + @Max(100) + limit = 50; +} diff --git a/backend/src/modules/tasks/tasks.controller.ts b/backend/src/modules/tasks/tasks.controller.ts new file mode 100644 index 0000000..279d86c --- /dev/null +++ b/backend/src/modules/tasks/tasks.controller.ts @@ -0,0 +1,38 @@ +import { Body, Controller, Get, Param, Patch, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { LabOrgGuard } from '../../common/guards/lab-org.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto'; +import { TasksService } from './tasks.service'; + +@ApiTags('tasks') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard, LabOrgGuard) +@Controller('tasks') +export class TasksController { + constructor(private readonly tasksService: TasksService) {} + + @Get() + @ApiOperation({ summary: 'List lab tasks' }) + list(@Query() query: ListLabTasksDto, @Req() req) { + const organizationId = this.tasksService.getOrganizationIdFromUser(req.user); + return this.tasksService.list(organizationId, req.user.id, query, req.user.language); + } + + @Patch(':taskId') + @ApiOperation({ summary: 'Update task status' }) + updateStatus( + @Param('taskId') taskId: string, + @Body() dto: UpdateLabTaskDto, + @Req() req, + ) { + const organizationId = this.tasksService.getOrganizationIdFromUser(req.user); + return this.tasksService.updateStatus( + taskId, + dto, + organizationId, + req.user.id, + req.user.language, + ); + } +} diff --git a/backend/src/modules/tasks/tasks.module.ts b/backend/src/modules/tasks/tasks.module.ts new file mode 100644 index 0000000..76ba304 --- /dev/null +++ b/backend/src/modules/tasks/tasks.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TasksController } from './tasks.controller'; +import { TasksService } from './tasks.service'; + +@Module({ + controllers: [TasksController], + providers: [TasksService], +}) +export class TasksModule {} diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts new file mode 100644 index 0000000..e3ded96 --- /dev/null +++ b/backend/src/modules/tasks/tasks.service.ts @@ -0,0 +1,318 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +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 { normalizeTaskTeeth } from '../cases/lab-case-task.util'; +import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto'; + +const taskListInclude = { + lastStatusChangedBy: { select: { id: true, name: true } }, + labCase: { + include: { + treatment: { + include: { + organization: { select: { id: true, name: true } }, + patient: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + }, + }, +} satisfies Prisma.LabCaseTaskInclude; + +@Injectable() +export class TasksService { + constructor( + private readonly prisma: PrismaService, + private readonly catalogLabels: CatalogLabelService, + ) {} + + getOrganizationIdFromUser(user: { organizationId?: string }) { + if (!user?.organizationId) { + throw new BadRequestException('Organization is not selected'); + } + return user.organizationId; + } + + async list( + labOrganizationId: string, + actorUserId: string, + query: ListLabTasksDto, + localeInput?: string | null, + ) { + await this.assertCanReadTasks(actorUserId, labOrganizationId); + + const page = query.page ?? 1; + const limit = Math.min(Math.max(query.limit ?? 50, 1), 100); + const skip = (page - 1) * limit; + + const where = this.buildListWhere(labOrganizationId, query); + + const [items, total] = await Promise.all([ + this.prisma.labCaseTask.findMany({ + where, + include: taskListInclude, + orderBy: this.buildOrderBy(query), + skip, + take: limit, + }), + 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, prosthesisLabels)), + pagination: { + page, + limit, + total, + totalPages: Math.max(1, Math.ceil(total / limit)), + }, + }, + }; + } + + async updateStatus( + taskId: string, + dto: UpdateLabTaskDto, + labOrganizationId: string, + actorUserId: string, + localeInput?: string | null, + ) { + await this.assertCanEditTasks(actorUserId, labOrganizationId); + + const task = await this.prisma.labCaseTask.findFirst({ + where: { + id: taskId, + labCase: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + }, + include: taskListInclude, + }); + + if (!task) { + throw new NotFoundException('Task not found'); + } + + const updated = await this.prisma.$transaction(async (tx) => { + const result = await tx.labCaseTask.update({ + where: { id: taskId }, + data: { + status: dto.status, + lastStatusChangedByUserId: actorUserId, + lastStatusChangedAt: new Date(), + }, + include: taskListInclude, + }); + + if (task.status !== dto.status) { + await tx.labCaseTaskStatusEvent.create({ + data: { + taskId, + fromStatus: task.status, + toStatus: dto.status, + changedByUserId: actorUserId, + }, + }); + } + + return result; + }); + + 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 buildListWhere( + labOrganizationId: string, + query: ListLabTasksDto, + ): Prisma.LabCaseTaskWhereInput { + const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null }; + + if (query.sentFrom) { + const from = new Date(query.sentFrom); + if (Number.isNaN(from.getTime())) { + throw new BadRequestException('Invalid sentFrom date'); + } + sentAtFilter.gte = from; + } + if (query.sentTo) { + const to = new Date(query.sentTo); + if (Number.isNaN(to.getTime())) { + throw new BadRequestException('Invalid sentTo date'); + } + to.setHours(23, 59, 59, 999); + sentAtFilter.lte = to; + } + + // Status: explicit status wins; completed=true/false narrows; otherwise no status filter. + let status: LabTaskStatus | undefined; + if (query.status) { + status = query.status; + } else if (query.completed === true) { + status = LabTaskStatus.COMPLETED; + } else if (query.completed === false) { + status = LabTaskStatus.IN_PROGRESS; + } + + return { + labCase: { + sentAt: sentAtFilter, + sends: { some: { organizationId: labOrganizationId } }, + ...(query.clinicOrganizationId + ? { treatment: { organizationId: query.clinicOrganizationId } } + : {}), + ...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}), + ...(query.important !== undefined ? { isImportant: query.important } : {}), + }, + ...(status !== undefined ? { status } : {}), + }; + } + + private buildSearchWhere(q: string): Prisma.TreatmentWhereInput { + const orConditions: Prisma.PatientWhereInput[] = [ + { firstName: { contains: q, mode: 'insensitive' } }, + { lastName: { contains: q, mode: 'insensitive' } }, + ]; + const normalized = normalizeMobile(q); + if (normalized) { + orConditions.push({ mobile: normalized }); + } + return { + OR: [ + { patient: { OR: orConditions } }, + { organization: { name: { contains: q, mode: 'insensitive' } } }, + ], + }; + } + + private buildOrderBy(query: ListLabTasksDto): Prisma.LabCaseTaskOrderByWithRelationInput[] { + const dir = query.sortDir ?? 'desc'; + switch (query.sortBy) { + case 'status': + return [{ status: dir }, { createdAt: 'desc' }, { id: 'asc' }]; + case 'clinic': + return [ + { labCase: { treatment: { organization: { name: dir } } } }, + { createdAt: 'desc' }, + { id: 'asc' }, + ]; + case 'patient': + return [ + { labCase: { treatment: { patient: { lastName: dir } } } }, + { labCase: { treatment: { patient: { firstName: dir } } } }, + { id: 'asc' }, + ]; + case 'important': + return [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, { id: 'asc' }]; + case 'prosthesis': + return [{ prosthesisTypeCode: dir }, { createdAt: 'desc' }, { id: 'asc' }]; + case 'taskType': + return [ + { workflowStepCode: dir }, + { stepOrder: 'asc' }, + { createdAt: 'desc' }, + { id: 'asc' }, + ]; + case 'date': + default: + // date / caseId / taskId / stepId — newest first by default. + return [ + { labCase: { sentAt: dir } }, + { labCaseId: dir }, + { id: dir }, + { stepOrder: dir }, + ]; + } + } + + private mapTaskListItem( + task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>, + prosthesisLabels: Map, + ) { + return { + id: task.id, + labCaseId: task.labCaseId, + treatmentDetailId: task.treatmentDetailId, + teeth: normalizeTaskTeeth(task.teeth), + 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, + isImportant: task.labCase.isImportant, + lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null, + lastStatusChangedBy: task.lastStatusChangedBy + ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name } + : null, + createdAt: task.createdAt.toISOString(), + clinic: task.labCase.treatment.organization, + patient: { + id: task.labCase.treatment.patient.id, + firstName: task.labCase.treatment.patient.firstName, + lastName: task.labCase.treatment.patient.lastName, + }, + }; + } + + private async assertCanReadTasks(userId: string, organizationId: string) { + const m = await this.getMembership(userId, organizationId); + if (!m) { + throw new ForbiddenException('You are not a member of this organization'); + } + if (m.isOwner) return; + const names = m.permissions.map((p) => p.permission.name); + if (names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT')) { + return; + } + throw new ForbiddenException('You do not have access to tasks'); + } + + private async assertCanEditTasks(userId: string, organizationId: string) { + const m = await this.getMembership(userId, organizationId); + if (!m) { + throw new ForbiddenException('You are not a member of this organization'); + } + if (m.isOwner) return; + const names = m.permissions.map((p) => p.permission.name); + if (names.includes('TAB_TASKS_EDIT')) { + return; + } + throw new ForbiddenException('You cannot update tasks'); + } + + private async getMembership(userId: string, organizationId: string) { + return this.prisma.membership.findFirst({ + where: { userId, organizationId, isActive: true }, + include: { permissions: { include: { permission: true } } }, + }); + } +} diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts b/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts new file mode 100644 index 0000000..fef1323 --- /dev/null +++ b/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + TreatmentCatalogContext, + TreatmentCatalogService, +} from './treatment-catalog.service'; + +@ApiTags('treatment-catalog') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('treatment-catalog') +export class TreatmentCatalogController { + constructor(private readonly treatmentCatalogService: TreatmentCatalogService) {} + + @Get() + @ApiOperation({ summary: 'List active treatment types with localized labels' }) + @ApiQuery({ + name: 'context', + required: false, + enum: ['appointment', 'treatment'], + description: 'Filter to types selectable in the given context', + }) + async list( + @Req() req: { user?: { language?: string | null } }, + @Query('context') context?: string, + ) { + const normalizedContext = + context === 'appointment' || context === 'treatment' + ? (context as TreatmentCatalogContext) + : undefined; + const data = await this.treatmentCatalogService.list( + req.user?.language, + normalizedContext, + ); + return { success: true, data }; + } +} diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.module.ts b/backend/src/modules/treatment-catalog/treatment-catalog.module.ts new file mode 100644 index 0000000..10c4315 --- /dev/null +++ b/backend/src/modules/treatment-catalog/treatment-catalog.module.ts @@ -0,0 +1,12 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { TreatmentCatalogController } from './treatment-catalog.controller'; +import { TreatmentCatalogService } from './treatment-catalog.service'; + +@Global() +@Module({ + controllers: [TreatmentCatalogController], + providers: [TreatmentCatalogService, PrismaService], + exports: [TreatmentCatalogService], +}) +export class TreatmentCatalogModule {} diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.service.ts b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts new file mode 100644 index 0000000..418bfb4 --- /dev/null +++ b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts @@ -0,0 +1,128 @@ +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 TreatmentCatalogContext = 'appointment' | 'treatment'; + +export type TreatmentTypeCatalogEntry = { + id: string; + code: string; + labDependent: boolean; + sortOrder: number; + label: string; + availableInAppointments: boolean; + availableInTreatment: boolean; +}; + +@Injectable() +export class TreatmentCatalogService 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.treatmentType.findMany({ + where: { isActive: true }, + orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }], + select: { + id: true, + code: true, + labDependent: true, + sortOrder: true, + availableInAppointments: true, + availableInTreatment: true, + }, + }); + + this.byCode = new Map( + rows.map((row) => [ + row.code, + { + id: row.id, + code: row.code, + labDependent: row.labDependent, + sortOrder: row.sortOrder, + label: row.code, + availableInAppointments: row.availableInAppointments, + availableInTreatment: row.availableInTreatment, + }, + ]), + ); + this.loaded = true; + } + + async list( + localeInput?: string | null, + context?: TreatmentCatalogContext | null, + ): Promise { + await this.ensureLabels(localeInput); + return [...this.byCode.values()] + .filter((entry) => { + if (context === 'appointment') return entry.availableInAppointments; + if (context === 'treatment') return entry.availableInTreatment; + return true; + }) + .sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code)); + } + + private async ensureLabels(localeInput?: string | null) { + this.ensureLoaded(); + 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 { + this.ensureLoaded(); + return this.byCode.get(code); + } + + assertKnownTreatmentType(code: string): TreatmentTypeCatalogEntry { + const entry = this.getByCode(code); + if (!entry) { + throw new BadRequestException(`Unknown treatment type: ${code}`); + } + return entry; + } + + assertLabDependentTreatmentType(code: string): TreatmentTypeCatalogEntry { + const entry = this.assertKnownTreatmentType(code); + if (!entry.labDependent) { + throw new BadRequestException( + `Treatment type "${code}" is completed in the clinic and cannot be sent to a lab`, + ); + } + return entry; + } + + isLabDependent(code: string): boolean { + return this.getByCode(code)?.labDependent ?? false; + } + + private ensureLoaded() { + if (!this.loaded) { + throw new Error('Treatment catalog is not loaded yet'); + } + } +} diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts index 3d3c462..a2fa750 100644 --- a/backend/src/modules/treatments/dto/treatment.dto.ts +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -1,7 +1,6 @@ import { ArrayMinSize, IsArray, - IsIn, IsOptional, IsString, IsUUID, @@ -10,9 +9,7 @@ import { } from 'class-validator'; import { Type } from 'class-transformer'; -const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const; - -export class SaveTreatmentCaseDto { +export class SaveTreatmentDetailDto { @IsString() @MaxLength(64) clientId: string; @@ -21,7 +18,8 @@ export class SaveTreatmentCaseDto { @IsUUID() id?: string; - @IsIn(TREATMENT_TYPES) + @IsString() + @MaxLength(64) treatmentType: string; @IsArray() @@ -43,15 +41,56 @@ export class SaveTreatmentDraftDto { @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) - @Type(() => SaveTreatmentCaseDto) - cases: SaveTreatmentCaseDto[]; + @Type(() => SaveTreatmentDetailDto) + details: SaveTreatmentDetailDto[]; } -export class SendTreatmentCaseDto { +export class LabCaseToothProsthesisDto { + @IsUUID() + treatmentDetailId: string; + + @IsString() + @MaxLength(8) + tooth: string; + + @IsString() + @MaxLength(64) + prosthesisTypeCode: string; +} + +export class SaveLabCaseDto { + @IsString() + @MaxLength(64) + clientId: string; + + @IsOptional() + @IsUUID() + id?: string; + + @IsOptional() + @IsUUID() + destinationOrganizationId?: string; + + @IsUUID() + treatmentDetailId: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LabCaseToothProsthesisDto) + toothProsthesis?: LabCaseToothProsthesisDto[]; + + @IsOptional() @IsArray() - @ArrayMinSize(1) @IsUUID(undefined, { each: true }) - organizationIds: string[]; + attachmentIds?: string[]; +} + +export class SaveTreatmentLabCasesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SaveLabCaseDto) + labCases: SaveLabCaseDto[]; } export class ListPatientTreatmentHistoryDto { 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/treatment.utils.ts b/backend/src/modules/treatments/treatment.utils.ts index fc13721..f2bfdfa 100644 --- a/backend/src/modules/treatments/treatment.utils.ts +++ b/backend/src/modules/treatments/treatment.utils.ts @@ -1,13 +1,3 @@ -import { TreatmentStatus } from '@prisma/client'; - -const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const; - -export type TreatmentTypeValue = (typeof TREATMENT_TYPES)[number]; - -export function isTreatmentType(value: string): value is TreatmentTypeValue { - return (TREATMENT_TYPES as readonly string[]).includes(value); -} - const FDI_TOOTH_IDS = new Set([ '11', '12', '13', '14', '15', '16', '17', '18', '21', '22', '23', '24', '25', '26', '27', '28', @@ -47,7 +37,3 @@ export function generateTreatmentTitle( return parts.join(' · '); } - -export function mapTreatmentStatusForApi(status: TreatmentStatus): string { - return status === TreatmentStatus.DRAFT ? 'draft' : 'completed'; -} diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts index 6f9bdac..59c7e54 100644 --- a/backend/src/modules/treatments/treatments.controller.ts +++ b/backend/src/modules/treatments/treatments.controller.ts @@ -17,16 +17,25 @@ import { FilesInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { memoryStorage } from 'multer'; import type { Response } from 'express'; +import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto'; +import { + SaveTreatmentDraftDto, + SaveTreatmentLabCasesDto, +} from './dto/treatment.dto'; +import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto'; +import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service'; import { TreatmentsService } from './treatments.service'; @ApiTags('treatments') @ApiBearerAuth('JWT-auth') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, ClinicOrgGuard) @Controller('treatments') export class TreatmentsController { - constructor(private readonly treatmentsService: TreatmentsService) {} + constructor( + private readonly treatmentsService: TreatmentsService, + private readonly commentsService: LabCaseCommentsService, + ) {} @Get('linked-organizations') @ApiOperation({ summary: 'List active linked counterpart organizations (TAB_TREATMENT_READ)' }) @@ -36,7 +45,7 @@ export class TreatmentsController { } @Get('patients/:patientId/history') - @ApiOperation({ summary: 'List completed treatments for a patient (TAB_TREATMENT_READ)' }) + @ApiOperation({ summary: 'List treatments for a patient (draft and completed, TAB_TREATMENT_READ)' }) listPatientHistory( @Param('patientId') patientId: string, @Query('limit', new ParseIntPipe({ optional: true })) limit = 20, @@ -66,7 +75,7 @@ export class TreatmentsController { } @Put('appointments/:appointmentId/draft') - @ApiOperation({ summary: 'Save draft treatment for an appointment (TAB_TREATMENT_EDIT)' }) + @ApiOperation({ summary: 'Save draft treatment details for an appointment (TAB_TREATMENT_EDIT)' }) saveDraft( @Param('appointmentId') appointmentId: string, @Body() dto: SaveTreatmentDraftDto, @@ -81,8 +90,24 @@ export class TreatmentsController { ); } - @Post('appointments/:appointmentId/cases/:caseClientKey/attachments') - @ApiOperation({ summary: 'Upload attachments for a draft case (TAB_TREATMENT_EDIT)' }) + @Put('appointments/:appointmentId/lab-cases') + @ApiOperation({ summary: 'Save lab case groupings for a draft treatment (TAB_TREATMENT_EDIT)' }) + saveLabCases( + @Param('appointmentId') appointmentId: string, + @Body() dto: SaveTreatmentLabCasesDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.saveLabCasesForAppointment( + appointmentId, + dto, + organizationId, + req.user.id, + ); + } + + @Post('appointments/:appointmentId/details/:detailClientKey/attachments') + @ApiOperation({ summary: 'Upload attachments for a draft treatment detail (TAB_TREATMENT_EDIT)' }) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { @@ -100,14 +125,39 @@ export class TreatmentsController { storage: memoryStorage(), }), ) - uploadAttachments( + uploadDetailAttachments( + @Param('appointmentId') appointmentId: string, + @Param('detailClientKey') detailClientKey: string, + @UploadedFiles() files: Express.Multer.File[], + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.uploadDetailAttachments( + appointmentId, + detailClientKey, + files, + organizationId, + req.user.id, + ); + } + + /** @deprecated Use details/:detailClientKey/attachments */ + @Post('appointments/:appointmentId/cases/:caseClientKey/attachments') + @ApiOperation({ summary: 'Legacy alias for detail attachment upload' }) + @ApiConsumes('multipart/form-data') + @UseInterceptors( + FilesInterceptor('files', 20, { + storage: memoryStorage(), + }), + ) + uploadDetailAttachmentsLegacy( @Param('appointmentId') appointmentId: string, @Param('caseClientKey') caseClientKey: string, @UploadedFiles() files: Express.Multer.File[], @Req() req: { user: { id: string; organizationId?: string } }, ) { const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); - return this.treatmentsService.uploadCaseAttachments( + return this.treatmentsService.uploadDetailAttachments( appointmentId, caseClientKey, files, @@ -135,14 +185,48 @@ export class TreatmentsController { file.stream.pipe(res); } - @Post('cases/:caseId/send') - @ApiOperation({ summary: 'Send a treatment case to linked organizations (TAB_TREATMENT_EDIT)' }) - sendCase( - @Param('caseId') caseId: string, - @Body() dto: SendTreatmentCaseDto, + @Post('lab-cases/:labCaseId/send') + @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; language?: string | null } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.sendLabCase( + labCaseId, + organizationId, + req.user.id, + req.user.language, + ); + } + + @Get('lab-cases/:labCaseId/comments') + @ApiOperation({ summary: 'List comments for a lab case during treatment dispatch' }) + listLabCaseComments( + @Param('labCaseId') labCaseId: string, @Req() req: { user: { id: string; organizationId?: string } }, ) { const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); - return this.treatmentsService.sendCase(caseId, dto, organizationId, req.user.id); + return this.commentsService.listForClinicTreatmentCase( + labCaseId, + organizationId, + req.user.id, + ); + } + + @Post('lab-cases/:labCaseId/comments') + @ApiOperation({ summary: 'Add a comment to a lab case during treatment dispatch' }) + addLabCaseComment( + @Param('labCaseId') labCaseId: string, + @Body() dto: CreateLabCaseCommentDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.commentsService.addForClinicTreatmentCase( + labCaseId, + organizationId, + req.user.id, + dto, + ); } } diff --git a/backend/src/modules/treatments/treatments.module.ts b/backend/src/modules/treatments/treatments.module.ts index 52fb3b6..a819eea 100644 --- a/backend/src/modules/treatments/treatments.module.ts +++ b/backend/src/modules/treatments/treatments.module.ts @@ -1,10 +1,14 @@ 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 { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module'; import { TreatmentsController } from './treatments.controller'; import { TreatmentsService } from './treatments.service'; @Module({ + imports: [ProsthesisCatalogModule, LabCaseCommentsModule], controllers: [TreatmentsController], - providers: [TreatmentsService, PrismaService], + providers: [TreatmentsService, PrismaService, ClinicOrgGuard], }) export class TreatmentsModule {} diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index e44fb96..7fe2c87 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -4,28 +4,65 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { LinkStatus, TreatmentStatus } from '@prisma/client'; +import { LinkStatus } from '@prisma/client'; import { createReadStream, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { PrismaService } from '../../../prisma/prisma.service'; -import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto'; +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, + SaveTreatmentLabCasesDto, +} from './dto/treatment.dto'; import { generateTreatmentTitle, - isTreatmentType, - mapTreatmentStatusForApi, normalizeTeeth, } from './treatment.utils'; +import { assertCompleteToothProsthesisMap } from './lab-case-send.validation'; const treatmentInclude = { - cases: { + details: { orderBy: [{ sortOrder: 'asc' as const }], include: { attachments: { orderBy: [{ createdAt: 'asc' as const }] }, + labCaseLink: { + include: { + labCase: { + include: { + sends: { + orderBy: [{ sentAt: 'asc' as const }], + include: { organization: { select: { id: true, name: true } } }, + }, + }, + }, + }, + }, + }, + }, + labCases: { + orderBy: [{ sortOrder: 'asc' as const }], + include: { + details: { + include: { + detail: { + select: { id: true, clientKey: true, treatmentType: true, teeth: true }, + }, + }, + }, sends: { orderBy: [{ sentAt: 'asc' as const }], include: { organization: { select: { id: true, name: true } } }, }, + toothProsthesis: true, + attachments: { + include: { + attachment: { + select: { id: true, fileName: true, mimeType: true, sizeBytes: true, createdAt: true }, + }, + }, + }, }, }, }; @@ -34,7 +71,11 @@ const treatmentInclude = { export class TreatmentsService { private readonly uploadRoot = join(process.cwd(), 'uploads', 'treatments'); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly treatmentCatalog: TreatmentCatalogService, + private readonly prosthesisCatalog: ProsthesisCatalogService, + ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { @@ -80,13 +121,13 @@ export class TreatmentsService { limit = 20, ) { await this.assertCanReadTreatment(actorUserId, organizationId); - await this.ensurePatientInOrg(patientId, organizationId); + await this.ensurePatientExists(patientId); const items = await this.prisma.treatment.findMany({ where: { patientId, organizationId, - status: TreatmentStatus.COMPLETED, + details: { some: {} }, }, include: treatmentInclude, orderBy: [{ treatmentAt: 'desc' }], @@ -113,7 +154,6 @@ export class TreatmentsService { where: { appointmentId: appointment.id, organizationId, - status: TreatmentStatus.DRAFT, }, include: treatmentInclude, }); @@ -135,22 +175,20 @@ export class TreatmentsService { true, ); - for (const c of dto.cases) { - if (!isTreatmentType(c.treatmentType)) { - throw new BadRequestException(`Invalid treatment type: ${c.treatmentType}`); - } + for (const d of dto.details) { + this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType); } - const normalizedCases = dto.cases.map((c, index) => ({ - ...c, + const normalizedDetails = dto.details.map((d, index) => ({ + ...d, sortOrder: index, - teeth: normalizeTeeth(c.teeth), - comment: c.comment?.trim() || null, - attachmentIds: c.attachmentIds ?? [], + teeth: normalizeTeeth(d.teeth), + comment: d.comment?.trim() || null, + attachmentIds: d.attachmentIds ?? [], })); const title = generateTreatmentTitle( - normalizedCases.map((c) => ({ treatmentType: c.treatmentType, teeth: c.teeth })), + normalizedDetails.map((d) => ({ treatmentType: d.treatmentType, teeth: d.teeth })), ); const treatment = await this.prisma.$transaction(async (tx) => { @@ -167,7 +205,6 @@ export class TreatmentsService { treatmentAt: appointment.startAt, patientId: appointment.patientId, providerUserId: appointment.providerUserId, - status: TreatmentStatus.DRAFT, }, }) : await tx.treatment.create({ @@ -177,82 +214,84 @@ export class TreatmentsService { appointmentId: appointment.id, providerUserId: appointment.providerUserId, title, - status: TreatmentStatus.DRAFT, treatmentAt: appointment.startAt, }, }); - const keepCaseIds = normalizedCases.map((c) => c.id).filter(Boolean) as string[]; - const existingCases = existing - ? await tx.treatmentCase.findMany({ + const keepDetailIds = normalizedDetails.map((d) => d.id).filter(Boolean) as string[]; + + const existingDetails = existing + ? await tx.treatmentDetail.findMany({ where: { treatmentId: saved.id }, - select: { id: true, sentAt: true }, + select: { id: true, labCaseLink: { select: { labCase: { select: { sentAt: true } } } } }, }) : []; - const sentCaseIds = new Set( - existingCases.filter((c) => c.sentAt).map((c) => c.id), + const lockedDetailIds = new Set( + existingDetails + .filter((d) => d.labCaseLink?.labCase.sentAt) + .map((d) => d.id), ); - const removableCaseIds = existingCases - .filter((c) => !keepCaseIds.includes(c.id) && !c.sentAt) - .map((c) => c.id); + const removableDetailIds = existingDetails + .filter((d) => !keepDetailIds.includes(d.id) && !lockedDetailIds.has(d.id)) + .map((d) => d.id); - if (removableCaseIds.length > 0) { - await tx.treatmentCase.deleteMany({ - where: { id: { in: removableCaseIds }, treatmentId: saved.id }, + if (removableDetailIds.length > 0) { + await tx.treatmentDetail.deleteMany({ + where: { id: { in: removableDetailIds }, treatmentId: saved.id }, }); } - for (const c of normalizedCases) { - if (c.id && sentCaseIds.has(c.id)) { + for (const d of normalizedDetails) { + if (d.id && lockedDetailIds.has(d.id)) { continue; } - const row = c.id - ? await tx.treatmentCase.update({ - where: { id: c.id }, + const row = d.id + ? await tx.treatmentDetail.update({ + where: { id: d.id }, data: { - clientKey: c.clientId, - sortOrder: c.sortOrder, - treatmentType: c.treatmentType, - teeth: c.teeth, - comment: c.comment, + clientKey: d.clientId, + sortOrder: d.sortOrder, + treatmentType: d.treatmentType, + teeth: d.teeth, + comment: d.comment, }, }) - : await tx.treatmentCase.create({ + : await tx.treatmentDetail.create({ data: { treatmentId: saved.id, - clientKey: c.clientId, - sortOrder: c.sortOrder, - treatmentType: c.treatmentType, - teeth: c.teeth, - comment: c.comment, + clientKey: d.clientId, + sortOrder: d.sortOrder, + treatmentType: d.treatmentType, + teeth: d.teeth, + comment: d.comment, }, }); - const allowedAttachmentIds = new Set(c.attachmentIds); - const pendingAttachments = await tx.treatmentCaseAttachment.findMany({ + const allowedAttachmentIds = new Set(d.attachmentIds); + const pendingAttachments = await tx.treatmentDetailAttachment.findMany({ where: { appointmentId: appointment.id, - caseClientKey: c.clientId, + detailClientKey: d.clientId, }, }); for (const attachment of pendingAttachments) { if (!allowedAttachmentIds.has(attachment.id)) { - await tx.treatmentCaseAttachment.delete({ where: { id: attachment.id } }); + await tx.treatmentDetailAttachment.delete({ where: { id: attachment.id } }); } else { - await tx.treatmentCaseAttachment.update({ + await tx.treatmentDetailAttachment.update({ where: { id: attachment.id }, - data: { caseId: row.id, appointmentId: null, caseClientKey: null }, + data: { detailId: row.id, appointmentId: null, detailClientKey: null }, }); } } - await tx.treatmentCaseAttachment.deleteMany({ + await tx.treatmentDetailAttachment.deleteMany({ where: { - caseId: row.id, + detailId: row.id, id: { notIn: [...allowedAttachmentIds] }, }, }); @@ -267,87 +306,274 @@ export class TreatmentsService { return { success: true, data: this.mapTreatment(treatment) }; } - async sendCase( - caseId: string, - dto: SendTreatmentCaseDto, + async saveLabCasesForAppointment( + appointmentId: string, + dto: SaveTreatmentLabCasesDto, organizationId: string, actorUserId: string, ) { await this.assertCanEditTreatment(actorUserId, organizationId); + const appointment = await this.ensureAppointmentProvider( + appointmentId, + organizationId, + actorUserId, + true, + ); - const treatmentCase = await this.prisma.treatmentCase.findFirst({ + const treatment = await this.prisma.treatment.findFirst({ + where: { appointmentId: appointment.id, organizationId }, + select: { id: true }, + }); + + if (!treatment) { + throw new NotFoundException('Save treatment details before creating lab cases'); + } + + const detailIds = dto.labCases.map((lc) => lc.treatmentDetailId); + const uniqueDetailIds = new Set(detailIds); + if (uniqueDetailIds.size !== detailIds.length) { + throw new BadRequestException('Each treatment detail can belong to only one lab case'); + } + + const details = await this.prisma.treatmentDetail.findMany({ + where: { treatmentId: treatment.id, id: { in: detailIds } }, + select: { id: true, treatmentType: true, teeth: true }, + }); + if (details.length !== uniqueDetailIds.size) { + throw new BadRequestException('One or more treatment details were not found'); + } + + for (const detail of details) { + 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.treatmentDetailId !== row.treatmentDetailId) { + throw new BadRequestException( + 'Tooth prosthesis must reference the lab case treatment detail', + ); + } + const detail = detailById.get(row.treatmentDetailId); + if (!detail) { + throw new BadRequestException('Tooth prosthesis references an unknown treatment detail'); + } + const teeth = normalizeTeeth(detail.teeth); + if (!teeth.includes(row.tooth)) { + throw new BadRequestException(`Tooth ${row.tooth} is not on the selected treatment detail`); + } + this.prosthesisCatalog.assertKnownProsthesisType(row.prosthesisTypeCode); + } + } + + const saved = await this.prisma.$transaction(async (tx) => { + const existingLabCases = await tx.labCase.findMany({ + where: { treatmentId: treatment.id }, + select: { id: true, sentAt: true }, + }); + + const sentLabCaseIds = new Set(existingLabCases.filter((lc) => lc.sentAt).map((lc) => lc.id)); + const keepLabCaseIds = dto.labCases.map((lc) => lc.id).filter(Boolean) as string[]; + + const removableLabCaseIds = existingLabCases + .filter((lc) => !keepLabCaseIds.includes(lc.id) && !lc.sentAt) + .map((lc) => lc.id); + + if (removableLabCaseIds.length > 0) { + await tx.labCase.deleteMany({ + where: { id: { in: removableLabCaseIds }, treatmentId: treatment.id }, + }); + } + + for (const [index, lc] of dto.labCases.entries()) { + if (lc.id && sentLabCaseIds.has(lc.id)) { + continue; + } + + const row = lc.id + ? await tx.labCase.update({ + where: { id: lc.id }, + data: { + clientKey: lc.clientId, + sortOrder: index, + destinationOrganizationId: lc.destinationOrganizationId ?? null, + }, + }) + : await tx.labCase.create({ + data: { + treatmentId: treatment.id, + clientKey: lc.clientId, + sortOrder: index, + destinationOrganizationId: lc.destinationOrganizationId ?? null, + }, + }); + + await tx.labCaseDetail.deleteMany({ where: { labCaseId: row.id } }); + await tx.labCaseDetail.create({ + data: { + labCaseId: row.id, + treatmentDetailId: lc.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, + })), + }); + } + + await tx.labCaseAttachment.deleteMany({ where: { labCaseId: row.id } }); + const attachmentIds = lc.attachmentIds ?? []; + if (attachmentIds.length > 0) { + const validAttachments = await tx.treatmentDetailAttachment.findMany({ + where: { + id: { in: attachmentIds }, + detailId: lc.treatmentDetailId, + }, + select: { id: true }, + }); + if (validAttachments.length !== attachmentIds.length) { + throw new BadRequestException( + 'One or more attachments are invalid for this lab case', + ); + } + await tx.labCaseAttachment.createMany({ + data: attachmentIds.map((attachmentId) => ({ + labCaseId: row.id, + attachmentId, + })), + }); + } + } + + return tx.treatment.findUniqueOrThrow({ + where: { id: treatment.id }, + include: treatmentInclude, + }); + }); + + return { success: true, data: this.mapTreatment(saved) }; + } + + async sendLabCase( + labCaseId: string, + organizationId: string, + actorUserId: string, + actorLanguage?: string | null, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + + const labCase = await this.prisma.labCase.findFirst({ where: { - id: caseId, + id: labCaseId, treatment: { organizationId }, }, include: { - treatment: { select: { providerUserId: true, appointmentId: true } }, + treatment: { select: { providerUserId: true } }, sends: { select: { organizationId: true } }, + details: { + include: { + detail: { select: { id: true, treatmentType: true, teeth: true } }, + }, + }, + toothProsthesis: true, }, }); - if (!treatmentCase) { - throw new NotFoundException('Treatment case not found'); + if (!labCase) { + throw new NotFoundException('Lab case not found'); } - if (treatmentCase.treatment.providerUserId !== actorUserId) { + if (!labCase.destinationOrganizationId) { + throw new BadRequestException('Lab case has no destination organization'); + } + + if (labCase.details.length === 0) { + throw new BadRequestException('Lab case must include a treatment detail'); + } + + if (labCase.details.length > 1) { + throw new BadRequestException('Lab case can include only one treatment detail'); + } + + assertCompleteToothProsthesisMap(labCase); + + if (labCase.treatment.providerUserId !== actorUserId) { const membership = await this.getMembership(actorUserId, organizationId); if (!membership?.isOwner) { - throw new ForbiddenException('Only the appointment provider can send this case'); + throw new ForbiddenException('Only the appointment provider can send this lab case'); } } const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); - const uniqueTargets = [...new Set(dto.organizationIds)]; - - for (const orgId of uniqueTargets) { - if (!linkedOrgIds.has(orgId)) { - throw new BadRequestException('One or more organizations are not active linked counterparts'); - } + if (!linkedOrgIds.has(labCase.destinationOrganizationId)) { + throw new BadRequestException('Destination organization is not an active linked counterpart'); } - const alreadySent = new Set(treatmentCase.sends.map((s) => s.organizationId)); - const newTargets = uniqueTargets.filter((id) => !alreadySent.has(id)); - - if (newTargets.length === 0) { - throw new BadRequestException('Case was already sent to all selected organizations'); + const alreadySent = labCase.sends.some( + (s) => s.organizationId === labCase.destinationOrganizationId, + ); + if (alreadySent) { + throw new BadRequestException('Lab case was already sent to the destination organization'); } const now = new Date(); await this.prisma.$transaction(async (tx) => { - await tx.treatmentCaseSend.createMany({ - data: newTargets.map((organizationId) => ({ - caseId, - organizationId, - })), + await tx.labCaseSend.create({ + data: { + labCaseId, + organizationId: labCase.destinationOrganizationId!, + }, }); - if (!treatmentCase.sentAt) { - await tx.treatmentCase.update({ - where: { id: caseId }, + if (!labCase.sentAt) { + await tx.labCase.update({ + where: { id: labCaseId }, data: { sentAt: now }, }); } + + await generateLabCaseTasks(tx, labCaseId, actorLanguage); }); - const refreshed = await this.prisma.treatmentCase.findUniqueOrThrow({ - where: { id: caseId }, + const refreshed = await this.prisma.labCase.findUniqueOrThrow({ + where: { id: labCaseId }, include: { - attachments: { orderBy: [{ createdAt: 'asc' }] }, + details: { + include: { + detail: { + select: { id: true, clientKey: true, treatmentType: true, teeth: true }, + }, + }, + }, sends: { orderBy: [{ sentAt: 'asc' }], include: { organization: { select: { id: true, name: true } } }, }, + toothProsthesis: true, }, }); - return { success: true, data: this.mapCase(refreshed) }; + return { success: true, data: this.mapLabCase(refreshed) }; } - async uploadCaseAttachments( + async uploadDetailAttachments( appointmentId: string, - caseClientKey: string, + detailClientKey: string, files: Express.Multer.File[], organizationId: string, actorUserId: string, @@ -355,8 +581,8 @@ export class TreatmentsService { await this.assertCanEditTreatment(actorUserId, organizationId); await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId, true); - if (!caseClientKey?.trim()) { - throw new BadRequestException('caseClientKey is required'); + if (!detailClientKey?.trim()) { + throw new BadRequestException('detailClientKey is required'); } if (!files?.length) { @@ -379,10 +605,10 @@ export class TreatmentsService { const { writeFileSync } = await import('fs'); writeFileSync(storagePath, file.buffer); - const attachment = await this.prisma.treatmentCaseAttachment.create({ + const attachment = await this.prisma.treatmentDetailAttachment.create({ data: { appointmentId, - caseClientKey, + detailClientKey, fileName: file.originalname, mimeType: file.mimetype || 'application/octet-stream', sizeBytes: file.size, @@ -403,16 +629,16 @@ export class TreatmentsService { ) { await this.assertCanReadTreatment(actorUserId, organizationId); - const attachment = await this.prisma.treatmentCaseAttachment.findFirst({ + const attachment = await this.prisma.treatmentDetailAttachment.findFirst({ where: { id: attachmentId, OR: [ - { case: { treatment: { organizationId } } }, + { detail: { treatment: { organizationId } } }, { appointmentId: { not: null } }, ], }, include: { - case: { select: { treatment: { select: { organizationId: true } } } }, + detail: { select: { treatment: { select: { organizationId: true } } } }, }, }); @@ -420,11 +646,11 @@ export class TreatmentsService { throw new NotFoundException('Attachment not found'); } - if (attachment.case && attachment.case.treatment.organizationId !== organizationId) { + if (attachment.detail && attachment.detail.treatment.organizationId !== organizationId) { throw new NotFoundException('Attachment not found'); } - if (!attachment.case && attachment.appointmentId) { + if (!attachment.detail && attachment.appointmentId) { const appointment = await this.prisma.appointment.findFirst({ where: { id: attachment.appointmentId, organizationId }, select: { id: true }, @@ -450,26 +676,51 @@ export class TreatmentsService { patientId: string; appointmentId: string | null; title: string; - status: TreatmentStatus; treatmentAt: Date; - cases: Array<{ + details: Array<{ id: string; clientKey: string | null; treatmentType: string; teeth: unknown; comment: string | null; - sentAt: Date | null; attachments: Array<{ id: string; fileName: string; mimeType: string; sizeBytes: number; }>; - sends: Array<{ organizationId: string; sentAt: Date; organization: { id: string; name: string } }>; + labCaseLink?: { + labCase: { + id: string; + sentAt: Date | null; + destinationOrganizationId: string | null; + sends: Array<{ + organizationId: string; + sentAt: Date; + organization: { id: string; name: string }; + }>; + }; + } | null; + }>; + labCases: Array<{ + id: string; + clientKey: string | null; + sortOrder: number; + destinationOrganizationId: string | null; + sentAt: Date | null; + details: Array<{ + treatmentDetailId: string; + detail: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown }; + }>; + sends: Array<{ + organizationId: string; + sentAt: Date; + organization: { id: string; name: string }; + }>; }>; }) { - const documents = treatment.cases.flatMap((c) => - c.attachments.map((a) => this.mapAttachment(a)), + const documents = treatment.details.flatMap((d) => + d.attachments.map((a) => this.mapAttachment(a)), ); return { @@ -478,42 +729,119 @@ export class TreatmentsService { appointmentId: treatment.appointmentId, title: treatment.title, treatmentAt: treatment.treatmentAt.toISOString(), - status: mapTreatmentStatusForApi(treatment.status), - cases: treatment.cases.map((c) => this.mapCase(c)), + details: treatment.details.map((d) => this.mapDetail(d)), + labCases: treatment.labCases.map((lc) => this.mapLabCase(lc)), documents, }; } - private mapCase(c: { + private mapDetail(d: { id: string; clientKey?: string | null; treatmentType: string; teeth: unknown; comment?: string | null; - sentAt?: Date | null; attachments?: Array<{ id: string; fileName: string; mimeType: string; sizeBytes: number; }>; - sends?: Array<{ organizationId: string; sentAt: Date; organization?: { id: string; name: string } }>; + labCaseLink?: { + labCase: { + id: string; + sentAt: Date | null; + destinationOrganizationId: string | null; + sends: Array<{ + organizationId: string; + sentAt: Date; + organization?: { id: string; name: string }; + }>; + }; + } | null; }) { + const labCase = d.labCaseLink?.labCase; return { - id: c.id, - clientId: c.clientKey ?? c.id, - treatmentType: c.treatmentType, - teeth: normalizeTeeth(c.teeth), - notes: c.comment ?? null, - sentAt: c.sentAt?.toISOString() ?? null, - sendToOrganizationIds: c.sends?.map((s) => s.organizationId) ?? [], + id: d.id, + clientId: d.clientKey ?? d.id, + treatmentType: d.treatmentType, + teeth: normalizeTeeth(d.teeth), + notes: d.comment ?? null, + attachmentMetas: (d.attachments ?? []).map((a) => this.mapAttachment(a)), + labCaseId: labCase?.id ?? null, + sentAt: labCase?.sentAt?.toISOString() ?? null, + destinationOrganizationId: labCase?.destinationOrganizationId ?? null, sends: - c.sends?.map((s) => ({ + labCase?.sends.map((s) => ({ + organizationId: s.organizationId, + organizationName: s.organization?.name ?? 'Unknown organization', + sentAt: s.sentAt.toISOString(), + })) ?? [], + }; + } + + private mapLabCase(lc: { + id: string; + clientKey?: string | null; + sortOrder?: number; + destinationOrganizationId?: string | null; + sentAt?: Date | null; + details?: Array<{ + treatmentDetailId: string; + detail?: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown }; + }>; + sends?: Array<{ + organizationId: string; + sentAt: Date; + organization?: { id: string; name: string }; + }>; + toothProsthesis?: Array<{ + treatmentDetailId: string; + tooth: string; + prosthesisTypeCode: string; + }>; + attachments?: Array<{ + attachment: { + id: string; + fileName: string; + mimeType: string; + sizeBytes: number; + createdAt: Date; + }; + }>; + }) { + return { + id: lc.id, + clientId: lc.clientKey ?? lc.id, + destinationOrganizationId: lc.destinationOrganizationId ?? null, + sentAt: lc.sentAt?.toISOString() ?? null, + treatmentDetailId: lc.details?.[0]?.treatmentDetailId ?? null, + detail: lc.details?.[0] + ? { + id: lc.details[0].detail?.id ?? lc.details[0].treatmentDetailId, + clientId: lc.details[0].detail?.clientKey ?? lc.details[0].treatmentDetailId, + treatmentType: lc.details[0].detail?.treatmentType ?? '', + teeth: lc.details[0].detail ? normalizeTeeth(lc.details[0].detail.teeth) : [], + } + : null, + toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({ + treatmentDetailId: tp.treatmentDetailId, + tooth: tp.tooth, + prosthesisTypeCode: tp.prosthesisTypeCode, + })), + attachments: (lc.attachments ?? []).map((row) => ({ + id: row.attachment.id, + fileName: row.attachment.fileName, + mimeType: row.attachment.mimeType, + sizeBytes: row.attachment.sizeBytes, + createdAt: row.attachment.createdAt.toISOString(), + })), + sends: + lc.sends?.map((s) => ({ organizationId: s.organizationId, organizationName: s.organization?.name ?? 'Unknown organization', sentAt: s.sentAt.toISOString(), })) ?? [], - attachmentMetas: (c.attachments ?? []).map((a) => this.mapAttachment(a)), }; } @@ -549,9 +877,9 @@ export class TreatmentsService { ]); } - private async ensurePatientInOrg(patientId: string, organizationId: string) { - const patient = await this.prisma.patient.findFirst({ - where: { id: patientId, organizationId }, + private async ensurePatientExists(patientId: string) { + const patient = await this.prisma.patient.findUnique({ + where: { id: patientId }, select: { id: true }, }); if (!patient) { diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 1f22da3..dd10824 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -51,6 +51,8 @@ "patients": "Patients", "appointment": "Appointment", "treatment": "Treatment", + "cases": "Cases", + "tasks": "Tasks", "billing": "Billing", "reports": "Reports", "clinics": "Clinics", @@ -276,6 +278,8 @@ "featurePatients": "Patients", "featureAppointment": "Appointment", "featureTreatment": "Treatment", + "featureCases": "Cases", + "featureTasks": "Tasks", "featureBilling": "Billing", "featureReports": "Reports", "noTabAccess": "No tab access", @@ -311,21 +315,133 @@ "errorSavePatient": "Failed to save patient.", "firstName": "First name", "lastName": "Last name", - "phone": "Phone", + "mobile": "Mobile", + "mobilePlaceholder": "09121234567", + "mobileLabel": "Mobile:", + "patientAlreadyExists": "A patient with this mobile already exists: {firstName} {lastName}. They were selected for you.", "savePatient": "Save Patient", "dialogTitle": "New patient", - "searchPlaceholder": "Search patients by name, phone, email", + "searchPlaceholder": "Search patients by name, mobile, email", "loadingPatients": "Loading patients...", "noResults": "No patients found for this search.", "noContact": "No contact", "selectPatient": "Select a patient to view details.", - "phoneLabel": "Phone:", "emailLabel": "Email:", "statusLabel": "Status:", "statusActive": "Active", "statusInactive": "Inactive", "emptyValue": "-" }, + "cases": { + "title": "Cases", + "subtitle": "Lab cases sent from linked clinics. Track progress and flag important tasks.", + "searchPlaceholder": "Search by patient name or mobile…", + "emptyList": "No cases received yet.", + "selectCaseHint": "Select a case from the list to view tasks.", + "fromClinic": "From {name}", + "sentAt": "Sent {date}", + "taskProgressLabel": "Tasks: {completed} of {total} completed", + "taskProgressShort": "{progress} tasks", + "treatmentDetails": "Treatment details", + "teethLabel": "Teeth", + "tasksByTooth": "Tasks", + "toothGroupTitle": "Teeth {teeth} · {prosthesis}", + "noTasks": "No tasks were generated for this case.", + "statusInProgress": "In progress", + "statusCompleted": "Completed", + "importantLabel": "Important", + "markCaseImportant": "Mark case as important", + "markImportant": "Mark as important", + "lastUpdatedBy": "Updated by {name}", + "lastUpdatedUnknown": "Not started yet", + "timelineTitle": "History", + "timelineEntry": "{status} · {name} · {date}", + "errorLoadList": "Failed to load cases.", + "errorLoadDetail": "Failed to load case details.", + "errorUpdateTask": "Failed to update task.", + "filterClinic": "Clinic", + "filterClinicAll": "All clinics", + "filterTreatmentType": "Treatment type", + "filterTreatmentTypeAll": "All types", + "filterSentFrom": "Sent from", + "filterSentTo": "Sent to", + "clearFilters": "Clear filters", + "patientMobile": "Mobile", + "showComments": "Comments", + "commentsCount": "Comments ({count})", + "viewAttachments": "View all attachments", + "attachmentsDialogTitle": "Case attachments", + "attachmentsDialogSubtitle": "Preview and download files shared with this case.", + "noAttachments": "No attachments were shared with this case.", + "downloadAttachment": "Download", + "downloadAllAttachments": "Download all", + "attachmentPreviewUnavailable": "Preview unavailable", + "prevPage": "Previous", + "nextPage": "Next", + "pageSummary": "Page {page} of {totalPages} ({total} cases)", + "statusLabel": "Status" + }, + "tasks": { + "title": "Tasks", + "subtitle": "All lab tasks from connected clinics. Filter, sort, and update the status of each step.", + "subtitleOwner": "All lab tasks from connected clinics. Filter, sort, and update the status of each step.", + "loading": "Loading tasks…", + "emptyList": "No tasks match the current filters.", + "emptyListOwner": "No tasks match the current filters.", + "noPermissionTitle": "Tasks", + "noPermissionBody": "You do not have permission to view tasks for this organization.", + "fromClinic": "From {name}", + "patientLabel": "Patient", + "taskDate": "{date}", + "teethLabel": "Teeth {teeth}", + "importantBadge": "Important", + "lastUpdatedBy": "Updated by {name}", + "statusInProgress": "In progress", + "statusCompleted": "Completed", + "searchPlaceholder": "Search patient or clinic…", + "filterClinic": "Clinic", + "filterClinicAll": "All clinics", + "filterStatus": "Status", + "filterStatusAll": "All statuses", + "showCompleted": "Show completed", + "importantOnly": "Important only", + "filterSentFrom": "From", + "filterSentTo": "To", + "sortBy": "Sort by", + "sortDate": "Date", + "sortStatus": "Status", + "sortClinic": "Clinic", + "sortPatient": "Patient", + "sortImportant": "Important", + "sortProsthesis": "Prosthesis type", + "sortTaskType": "Task type", + "sortDirection": "Sort direction", + "clearFilters": "Clear filters", + "commentsButton": "Comments", + "errorLoadList": "Failed to load tasks.", + "errorUpdateTask": "Failed to update task.", + "pageSummary": "Page {page} of {totalPages} ({total} tasks)" + }, + "caseComments": { + "title": "Comments", + "placeholder": "Write a comment…", + "reply": "Reply…", + "post": "Post", + "empty": "No comments yet.", + "visibleToClinicToggle": "Visible to clinic", + "clinicCanSee": "Clinic can see this", + "hiddenFromClinic": "Hidden from clinic", + "makeVisible": "Make visible to clinic", + "makeHidden": "Hide from clinic", + "labAuthor": "Lab", + "clinicAuthor": "Clinic", + "errorLoad": "Failed to load comments.", + "errorPost": "Failed to post comment.", + "errorToggle": "Failed to update comment visibility.", + "send": "Send comment", + "composerVisible": "Visible to clinic", + "composerHidden": "Hidden from clinic" + }, "appointments": { "title": "Appointments", "subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.", @@ -384,6 +500,7 @@ "noPermissionBody": "You do not have permission to view the Treatment tab for this organization.", "title": "Treatment", "subtitleEdit": "Document cases for your appointments, save drafts, and send work to linked organizations.", + "subtitleEditPhase4": "Plan treatment details first, then group lab-dependent work into shipments in the lab dispatch panel.", "subtitleReadOnly": "View-only access — you can review appointments and treatment history but cannot edit.", "pastDayNotice": "Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.", "selectedPatient": "Selected patient", @@ -391,7 +508,6 @@ "loadingAppointments": "Loading appointments…", "selectDayWithAppointment": "Select a day with at least one appointment.", "confirmDiscard": "You have unsaved changes. Discard them and continue?", - "successDraftSaved": "Treatment draft saved.", "errorChooseOrg": "Choose at least one active organization to send this case.", "successCaseSent": "Case sent to selected organizations.", "successFilesUploaded": "{count} file(s) uploaded successfully.", @@ -403,7 +519,7 @@ "errorSaveDraft": "Failed to save treatment draft.", "errorSendCase": "Failed to send case.", "errorCaseMustSave": "Case must be saved before sending.", - "draftTitle": "Draft · {patientName}", + "treatmentPlanTitle": "Treatment · {patientName}", "hiddenMessage": "Appointments are hidden.", "showAppointments": "Show appointments", "appointmentsTitle": "My appointments", @@ -411,6 +527,13 @@ "emptyDay": "No appointments assigned to you on this day.", "casesTitle": "Treatment cases", "casesSubtitle": "Each case has its own teeth, notes, attachments, and destinations for send.", + "detailsTitle": "Treatment details", + "detailsSubtitle": "Plan teeth, type, notes, and attachments for each detail line.", + "addDetail": "Add detail", + "detailLabel": "Detail {n}", + "detailSentBadge": "sent", + "detailLockedInShipment": "This detail was sent to a lab and can no longer be edited.", + "detailsSaveHint": "Lab dispatch is configured separately below.", "addCase": "Add case", "caseLabel": "Case {n}", "comments": "Comments", @@ -429,30 +552,56 @@ "recent": "Recent:", "noOrgMatch": "No active organization matches your search.", "sendThisCase": "Send this case", - "saveDraft": "Save treatment draft", + "labDispatchTitle": "Lab dispatch", + "labDispatchSubtitle": "Group lab-dependent details into shipments and send them to linked labs.", + "labDispatchSendHint": "Shipment grouping is saved when you send to the lab.", + "addLabShipment": "Add lab shipment", + "labShipmentLabel": "Shipment {n}", + "includeDetails": "Include treatment details", + "labShipmentIncludedDetails": "Included in this shipment", + "labShipmentNoIncludedDetails": "No details were included in this shipment.", + "labShipmentNoDetailsAvailable": "All lab details are already in other shipments or have been sent.", + "labDetailLine": "Detail {n} · {type} · {teeth}", + "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", + "selectLab": "Destination lab", + "selectLabPlaceholder": "Choose a linked lab…", + "sendToLab": "Send to lab", + "saveLabShipments": "Save lab shipments", + "labDispatchSaveHint": "Saves shipment grouping without sending.", + "successLabShipmentsSaved": "Lab shipments saved.", + "errorSaveLabShipments": "Failed to save lab shipments.", + "errorLabCaseNeedsDetails": "Select at least one treatment detail for this shipment.", "unsavedChanges": "Unsaved changes", - "draftSaved": "Draft saved", + "saveStatusSaving": "Saving…", + "saveStatusSaved": "All changes saved", + "saveStatusError": "Could not save — check your connection", "sendSavesFirst": "Sending is per case and saves first automatically.", "historyTitle": "Previous treatments", - "historySubtitle": "Completed treatments for this patient. Each case is listed separately.", + "historySubtitle": "Click a treatment to preview it. Use Open in the preview card to load it in the workspace.", "loadingHistory": "Loading history…", - "historyEmpty": "No prior treatments for this patient.", - "statusLabel": "Status:", - "historyCaseLabel": "Case {n} · {type}", + "historyEmpty": "No other treatments recorded for this patient yet.", + "historyDetailLabel": "Detail {n} · {type}", + "previewTitle": "Treatment preview", + "openTreatment": "Open", + "selectAppointment": "Select an appointment to preview its treatment.", + "detailCount": "{n} detail(s)", + "detailSummary": "Detail {n}: {type}", + "detailAttachmentCount": "{n, plural, one {# file} other {# files}}", + "detailNotSentToLab": "Not sent to lab", + "detailPendingLabSend": "This lab detail has not been sent yet.", "teethLabel": "Teeth:", "teethNone": "None selected", - "reviewDetails": "Review details", - "previewTitle": "Treatment preview", - "previewDraft": "Preview current draft", - "selectAppointment": "Select an appointment to preview its draft.", - "caseCount": "{n} case(s)", - "attachmentCount": "{n} attachment(s)", - "caseSummary": "Case {n}: {type}", - "teethPrefix": "· Teeth", - "moreCases": "+ {n} more case(s)", - "previewDialogTitle": "Treatment preview", - "previewDialogSubtitle": "Review cases, attachments, and send destinations.", + "historicalReadonlyNotice": "You are viewing a past treatment (read-only).", + "errorNoAppointmentForTreatment": "This treatment has no linked appointment and cannot be opened.", "noCases": "No cases in this treatment.", + "noDetails": "No treatment details yet.", "typeLabel": "Type:", "commentsLabel": "Comments:", "commentsEmpty": "Comments: —", @@ -462,7 +611,11 @@ "noActiveOrgs": "No active linked organizations.", "confirmSend": "Confirm send", "toothChartTitle": "FDI tooth chart", - "toothChartHint": "Tap teeth to multi-select. Applies to the active case.", + "toothChartTitleCompact": "Tooth chart", + "toothChartHint": "Tap teeth to multi-select. Applies to the active detail.", + "toothChartWholePlan": "Show whole treatment plan", + "labShipmentAttachments": "Files for the lab", + "labShipmentAttachmentsHint": "Select which attachments from this detail are included in this shipment. None are sent by default.", "selectedLabel": "Selected:", "selectedEmpty": "—", "upperArch": "Upper arch", @@ -526,7 +679,16 @@ "continueArrow": "Continue →", "planLabel": "Plan: {name} • {maxUsers} users", "counterpartClinic": "Clinic", - "counterpartLab": "Lab" + "counterpartLab": "Lab", + "viewCaseHistory": "View case history", + "caseHistoryBackToConnections": "← Back to connections", + "caseHistoryTitle": "Case history with {name}", + "caseHistorySubtitleClinic": "Cases you sent to this lab, including lab workflow status for each step.", + "caseHistorySubtitleLab": "Cases received from this clinic, including task status for each step.", + "caseHistoryEmpty": "No cases exchanged with this organization yet.", + "caseHistorySentToLab": "Sent to {name}", + "caseHistoryErrorLoadList": "Failed to load case history.", + "caseHistoryErrorLoadDetail": "Failed to load case details." }, "settings": { "accountTitle": "Account", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 91226af..fc29e6f 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -51,6 +51,8 @@ "patients": "بیماران", "appointment": "نوبت‌ها", "treatment": "درمان", + "cases": "پرونده‌ها", + "tasks": "وظایف", "billing": "صورتحساب", "reports": "گزارش‌ها", "clinics": "کلینیک‌ها", @@ -276,6 +278,8 @@ "featurePatients": "بیماران", "featureAppointment": "نوبت‌ها", "featureTreatment": "درمان", + "featureCases": "پرونده‌ها", + "featureTasks": "وظایف", "featureBilling": "صورتحساب", "featureReports": "گزارش‌ها", "noTabAccess": "دسترسی به برگه‌ها وجود ندارد", @@ -311,21 +315,134 @@ "errorSavePatient": "ذخیره بیمار ناموفق بود.", "firstName": "نام", "lastName": "نام خانوادگی", - "phone": "تلفن", + "mobile": "موبایل", + "mobilePlaceholder": "09121234567", + "mobileLabel": "موبایل:", + "patientAlreadyExists": "بیماری با این شماره موبایل از قبل وجود دارد: {firstName} {lastName}. برای شما انتخاب شد.", "savePatient": "ذخیره بیمار", "dialogTitle": "بیمار جدید", - "searchPlaceholder": "جستجوی بیماران بر اساس نام، تلفن، ایمیل", + "searchPlaceholder": "جستجوی بیماران بر اساس نام، موبایل، ایمیل", "loadingPatients": "در حال بارگذاری بیماران...", "noResults": "هیچ بیماری برای این جستجو یافت نشد.", "noContact": "بدون اطلاعات تماس", "selectPatient": "برای مشاهده جزئیات، یک بیمار را انتخاب کنید.", - "phoneLabel": "تلفن:", "emailLabel": "ایمیل:", "statusLabel": "وضعیت:", "statusActive": "فعال", "statusInactive": "غیرفعال", "emptyValue": "-" }, + "cases": { + "title": "پرونده‌ها", + "subtitle": "پرونده‌های ارسالی از کلینیک‌های متصل. پیشرفت را پیگیری کنید و وظایف مهم را علامت بزنید.", + "searchPlaceholder": "جستجو با نام یا موبایل بیمار…", + "emptyList": "هنوز پرونده‌ای دریافت نشده است.", + "selectCaseHint": "برای مشاهده وظایف، یک پرونده از فهرست انتخاب کنید.", + "fromClinic": "از {name}", + "sentAt": "ارسال {date}", + "taskProgressLabel": "وظایف: {completed} از {total} انجام شده", + "taskProgressShort": "{progress} وظیفه", + "treatmentDetails": "جزئیات درمان", + "teethLabel": "دندان‌ها", + "tasksByTooth": "وظایف", + "toothGroupTitle": "دندان‌های {teeth} · {prosthesis}", + "noTasks": "برای این پرونده وظیفه‌ای ایجاد نشده است.", + "statusInProgress": "در حال انجام", + "statusCompleted": "انجام شده", + "importantLabel": "مهم", + "markCaseImportant": "علامت‌گذاری پرونده به‌عنوان مهم", + "markImportant": "علامت‌گذاری به عنوان مهم", + "lastUpdatedBy": "به‌روزرسانی توسط {name}", + "lastUpdatedUnknown": "هنوز شروع نشده", + "timelineTitle": "تاریخچه", + "timelineEntry": "{status} · {name} · {date}", + "errorLoadList": "بارگذاری پرونده‌ها ناموفق بود.", + "errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.", + "errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود.", + "filterClinic": "کلینیک", + "filterClinicAll": "همه کلینیک‌ها", + "filterTreatmentType": "نوع درمان", + "filterTreatmentTypeAll": "همه انواع", + "filterSentFrom": "ارسال از", + "filterSentTo": "ارسال تا", + "clearFilters": "پاک کردن فیلترها", + "patientMobile": "موبایل", + "showComments": "نظرات", + "commentsCount": "نظرات ({count})", + "latestAttachment": "آخرین فایل", + "viewAttachments": "مشاهده همه پیوست‌ها", + "attachmentsDialogTitle": "پیوست‌های پرونده", + "attachmentsDialogSubtitle": "پیش‌نمایش و دانلود فایل‌های به‌اشتراک‌گذاشته‌شده با این پرونده.", + "noAttachments": "هیچ پیوستی با این پرونده به‌اشتراک گذاشته نشده است.", + "downloadAttachment": "دانلود", + "downloadAllAttachments": "دانلود همه", + "attachmentPreviewUnavailable": "پیش‌نمایش در دسترس نیست", + "prevPage": "قبلی", + "nextPage": "بعدی", + "pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)", + "statusLabel": "وضعیت" + }, + "tasks": { + "title": "وظایف", + "subtitle": "همه وظایف لاب از کلینیک‌های متصل. فیلتر، مرتب‌سازی و به‌روزرسانی وضعیت هر مرحله.", + "subtitleOwner": "همه وظایف لاب از کلینیک‌های متصل. فیلتر، مرتب‌سازی و به‌روزرسانی وضعیت هر مرحله.", + "loading": "در حال بارگذاری وظایف…", + "emptyList": "هیچ وظیفه‌ای با فیلترهای فعلی مطابقت ندارد.", + "emptyListOwner": "هیچ وظیفه‌ای با فیلترهای فعلی مطابقت ندارد.", + "noPermissionTitle": "وظایف", + "noPermissionBody": "شما مجوز مشاهده وظایف برای این سازمان را ندارید.", + "fromClinic": "از {name}", + "patientLabel": "بیمار", + "taskDate": "{date}", + "teethLabel": "دندان‌های {teeth}", + "importantBadge": "مهم", + "lastUpdatedBy": "به‌روزرسانی توسط {name}", + "statusInProgress": "در حال انجام", + "statusCompleted": "تکمیل‌شده", + "searchPlaceholder": "جستجوی بیمار یا کلینیک…", + "filterClinic": "کلینیک", + "filterClinicAll": "همه کلینیک‌ها", + "filterStatus": "وضعیت", + "filterStatusAll": "همه وضعیت‌ها", + "showCompleted": "نمایش تکمیل‌شده‌ها", + "importantOnly": "فقط مهم‌ها", + "filterSentFrom": "از", + "filterSentTo": "تا", + "sortBy": "مرتب‌سازی بر اساس", + "sortDate": "تاریخ", + "sortStatus": "وضعیت", + "sortClinic": "کلینیک", + "sortPatient": "بیمار", + "sortImportant": "مهم", + "sortProsthesis": "نوع پروتز", + "sortTaskType": "نوع کار", + "sortDirection": "جهت مرتب‌سازی", + "clearFilters": "پاک کردن فیلترها", + "commentsButton": "نظرات", + "errorLoadList": "بارگذاری وظایف ناموفق بود.", + "errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود.", + "pageSummary": "صفحه {page} از {totalPages} ({total} وظیفه)" + }, + "caseComments": { + "title": "نظرات", + "placeholder": "یک نظر بنویسید…", + "reply": "پاسخ…", + "post": "ثبت", + "empty": "هنوز نظری ثبت نشده است.", + "visibleToClinicToggle": "قابل مشاهده برای کلینیک", + "clinicCanSee": "کلینیک می‌تواند ببیند", + "hiddenFromClinic": "پنهان از کلینیک", + "makeVisible": "نمایش به کلینیک", + "makeHidden": "پنهان از کلینیک", + "labAuthor": "آزمایشگاه", + "clinicAuthor": "کلینیک", + "errorLoad": "بارگذاری نظرات ناموفق بود.", + "errorPost": "ثبت نظر ناموفق بود.", + "errorToggle": "به‌روزرسانی وضعیت نمایش نظر ناموفق بود.", + "send": "ارسال نظر", + "composerVisible": "قابل مشاهده برای کلینیک", + "composerHidden": "پنهان از کلینیک" + }, "appointments": { "title": "نوبت‌ها", "subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائه‌دهنده کلیک کنید تا رزرو کنید.", @@ -384,6 +501,7 @@ "noPermissionBody": "شما مجوز مشاهده برگه درمان برای این سازمان را ندارید.", "title": "درمان", "subtitleEdit": "پرونده‌های نوبت‌های خود را مستند کنید، پیش‌نویس‌ها را ذخیره کنید و کار را به سازمان‌های مرتبط ارسال کنید.", + "subtitleEditPhase4": "ابتدا جزئیات درمان را برنامه‌ریزی کنید، سپس کار وابسته به لابراتوار را در بخش ارسال لاب گروه‌بندی کنید.", "subtitleReadOnly": "دسترسی فقط خواندنی — می‌توانید نوبت‌ها و تاریخچه درمان را بررسی کنید اما نمی‌توانید ویرایش کنید.", "pastDayNotice": "روزهای گذشته فقط قابل مشاهده هستند. می‌توانید نوبت‌ها و تاریخچه را بررسی کنید، اما پرونده‌های درمانی قابل اضافه یا تغییر نیستند.", "selectedPatient": "بیمار انتخاب شده", @@ -391,7 +509,6 @@ "loadingAppointments": "در حال بارگذاری نوبت‌ها...", "selectDayWithAppointment": "روزی را انتخاب کنید که حداقل یک نوبت داشته باشد.", "confirmDiscard": "تغییرات ذخیره‌نشده دارید. آنها را کنار بگذارید و ادامه دهید؟", - "successDraftSaved": "پیش‌نویس درمان ذخیره شد.", "errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.", "successCaseSent": "پرونده به سازمان‌های انتخاب شده ارسال شد.", "successFilesUploaded": "{count} فایل با موفقیت بارگذاری شد.", @@ -403,7 +520,7 @@ "errorSaveDraft": "ذخیره پیش‌نویس درمان ناموفق بود.", "errorSendCase": "ارسال پرونده ناموفق بود.", "errorCaseMustSave": "پرونده باید قبل از ارسال ذخیره شود.", - "draftTitle": "پیش‌نویس · {patientName}", + "treatmentPlanTitle": "درمان · {patientName}", "hiddenMessage": "نوبت‌ها پنهان هستند.", "showAppointments": "نمایش نوبت‌ها", "appointmentsTitle": "نوبت‌های من", @@ -411,6 +528,13 @@ "emptyDay": "هیچ نوبتی به شما در این روز اختصاص داده نشده است.", "casesTitle": "پرونده‌های درمانی", "casesSubtitle": "هر پرونده دارای دندان‌ها، یادداشت‌ها، پیوست‌ها و مقصدهای ارسال خود است.", + "detailsTitle": "جزئیات درمان", + "detailsSubtitle": "دندان‌ها، نوع، یادداشت و پیوست‌ها را برای هر خط جزئیات برنامه‌ریزی کنید.", + "addDetail": "افزودن جزئیات", + "detailLabel": "جزئیات {n}", + "detailSentBadge": "ارسال‌شده", + "detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.", + "detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی می‌شود.", "addCase": "افزودن پرونده", "caseLabel": "پرونده {n}", "comments": "نظرات", @@ -429,29 +553,55 @@ "recent": "اخیر:", "noOrgMatch": "هیچ سازمان فعالی با جستجوی شما مطابقت ندارد.", "sendThisCase": "ارسال این پرونده", - "saveDraft": "ذخیره پیش‌نویس درمان", + "labDispatchTitle": "ارسال به لابراتوار", + "labDispatchSubtitle": "جزئیات وابسته به لاب را در محموله‌ها گروه‌بندی کرده و به لابراتوارهای متصل ارسال کنید.", + "labDispatchSendHint": "گروه‌بندی محموله هنگام ارسال به لاب ذخیره می‌شود.", + "addLabShipment": "افزودن محموله لاب", + "labShipmentLabel": "محموله {n}", + "includeDetails": "شامل جزئیات درمان", + "labShipmentIncludedDetails": "شامل این محموله", + "labShipmentNoIncludedDetails": "جزئیاتی در این محموله گنجانده نشده است.", + "labShipmentNoDetailsAvailable": "همه جزئیات لاب در محموله‌های دیگر هستند یا ارسال شده‌اند.", + "labDetailLine": "جزئیات {n} · {type} · {teeth}", + "noLabDetails": "هنوز جزئیات پروتز وجود ندارد. پروتز را در جزئیات درمان بالا اضافه کنید.", + "labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروه‌بندی و ارسال کنید.", + "prosthesisTypesTitle": "انواع پروتز", + "prosthesisApplyAll": "اعمال برای همه دندان‌ها", + "prosthesisSelectPlaceholder": "نوع پروتز را انتخاب کنید…", + "prosthesisColTooth": "دندان", + "prosthesisColDetail": "جزئیات", + "prosthesisColType": "نوع پروتز", + "selectLab": "لابراتوار مقصد", + "selectLabPlaceholder": "یک لابراتوار متصل انتخاب کنید…", + "sendToLab": "ارسال به لابراتوار", + "saveLabShipments": "ذخیره محموله‌های لاب", + "labDispatchSaveHint": "گروه‌بندی محموله را بدون ارسال ذخیره می‌کند.", + "successLabShipmentsSaved": "محموله‌های لاب ذخیره شد.", + "errorSaveLabShipments": "ذخیره محموله‌های لاب ناموفق بود.", + "errorLabCaseNeedsDetails": "حداقل یک جزئیات درمان برای این محموله انتخاب کنید.", "unsavedChanges": "تغییرات ذخیره‌نشده", - "draftSaved": "پیش‌نویس ذخیره شد", + "saveStatusSaving": "در حال ذخیره…", + "saveStatusSaved": "همه تغییرات ذخیره شد", + "saveStatusError": "ذخیره ناموفق بود — اتصال را بررسی کنید", "sendSavesFirst": "ارسال برای هر پرونده به صورت جداگانه است و ابتدا به طور خودکار ذخیره می‌کند.", "historyTitle": "درمان‌های قبلی", - "historySubtitle": "درمان‌های تکمیل شده برای این بیمار. هر پرونده به طور جداگانه فهرست شده است.", + "historySubtitle": "برای پیش‌نمایش روی یک درمان کلیک کنید. از دکمه باز کردن در کارت پیش‌نمایش برای بارگذاری در فضای کاری استفاده کنید.", "loadingHistory": "در حال بارگذاری تاریخچه...", - "historyEmpty": "هیچ درمان قبلی برای این بیمار وجود ندارد.", - "statusLabel": "وضعیت:", - "historyCaseLabel": "پرونده {n} · {type}", + "historyEmpty": "هیچ درمان دیگری برای این بیمار ثبت نشده است.", + "historyDetailLabel": "جزئیات {n} · {type}", + "previewTitle": "پیش‌نمایش درمان", + "openTreatment": "باز کردن", + "selectAppointment": "یک نوبت را برای پیش‌نمایش درمان انتخاب کنید.", + "detailCount": "{n} جزئیات", + "detailSummary": "جزئیات {n}: {type}", + "detailAttachmentCount": "{n} فایل", + "detailNotSentToLab": "به لاب ارسال نشده", + "detailPendingLabSend": "این جزئیات لاب هنوز ارسال نشده است.", + "historicalReadonlyNotice": "در حال مشاهده یک درمان گذشته (فقط خواندنی) هستید.", + "errorNoAppointmentForTreatment": "این درمان نوبت مرتبطی ندارد و قابل باز کردن نیست.", "teethLabel": "دندان‌ها:", "teethNone": "هیچکدام انتخاب نشده", - "reviewDetails": "بررسی جزئیات", - "previewTitle": "پیش‌نمایش درمان", - "previewDraft": "پیش‌نمایش پیش‌نویس فعلی", - "selectAppointment": "یک نوبت را برای پیش‌نمایش پیش‌نویس آن انتخاب کنید.", - "caseCount": "{n} پرونده", - "attachmentCount": "{n} پیوست", - "caseSummary": "پرونده {n}: {type}", - "teethPrefix": "· دندان‌ها", - "moreCases": "+ {n} پرونده دیگر", - "previewDialogTitle": "پیش‌نمایش درمان", - "previewDialogSubtitle": "بررسی پرونده‌ها، پیوست‌ها و مقصدهای ارسال.", + "noDetails": "هنوز جزئیات درمانی وجود ندارد.", "noCases": "هیچ پرونده‌ای در این درمان وجود ندارد.", "typeLabel": "نوع:", "commentsLabel": "نظرات:", @@ -462,7 +612,11 @@ "noActiveOrgs": "هیچ سازمان مرتبط فعالی وجود ندارد.", "confirmSend": "تأیید ارسال", "toothChartTitle": "نمودار دندان‌ها FDI", - "toothChartHint": "برای انتخاب چندگانه روی دندان‌ها ضربه بزنید. برای پرونده فعال اعمال می‌شود.", + "toothChartTitleCompact": "نمودار دندان", + "toothChartHint": "برای انتخاب چندگانه روی دندان‌ها ضربه بزنید. برای جزئیات فعال اعمال می‌شود.", + "toothChartWholePlan": "نمایش کل طرح درمان", + "labShipmentAttachments": "فایل‌ها برای لابراتوار", + "labShipmentAttachmentsHint": "انتخاب کنید کدام پیوست‌های این جزئیات در این محموله ارسال شوند. پیش‌فرض هیچ‌کدام نیست.", "selectedLabel": "انتخاب شده:", "selectedEmpty": "—", "upperArch": "قوس بالا", @@ -526,7 +680,16 @@ "continueArrow": "ادامه →", "planLabel": "طرح: {name} • {maxUsers} کاربر", "counterpartClinic": "کلینیک", - "counterpartLab": "لابراتوار" + "counterpartLab": "لابراتوار", + "viewCaseHistory": "مشاهده تاریخچه پرونده‌ها", + "caseHistoryBackToConnections": "← بازگشت به اتصالات", + "caseHistoryTitle": "تاریخچه پرونده با {name}", + "caseHistorySubtitleClinic": "پرونده‌هایی که به این لابراتوار ارسال کرده‌اید، شامل وضعیت گردش کار لابراتوار برای هر مرحله.", + "caseHistorySubtitleLab": "پرونده‌های دریافتی از این کلینیک، شامل وضعیت وظایف برای هر مرحله.", + "caseHistoryEmpty": "هنوز پرونده‌ای با این سازمان رد و بدل نشده است.", + "caseHistorySentToLab": "ارسال شده به {name}", + "caseHistoryErrorLoadList": "بارگذاری تاریخچه پرونده ناموفق بود.", + "caseHistoryErrorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود." }, "settings": { "accountTitle": "حساب کاربری", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 212f021..699d061 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -51,6 +51,8 @@ "patients": "Patiënten", "appointment": "Afspraak", "treatment": "Behandeling", + "cases": "Dossiers", + "tasks": "Taken", "billing": "Facturatie", "reports": "Rapporten", "clinics": "Klinieken", @@ -276,6 +278,8 @@ "featurePatients": "Patiënten", "featureAppointment": "Afspraak", "featureTreatment": "Behandeling", + "featureCases": "Dossiers", + "featureTasks": "Taken", "featureBilling": "Facturatie", "featureReports": "Rapporten", "noTabAccess": "Geen tabbladtoegang", @@ -311,21 +315,134 @@ "errorSavePatient": "Patiënt opslaan mislukt.", "firstName": "Voornaam", "lastName": "Achternaam", - "phone": "Telefoon", + "mobile": "Mobiel", + "mobilePlaceholder": "0612345678", + "mobileLabel": "Mobiel:", + "patientAlreadyExists": "Er bestaat al een patiënt met dit mobiele nummer: {firstName} {lastName}. Deze is voor u geselecteerd.", "savePatient": "Patiënt opslaan", "dialogTitle": "Nieuwe patiënt", - "searchPlaceholder": "Zoek patiënten op naam, telefoon, e-mail", + "searchPlaceholder": "Zoek patiënten op naam, mobiel, e-mail", "loadingPatients": "Patiënten laden...", "noResults": "Geen patiënten gevonden voor deze zoekopdracht.", "noContact": "Geen contact", "selectPatient": "Selecteer een patiënt om details te bekijken.", - "phoneLabel": "Telefoon:", "emailLabel": "E-mail:", "statusLabel": "Status:", "statusActive": "Actief", "statusInactive": "Inactief", "emptyValue": "-" }, + "cases": { + "title": "Dossiers", + "subtitle": "Labdossiers van gekoppelde klinieken. Volg de voortgang en markeer belangrijke taken.", + "searchPlaceholder": "Zoeken op patiëntnaam of mobiel…", + "emptyList": "Nog geen dossiers ontvangen.", + "selectCaseHint": "Selecteer een dossier uit de lijst om taken te bekijken.", + "fromClinic": "Van {name}", + "sentAt": "Verzonden {date}", + "taskProgressLabel": "Taken: {completed} van {total} voltooid", + "taskProgressShort": "{progress} taken", + "treatmentDetails": "Behandeldetails", + "teethLabel": "Tanden", + "tasksByTooth": "Taken", + "toothGroupTitle": "Tanden {teeth} · {prosthesis}", + "noTasks": "Er zijn geen taken gegenereerd voor dit dossier.", + "statusInProgress": "Bezig", + "statusCompleted": "Voltooid", + "importantLabel": "Belangrijk", + "markCaseImportant": "Zaak als belangrijk markeren", + "markImportant": "Markeren als belangrijk", + "lastUpdatedBy": "Bijgewerkt door {name}", + "lastUpdatedUnknown": "Nog niet gestart", + "timelineTitle": "Geschiedenis", + "timelineEntry": "{status} · {name} · {date}", + "errorLoadList": "Dossiers laden mislukt.", + "errorLoadDetail": "Dossierdetails laden mislukt.", + "errorUpdateTask": "Taak bijwerken mislukt.", + "filterClinic": "Kliniek", + "filterClinicAll": "Alle klinieken", + "filterTreatmentType": "Behandeltype", + "filterTreatmentTypeAll": "Alle types", + "filterSentFrom": "Verzonden vanaf", + "filterSentTo": "Verzonden tot", + "clearFilters": "Filters wissen", + "patientMobile": "Mobiel", + "showComments": "Opmerkingen", + "commentsCount": "Opmerkingen ({count})", + "latestAttachment": "Laatste bestand", + "viewAttachments": "Alle bijlagen bekijken", + "attachmentsDialogTitle": "Zaakbijlagen", + "attachmentsDialogSubtitle": "Bekijk en download bestanden die met deze zaak zijn gedeeld.", + "noAttachments": "Er zijn geen bijlagen met deze zaak gedeeld.", + "downloadAttachment": "Downloaden", + "downloadAllAttachments": "Alles downloaden", + "attachmentPreviewUnavailable": "Voorbeeld niet beschikbaar", + "prevPage": "Vorige", + "nextPage": "Volgende", + "pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)", + "statusLabel": "Status" + }, + "tasks": { + "title": "Taken", + "subtitle": "Alle labtaken van gekoppelde klinieken. Filter, sorteer en werk de status van elke stap bij.", + "subtitleOwner": "Alle labtaken van gekoppelde klinieken. Filter, sorteer en werk de status van elke stap bij.", + "loading": "Taken laden…", + "emptyList": "Geen taken komen overeen met de huidige filters.", + "emptyListOwner": "Geen taken komen overeen met de huidige filters.", + "noPermissionTitle": "Taken", + "noPermissionBody": "U heeft geen toestemming om taken voor deze organisatie te bekijken.", + "fromClinic": "Van {name}", + "patientLabel": "Patiënt", + "taskDate": "{date}", + "teethLabel": "Tanden {teeth}", + "importantBadge": "Belangrijk", + "lastUpdatedBy": "Bijgewerkt door {name}", + "statusInProgress": "Bezig", + "statusCompleted": "Voltooid", + "searchPlaceholder": "Zoek patiënt of kliniek…", + "filterClinic": "Kliniek", + "filterClinicAll": "Alle klinieken", + "filterStatus": "Status", + "filterStatusAll": "Alle statussen", + "showCompleted": "Voltooide tonen", + "importantOnly": "Alleen belangrijk", + "filterSentFrom": "Vanaf", + "filterSentTo": "Tot", + "sortBy": "Sorteren op", + "sortDate": "Datum", + "sortStatus": "Status", + "sortClinic": "Kliniek", + "sortPatient": "Patiënt", + "sortImportant": "Belangrijk", + "sortProsthesis": "Prothesetype", + "sortTaskType": "Taaktype", + "sortDirection": "Sorteerrichting", + "clearFilters": "Filters wissen", + "commentsButton": "Opmerkingen", + "errorLoadList": "Taken laden mislukt.", + "errorUpdateTask": "Taak bijwerken mislukt.", + "pageSummary": "Pagina {page} van {totalPages} ({total} taken)" + }, + "caseComments": { + "title": "Opmerkingen", + "placeholder": "Schrijf een opmerking…", + "reply": "Antwoorden…", + "post": "Plaatsen", + "empty": "Nog geen opmerkingen.", + "visibleToClinicToggle": "Zichtbaar voor kliniek", + "clinicCanSee": "Kliniek kan dit zien", + "hiddenFromClinic": "Verborgen voor kliniek", + "makeVisible": "Zichtbaar maken voor kliniek", + "makeHidden": "Verbergen voor kliniek", + "labAuthor": "Lab", + "clinicAuthor": "Kliniek", + "errorLoad": "Opmerkingen laden mislukt.", + "errorPost": "Opmerking plaatsen mislukt.", + "errorToggle": "Zichtbaarheid bijwerken mislukt.", + "send": "Opmerking versturen", + "composerVisible": "Zichtbaar voor kliniek", + "composerHidden": "Verborgen voor kliniek" + }, "appointments": { "title": "Afspraken", "subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.", @@ -384,6 +501,7 @@ "noPermissionBody": "U heeft geen toestemming om het tabblad Behandeling voor deze organisatie te bekijken.", "title": "Behandeling", "subtitleEdit": "Documenteer casussen voor uw afspraken, sla concepten op en stuur werk naar gekoppelde organisaties.", + "subtitleEditPhase4": "Plan eerst behandeldetails, groepeer daarna lab-afhankelijk werk in het lab-dispatchpaneel.", "subtitleReadOnly": "Alleen-lezen toegang — u kunt afspraken en behandelgeschiedenis bekijken, maar niet bewerken.", "pastDayNotice": "Dagen in het verleden zijn alleen-lezen. U kunt afspraken en geschiedenis bekijken, maar behandelcasussen kunnen niet worden toegevoegd of gewijzigd.", "selectedPatient": "Geselecteerde patiënt", @@ -391,7 +509,6 @@ "loadingAppointments": "Afspraken laden...", "selectDayWithAppointment": "Selecteer een dag met ten minste één afspraak.", "confirmDiscard": "U heeft niet-opgeslagen wijzigingen. Wilt u deze negeren en doorgaan?", - "successDraftSaved": "Behandelconcept opgeslagen.", "errorChooseOrg": "Kies ten minste één actieve organisatie om deze case te verzenden.", "successCaseSent": "Case verzonden naar geselecteerde organisaties.", "successFilesUploaded": "{count} bestand(en) succesvol geüpload.", @@ -403,7 +520,7 @@ "errorSaveDraft": "Behandelconcept opslaan mislukt.", "errorSendCase": "Case verzenden mislukt.", "errorCaseMustSave": "Case moet worden opgeslagen voor verzending.", - "draftTitle": "Concept · {patientName}", + "treatmentPlanTitle": "Behandeling · {patientName}", "hiddenMessage": "Afspraken zijn verborgen.", "showAppointments": "Afspraken tonen", "appointmentsTitle": "Mijn afspraken", @@ -411,6 +528,13 @@ "emptyDay": "Geen afspraken aan u toegewezen op deze dag.", "casesTitle": "Behandelcasussen", "casesSubtitle": "Elke case heeft zijn eigen tanden, notities, bijlagen en verzendbestemmingen.", + "detailsTitle": "Behandeldetails", + "detailsSubtitle": "Plan tanden, type, notities en bijlagen per detailregel.", + "addDetail": "Detail toevoegen", + "detailLabel": "Detail {n}", + "detailSentBadge": "verzonden", + "detailLockedInShipment": "Dit detail is naar het lab verzonden en kan niet meer worden bewerkt.", + "detailsSaveHint": "Lab-dispatch wordt hieronder apart geconfigureerd.", "addCase": "Case toevoegen", "caseLabel": "Case {n}", "comments": "Opmerkingen", @@ -429,30 +553,56 @@ "recent": "Recent:", "noOrgMatch": "Geen actieve organisatie komt overeen met uw zoekopdracht.", "sendThisCase": "Verzend deze case", - "saveDraft": "Behandelconcept opslaan", + "labDispatchTitle": "Lab-dispatch", + "labDispatchSubtitle": "Groepeer lab-afhankelijke details in zendingen en stuur ze naar gekoppelde labs.", + "labDispatchSendHint": "Groepering wordt opgeslagen wanneer u naar het lab verzendt.", + "addLabShipment": "Labzending toevoegen", + "labShipmentLabel": "Zending {n}", + "includeDetails": "Behandeldetails opnemen", + "labShipmentIncludedDetails": "Opgenomen in deze zending", + "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 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", + "selectLab": "Bestemmingslab", + "selectLabPlaceholder": "Kies een gekoppeld lab…", + "sendToLab": "Versturen naar lab", + "saveLabShipments": "Labzendingen opslaan", + "labDispatchSaveHint": "Slaat groepering op zonder te verzenden.", + "successLabShipmentsSaved": "Labzendingen opgeslagen.", + "errorSaveLabShipments": "Labzendingen opslaan mislukt.", + "errorLabCaseNeedsDetails": "Selecteer minimaal één behandeldetail voor deze zending.", "unsavedChanges": "Niet-opgeslagen wijzigingen", - "draftSaved": "Concept opgeslagen", + "saveStatusSaving": "Opslaan…", + "saveStatusSaved": "Alle wijzigingen opgeslagen", + "saveStatusError": "Opslaan mislukt — controleer uw verbinding", "sendSavesFirst": "Verzenden is per case en slaat eerst automatisch op.", "historyTitle": "Eerdere behandelingen", - "historySubtitle": "Voltooide behandelingen voor deze patiënt. Elke case wordt afzonderlijk weergegeven.", + "historySubtitle": "Klik op een behandeling om te bekijken. Gebruik Open in de voorbeeldkkaart om deze in de werkruimte te laden.", "loadingHistory": "Geschiedenis laden...", - "historyEmpty": "Geen eerdere behandelingen voor deze patiënt.", - "statusLabel": "Status:", - "historyCaseLabel": "Case {n} · {type}", + "historyEmpty": "Geen andere behandelingen voor deze patiënt geregistreerd.", + "historyDetailLabel": "Detail {n} · {type}", + "previewTitle": "Behandelvoorbeeld", + "openTreatment": "Openen", + "selectAppointment": "Selecteer een afspraak om de behandeling te bekijken.", + "detailCount": "{n} detail(s)", + "detailSummary": "Detail {n}: {type}", + "detailAttachmentCount": "{n, plural, one {# bestand} other {# bestanden}}", + "detailNotSentToLab": "Niet naar lab verzonden", + "detailPendingLabSend": "Dit labdetail is nog niet verzonden.", + "historicalReadonlyNotice": "U bekijkt een eerdere behandeling (alleen-lezen).", + "errorNoAppointmentForTreatment": "Deze behandeling heeft geen gekoppelde afspraak en kan niet worden geopend.", "teethLabel": "Tanden:", "teethNone": "Geen geselecteerd", - "reviewDetails": "Details bekijken", - "previewTitle": "Behandelvoorbeeld", - "previewDraft": "Bekijk huidig concept", - "selectAppointment": "Selecteer een afspraak om het concept te bekijken.", - "caseCount": "{n} case(s)", - "attachmentCount": "{n} bijlage(n)", - "caseSummary": "Case {n}: {type}", - "teethPrefix": "· Tanden", - "moreCases": "+ {n} meer case(s)", - "previewDialogTitle": "Behandelvoorbeeld", - "previewDialogSubtitle": "Bekijk casussen, bijlagen en verzendbestemmingen.", "noCases": "Geen casussen in deze behandeling.", + "noDetails": "Nog geen behandeldetails.", "typeLabel": "Type:", "commentsLabel": "Opmerkingen:", "commentsEmpty": "Opmerkingen: —", @@ -462,7 +612,11 @@ "noActiveOrgs": "Geen actieve gekoppelde organisaties.", "confirmSend": "Bevestig verzending", "toothChartTitle": "FDI-tanddiagram", - "toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor de actieve case.", + "toothChartTitleCompact": "Tanddiagram", + "toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor het actieve detail.", + "toothChartWholePlan": "Hele behandelplan tonen", + "labShipmentAttachments": "Bestanden voor het lab", + "labShipmentAttachmentsHint": "Kies welke bijlagen van dit detail bij deze zending horen. Standaard worden er geen meegestuurd.", "selectedLabel": "Geselecteerd:", "selectedEmpty": "—", "upperArch": "Bovenboog", @@ -526,7 +680,16 @@ "continueArrow": "Doorgaan →", "planLabel": "Plan: {name} • {maxUsers} gebruikers", "counterpartClinic": "Kliniek", - "counterpartLab": "Laboratorium" + "counterpartLab": "Laboratorium", + "viewCaseHistory": "Casusgeschiedenis bekijken", + "caseHistoryBackToConnections": "← Terug naar verbindingen", + "caseHistoryTitle": "Casusgeschiedenis met {name}", + "caseHistorySubtitleClinic": "Cases die u naar dit lab hebt gestuurd, inclusief lab-workflowstatus per stap.", + "caseHistorySubtitleLab": "Cases ontvangen van deze kliniek, inclusief taakstatus per stap.", + "caseHistoryEmpty": "Nog geen cases uitgewisseld met deze organisatie.", + "caseHistorySentToLab": "Verzonden naar {name}", + "caseHistoryErrorLoadList": "Casusgeschiedenis laden mislukt.", + "caseHistoryErrorLoadDetail": "Casusdetails laden mislukt." }, "settings": { "accountTitle": "Account", diff --git a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx index 10207e0..573c324 100644 --- a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx @@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import { appointmentsApi } from '@/lib/api/appointments'; import { patientsApi } from '@/lib/api/patients'; +import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import { useAuth } from '@/lib/hooks/useAuth'; import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; @@ -24,7 +26,7 @@ import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/co const EMPTY_PATIENT_FORM: CreatePatientInput = { firstName: '', lastName: '', - phone: '', + mobile: '', email: '', }; @@ -36,6 +38,7 @@ export default function AppointmentsPage() { const [providers, setProviders] = useState([]); const [appointments, setAppointments] = useState([]); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); const [loadingSchedule, setLoadingSchedule] = useState(false); const toast = useToast(); @@ -114,6 +117,13 @@ export default function AppointmentsPage() { void loadSchedule(); }, [loadSchedule]); + useEffect(() => { + void treatmentCatalogApi + .list('appointment') + .then((r) => setTreatmentCatalog(r.data)) + .catch(() => {}); + }, []); + useEffect(() => { const t = setTimeout(() => { void loadPatientsSearch(search); @@ -152,12 +162,21 @@ export default function AppointmentsPage() { setPatientForm(EMPTY_PATIENT_FORM); await loadPatientsSearch(search); setSelectedPatient(response.data); - toast.showSuccess( - t('successPatientSaved', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); + if (response.existing) { + toast.showInfo( + tPatients('patientAlreadyExists', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } else { + toast.showSuccess( + t('successPatientSaved', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } } catch (err: unknown) { const message = err && typeof err === 'object' && 'message' in err @@ -292,7 +311,7 @@ export default function AppointmentsPage() {
- +
handleSlotClick(startMinute, uid, name)} onAppointmentClick={(apt) => handleAppointmentClick(apt)} @@ -323,6 +343,7 @@ export default function AppointmentsPage() { providerUserId={bookingProviderId} providerName={bookingProviderName} initialStartMinute={bookingStartMinute} + treatmentCatalog={treatmentCatalog} editingAppointment={activeEditingAppointment} onClose={() => { setBookingOpen(false); diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx new file mode 100644 index 0000000..7fd12b1 --- /dev/null +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -0,0 +1,448 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { useToast } from '@/lib/hooks/useToast'; +import { canEditCases, canEditTasks } from '@/components/shared/permissions'; +import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel'; +import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; +import { + formatCaseDateTime, + formatPatientName, +} from '@/components/ui/lab/caseDetailUtils'; +import { casesApi } from '@/lib/api/cases'; +import { tasksApi } from '@/lib/api/tasks'; +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 { + CasesFilterOptions, + LabCaseDetail, + LabCaseListItem, + LabTaskStatus, + PaginatedLabCases, +} from '@/types/cases'; + +const PAGE_SIZE = 20; + +export default function CasesPage() { + const t = useTranslations('cases'); + const tCommon = useTranslations('common'); + const { currentOrganization, user } = useAuth(); + const toast = useToast(); + const searchParams = useSearchParams(); + + const [search, setSearch] = useState(''); + const [clinicId, setClinicId] = useState(''); + const [treatmentType, setTreatmentType] = useState(''); + const [sentFrom, setSentFrom] = useState(''); + const [sentTo, setSentTo] = useState(''); + const [page, setPage] = useState(1); + + const [cases, setCases] = useState([]); + const [pagination, setPagination] = useState({ + page: 1, + limit: PAGE_SIZE, + total: 0, + totalPages: 1, + }); + const [filterOptions, setFilterOptions] = useState({ + clinics: [], + treatmentTypes: [], + }); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); + + const [selectedCaseId, setSelectedCaseId] = useState(null); + const [selectedCase, setSelectedCase] = useState(null); + const [loadingList, setLoadingList] = useState(false); + const [loadingDetail, setLoadingDetail] = useState(false); + const [updatingImportant, setUpdatingImportant] = useState(false); + const [commentCount, setCommentCount] = useState(0); + + const canEdit = canEditCases(currentOrganization); + const canEditComments = canEditTasks(currentOrganization); + const locale = user?.language ?? 'en'; + + const treatmentLabel = useCallback( + (type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog), + [treatmentCatalog], + ); + + const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( + () => [ + { value: 'IN_PROGRESS', label: t('statusInProgress') }, + { value: 'COMPLETED', label: t('statusCompleted') }, + ], + [t], + ); + + const hasActiveFilters = Boolean( + search.trim() || clinicId || treatmentType || sentFrom || sentTo, + ); + + const loadCases = async (params: { + q: string; + clinicOrganizationId: string; + treatmentType: string; + sentFrom: string; + sentTo: string; + page: number; + }) => { + setLoadingList(true); + toast.setError(''); + try { + const response = await casesApi.list({ + q: params.q.trim() || undefined, + clinicOrganizationId: params.clinicOrganizationId || undefined, + treatmentType: params.treatmentType || undefined, + sentFrom: params.sentFrom || undefined, + sentTo: params.sentTo || undefined, + page: params.page, + limit: PAGE_SIZE, + }); + setCases(response.data.items); + setPagination(response.data.pagination); + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorLoadList'))); + } finally { + setLoadingList(false); + } + }; + + const loadDetail = async (caseId: string, options?: { silent?: boolean }) => { + if (!options?.silent) { + setLoadingDetail(true); + } + toast.setError(''); + try { + const response = await casesApi.getOne(caseId); + setSelectedCase(response.data); + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorLoadDetail'))); + if (!options?.silent) { + setSelectedCase(null); + } + } finally { + if (!options?.silent) { + setLoadingDetail(false); + } + } + }; + + useEffect(() => { + void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {}); + void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch + }, []); + + useEffect(() => { + const caseIdFromUrl = searchParams.get('caseId'); + if (caseIdFromUrl) { + setSelectedCaseId(caseIdFromUrl); + } + }, [searchParams]); + + useEffect(() => { + const timeout = setTimeout(() => { + void loadCases({ + q: search, + clinicOrganizationId: clinicId, + treatmentType, + sentFrom, + sentTo, + page, + }); + }, search ? 300 : 0); + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload + }, [search, clinicId, treatmentType, sentFrom, sentTo, page]); + + useEffect(() => { + if (selectedCaseId) { + void loadDetail(selectedCaseId); + void tasksApi + .listComments(selectedCaseId) + .then((r) => setCommentCount(r.data.length)) + .catch(() => setCommentCount(0)); + } else { + setSelectedCase(null); + setCommentCount(0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes + }, [selectedCaseId]); + + function scrollToComments() { + document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' }); + } + + const loadCaseAttachmentBlob = useCallback( + (caseId: string, attachmentId: string) => casesApi.getAttachmentFileBlob(caseId, attachmentId), + [], + ); + + function clearFilters() { + setSearch(''); + setClinicId(''); + setTreatmentType(''); + setSentFrom(''); + setSentTo(''); + setPage(1); + } + + async function handleCaseImportantToggle(isImportant: boolean) { + if (!selectedCaseId || !canEdit || !selectedCase) return; + + const previousCase = selectedCase; + setSelectedCase({ ...selectedCase, isImportant }); + + setUpdatingImportant(true); + toast.setError(''); + try { + const response = await casesApi.setCaseImportant(selectedCaseId, isImportant); + setSelectedCase(response.data); + } catch (error: unknown) { + setSelectedCase(previousCase); + toast.showError(formatApiErrorMessage(error, t('errorUpdateTask'))); + } finally { + setUpdatingImportant(false); + } + } + + const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-3 py-2`; + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+
+ { + setSearch(value); + setPage(1); + }} + placeholder={t('searchPlaceholder')} + /> + +
+ + + + + + + +
+ + {hasActiveFilters ? ( + + ) : null} + +
+ {loadingList ? ( +

{tCommon('loading')}

+ ) : cases.length === 0 ? ( +

{t('emptyList')}

+ ) : ( +
    + {cases.map((item) => { + const isActive = item.id === selectedCaseId; + + return ( +
  • + +
  • + ); + })} +
+ )} +
+ + {pagination.totalPages > 1 ? ( +
+ + + {t('pageSummary', { + page: pagination.page, + totalPages: pagination.totalPages, + total: pagination.total, + })} + + +
+ ) : null} +
+ +
+ {!selectedCaseId ? ( +

{t('selectCaseHint')}

+ ) : loadingDetail || !selectedCase ? ( +

{tCommon('loading')}

+ ) : ( + void handleCaseImportantToggle(checked)} + headerMetaLines={ +

+ {t('fromClinic', { name: selectedCase.clinic.name })} +

+ } + commentsSection={ + selectedCaseId ? ( +
+ { + const r = await tasksApi.listComments(selectedCaseId); + setCommentCount(r.data.length); + return r.data; + }} + onPost={async (body, visibleToClinic) => { + const r = await tasksApi.addComment(selectedCaseId, { + body, + visibleToClinic, + }); + setCommentCount((n) => n + 1); + return r.data; + }} + onToggleVisibility={async (commentId, visible) => { + const r = await tasksApi.setCommentVisibility(commentId, visible); + return r.data; + }} + onError={toast.showError} + /> +
+ ) : null + } + /> + )} +
+
+ + +
+ ); +} diff --git a/frontend/src/app/[locale]/(dashboard)/layout.tsx b/frontend/src/app/[locale]/(dashboard)/layout.tsx index d3b4c39..037e1c7 100644 --- a/frontend/src/app/[locale]/(dashboard)/layout.tsx +++ b/frontend/src/app/[locale]/(dashboard)/layout.tsx @@ -8,10 +8,8 @@ import Sidebar from '@/components/ui/shared/Sidebar'; import { TopBarControls } from '@/components/ui/shared/TopBarControls'; import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu'; import { - canAccessAppointmentsSection, + canAccessDashboardRoute, firstAccessibleDashboardPath, - getRequiredReadPermissionForPath, - hasPermission, } from '@/components/shared/permissions'; export default function DashboardLayout({ children }: { children: React.ReactNode }) { @@ -33,15 +31,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod return; } - const required = getRequiredReadPermissionForPath(pathname); - if (required) { - const allowed = - hasPermission(currentOrganization, required) || - (required === 'TAB_APPOINTMENTS_READ' && - canAccessAppointmentsSection(currentOrganization)); - if (!allowed) { - router.replace(firstAccessibleDashboardPath(currentOrganization)); - } + if (!canAccessDashboardRoute(currentOrganization, pathname)) { + router.replace(firstAccessibleDashboardPath(currentOrganization)); } }, [isAuthReady, user, currentOrganization, router, pathname]); @@ -68,8 +59,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
-
-
+
+
{children}
diff --git a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx index c0ffcfc..cc1da47 100644 --- a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useToast } from '@/lib/hooks/useToast'; -import { Check, Trash2, UserPlus, X } from 'lucide-react'; +import { Check, History, Trash2, UserPlus, X } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount'; import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy'; @@ -16,6 +16,7 @@ import { import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks'; import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog'; +import { ConnectionCaseHistoryContent } from '@/components/ui/organizations/ConnectionCaseHistoryContent'; import { Button } from '@/components/ui/shared/Button'; import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge'; import { Input } from '@/components/ui/shared/Input'; @@ -90,6 +91,9 @@ export default function OrganizationsPage() { const [historyOpen, setHistoryOpen] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [historyItems, setHistoryItems] = useState([]); + const [caseHistoryConnection, setCaseHistoryConnection] = useState( + null, + ); const { copiedId, @@ -289,6 +293,15 @@ export default function OrganizationsPage() { return

{t('loadingOrganization')}

; } + if (caseHistoryConnection) { + return ( + setCaseHistoryConnection(null)} + /> + ); + } + return (
@@ -426,16 +439,27 @@ export default function OrganizationsPage() { )} {row.status === 'ACTIVE' && ( - + <> + + + )}
diff --git a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx index 61a5f21..ddc52b1 100644 --- a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx @@ -1,151 +1,160 @@ -'use client'; - -import { useEffect, useMemo, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { Button } from '@/components/ui/shared/Button'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import { patientsApi } from '@/lib/api/patients'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { useToast } from '@/lib/hooks/useToast'; -import { hasPermission } from '@/components/shared/permissions'; -import { CreatePatientInput, Patient } from '@/types/patient'; -import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect'; -import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; -import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; - -const EMPTY_PATIENT_FORM: CreatePatientInput = { - firstName: '', - lastName: '', - phone: '', - email: '', -}; - -export default function PatientsPage() { - const t = useTranslations('patients'); - const tCommon = useTranslations('common'); - const { currentOrganization } = useAuth(); - const toast = useToast(); - const [search, setSearch] = useState(''); - const [patients, setPatients] = useState([]); - const [selectedPatient, setSelectedPatient] = useState(); - const [loadingPatients, setLoadingPatients] = useState(false); - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [savingPatient, setSavingPatient] = useState(false); - const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); - const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); - - const sortedPatients = useMemo( - () => - [...patients].sort((a, b) => - `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), - ), - [patients], - ); - - useEffect(() => { - const timeout = setTimeout(() => { - void loadPatients(search); - }, 300); - return () => clearTimeout(timeout); - }, [search]); - - useEffect(() => { - void loadPatients(''); - }, []); - - async function loadPatients(q: string) { - setLoadingPatients(true); - toast.setError(''); - try { - const response = await patientsApi.list({ q, page: 1, limit: 25 }); - const items = response.data.items; - setPatients(items); - - if (selectedPatient) { - const freshSelected = items.find((item) => item.id === selectedPatient.id); - setSelectedPatient(freshSelected); - } - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorLoadPatients'))); - } finally { - setLoadingPatients(false); - } - } - - async function handleCreatePatient() { - setSavingPatient(true); - toast.setError(''); - try { - const response = await patientsApi.create(patientForm); - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - await loadPatients(search); - setSelectedPatient(response.data); - toast.showSuccess( - t('successPatientSaved', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorSavePatient'))); - } finally { - setSavingPatient(false); - } - } - - return ( -
-
-

{t('title')}

- -
- - - - {isCreateOpen && ( - setPatientForm((prev) => ({ ...prev, ...patch }))} - onSubmit={() => void handleCreatePatient()} - onClose={() => { - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - }} - loading={savingPatient} - /> - )} - -
-
- -
- -
- -
-
-
- ); -} +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/shared/Button'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { patientsApi } from '@/lib/api/patients'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { useToast } from '@/lib/hooks/useToast'; +import { hasPermission } from '@/components/shared/permissions'; +import { CreatePatientInput, Patient } from '@/types/patient'; +import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect'; +import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; +import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; + +const EMPTY_PATIENT_FORM: CreatePatientInput = { + firstName: '', + lastName: '', + mobile: '', + email: '', +}; + +export default function PatientsPage() { + const t = useTranslations('patients'); + const tCommon = useTranslations('common'); + const { currentOrganization } = useAuth(); + const toast = useToast(); + const [search, setSearch] = useState(''); + const [patients, setPatients] = useState([]); + const [selectedPatient, setSelectedPatient] = useState(); + const [loadingPatients, setLoadingPatients] = useState(false); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [savingPatient, setSavingPatient] = useState(false); + const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); + const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); + + const sortedPatients = useMemo( + () => + [...patients].sort((a, b) => + `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), + ), + [patients], + ); + + useEffect(() => { + const timeout = setTimeout(() => { + void loadPatients(search); + }, 300); + return () => clearTimeout(timeout); + }, [search]); + + useEffect(() => { + void loadPatients(''); + }, []); + + async function loadPatients(q: string) { + setLoadingPatients(true); + toast.setError(''); + try { + const response = await patientsApi.list({ q, page: 1, limit: 25 }); + const items = response.data.items; + setPatients(items); + + if (selectedPatient) { + const freshSelected = items.find((item) => item.id === selectedPatient.id); + setSelectedPatient(freshSelected); + } + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorLoadPatients'))); + } finally { + setLoadingPatients(false); + } + } + + async function handleCreatePatient() { + setSavingPatient(true); + toast.setError(''); + try { + const response = await patientsApi.create(patientForm); + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + await loadPatients(search); + setSelectedPatient(response.data); + if (response.existing) { + toast.showInfo( + t('patientAlreadyExists', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } else { + toast.showSuccess( + t('successPatientSaved', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorSavePatient'))); + } finally { + setSavingPatient(false); + } + } + + return ( +
+
+

{t('title')}

+ +
+ + + + {isCreateOpen && ( + setPatientForm((prev) => ({ ...prev, ...patch }))} + onSubmit={() => void handleCreatePatient()} + onClose={() => { + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + }} + loading={savingPatient} + /> + )} + +
+
+ +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx index 73a0171..e89a731 100644 --- a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx @@ -9,13 +9,13 @@ import { canViewStaff, } from '@/components/shared/permissions'; import { - STAFF_FEATURE_GROUPS, permissionNamesFromFeatureState, emptyFeaturePermissionState, featureStateFromPermissionNames, featureStateHasTreatmentEdit, resolveStaffFeatureLabel, formatAccessSummary, + staffFeatureGroupsForOrgType, type FeaturePermState, } from '@/components/staff/staff-permission-form'; import { @@ -112,7 +112,7 @@ function PermissionGrid({ return (
- {STAFF_FEATURE_GROUPS.map((g) => { + {staffFeatureGroupsForOrgType(organizationType).map((g) => { const cell = state[g.edit] ?? { read: false, edit: false }; return (
canEditStaff(currentOrganization), [currentOrganization]); const inviteHasTreatmentEdit = useMemo( - () => featureStateHasTreatmentEdit(invitePerms), - [invitePerms], + () => + currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(invitePerms), + [currentOrganization?.type, invitePerms], + ); + const editHasTreatmentEdit = useMemo( + () => currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(editPerms), + [currentOrganization?.type, editPerms], ); - const editHasTreatmentEdit = useMemo(() => featureStateHasTreatmentEdit(editPerms), [editPerms]); const hasActivePlan = Boolean(currentOrganization?.plan); const atSeatLimit = useMemo(() => { if (!seats || seats.unlimited) return false; @@ -309,7 +313,7 @@ export default function StaffPage() { setInviteStep(1); setInviteEmail(''); setInviteName(''); - setInvitePerms(emptyFeaturePermissionState()); + setInvitePerms(emptyFeaturePermissionState(currentOrganization?.type)); const defaults = createDefaultWorkingHoursState(); setInviteWorkingHoursDays(defaults.days); setInviteAutoRepeatWeekly(defaults.autoRepeatWeekly); @@ -393,7 +397,7 @@ export default function StaffPage() { setEditing(m); setEditStep(1); setEditName(m.name); - setEditPerms(featureStateFromPermissionNames(m.permissions ?? [])); + setEditPerms(featureStateFromPermissionNames(m.permissions ?? [], currentOrganization?.type)); setEditHoursValidationError(null); const defaults = createDefaultWorkingHoursState(); setEditWorkingHoursDays(defaults.days); diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx new file mode 100644 index 0000000..91c47bf --- /dev/null +++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx @@ -0,0 +1,402 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { MessageSquare } from 'lucide-react'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { Badge } from '@/components/ui/shared/Badge'; +import { Button } from '@/components/ui/shared/Button'; +import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; +import { SearchBar } from '@/components/ui/shared/SearchBar'; +import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; +import { + labTaskStatusSelectStyle, + labTaskStatusVariant, +} from '@/components/ui/lab/labTaskStatusDisplay'; +import { + formatToothList, + prosthesisTypeBadgeStyle, +} from '@/components/ui/treatment/prosthesisTypeDisplay'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +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 type { + LabTaskListItem, + LabTaskStatus, + ListLabTasksParams, + PaginatedLabTasks, + TaskSortField, +} from '@/types/cases'; + +const PAGE_SIZE = 50; + +function formatPatientName(patient: { firstName: string; lastName: string }) { + return `${patient.firstName} ${patient.lastName}`.trim(); +} + +export default function TasksPage() { + const t = useTranslations('tasks'); + const { currentOrganization, user, isAuthReady } = useAuth(); + const { showError, setError, messages: toastMessages } = useToast(); + + const [tasks, setTasks] = useState([]); + const [pagination, setPagination] = useState({ + page: 1, + limit: PAGE_SIZE, + total: 0, + totalPages: 1, + }); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [updatingTaskId, setUpdatingTaskId] = useState(null); + const [expandedCommentsTaskId, setExpandedCommentsTaskId] = useState(null); + + const [search, setSearch] = useState(''); + const [clinicId, setClinicId] = useState(''); + const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS'); + const [sentFrom, setSentFrom] = useState(''); + const [sentTo, setSentTo] = useState(''); + const [sortBy, setSortBy] = useState('date'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); + + const canView = canViewTasks(currentOrganization); + const canEdit = canEditTasks(currentOrganization); + const locale = user?.language ?? 'en'; + + const tRef = useRef(t); + tRef.current = t; + + const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( + () => [ + { value: 'IN_PROGRESS', label: t('statusInProgress') }, + { value: 'COMPLETED', label: t('statusCompleted') }, + ], + [t], + ); + + const listParams = useMemo((): ListLabTasksParams => { + const params: ListLabTasksParams = { + page, + limit: PAGE_SIZE, + sortBy, + sortDir, + }; + if (search.trim()) params.q = search.trim(); + if (clinicId) params.clinicOrganizationId = clinicId; + if (statusFilter) params.status = statusFilter; + if (sentFrom) params.sentFrom = sentFrom; + if (sentTo) params.sentTo = sentTo; + return params; + }, [page, search, clinicId, statusFilter, sentFrom, sentTo, sortBy, sortDir]); + + const clinicOptions = useMemo(() => { + const map = new Map(); + for (const task of tasks) { + map.set(task.clinic.id, task.clinic.name); + } + return [...map.entries()].map(([id, name]) => ({ id, name })); + }, [tasks]); + + const loadTasks = useCallback(async () => { + setLoading(true); + setError(''); + try { + const response = await tasksApi.list(listParams); + setTasks(response.data.items); + setPagination(response.data.pagination); + } catch (error: unknown) { + showError(formatApiErrorMessage(error, tRef.current('errorLoadList'))); + } finally { + setLoading(false); + } + }, [listParams, showError, setError]); + + useEffect(() => { + if (!canView) return; + const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0); + return () => clearTimeout(timeout); + }, [canView, loadTasks, search]); + + async function handleStatusUpdate(taskId: string, status: LabTaskStatus) { + if (!canEdit) return; + setUpdatingTaskId(taskId); + setError(''); + try { + await tasksApi.updateStatus(taskId, status); + await loadTasks(); + } catch (error: unknown) { + showError(formatApiErrorMessage(error, t('errorUpdateTask'))); + } finally { + setUpdatingTaskId(null); + } + } + + function formatTaskDate(value: string) { + return new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(new Date(value)); + } + + const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`; + + if (!isAuthReady) { + return
{t('loading')}
; + } + + if (!canView) { + return ( +
+

{t('noPermissionTitle')}

+

{t('noPermissionBody')}

+
+ ); + } + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+ { + setSearch(v); + setPage(1); + }} + placeholder={t('searchPlaceholder')} + /> +
+ + + +
+
+ +
+ {loading && tasks.length === 0 ? ( +

{t('loading')}

+ ) : tasks.length === 0 ? ( +

{t('emptyList')}

+ ) : ( +
    + {tasks.map((task, index) => { + const commentsOpen = expandedCommentsTaskId === task.id; + + return ( +
  • +
    +
    +
    +

    + {task.stepOrder}. {task.stepLabel} +

    + {task.isImportant ? ( + + {t('importantBadge')} + + ) : null} +
    +

    + {t('fromClinic', { name: task.clinic.name })} ·{' '} + {formatPatientName(task.patient)} ·{' '} + {t('teethLabel', { teeth: formatToothList(task.teeth) })} +

    +

    + {t('taskDate', { date: formatTaskDate(task.createdAt) })} + {task.lastStatusChangedBy ? ( + <> + · + + {t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })} + + + ) : null} +

    +
    + +
    + {canEdit ? ( + + ) : ( + + {statusOptions.find((opt) => opt.value === task.status)?.label ?? + task.status} + + )} +
    + +
    + {canEdit ? ( + + ) : null} + + {task.prosthesisTypeLabel} + +
    +
    + + {commentsOpen && canEdit ? ( +
    + { + const r = await tasksApi.listComments(task.labCaseId); + return r.data; + }} + onPost={async (body, visibleToClinic) => { + const r = await tasksApi.addComment(task.labCaseId, { + body, + visibleToClinic, + }); + return r.data; + }} + onToggleVisibility={async (commentId, visible) => { + const r = await tasksApi.setCommentVisibility(commentId, visible); + return r.data; + }} + onError={showError} + /> +
    + ) : null} +
  • + ); + })} +
+ )} +
+ + {pagination.totalPages > 1 && ( +
+

+ {t('pageSummary', { + page: pagination.page, + totalPages: pagination.totalPages, + total: pagination.total, + })} +

+
+ + +
+
+ )} + + +
+ ); +} diff --git a/frontend/src/components/shared/permissions.ts b/frontend/src/components/shared/permissions.ts index 210bf25..aa0f649 100644 --- a/frontend/src/components/shared/permissions.ts +++ b/frontend/src/components/shared/permissions.ts @@ -1,16 +1,35 @@ import type { Organization } from '@/types/organization'; -const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [ - { prefix: '/today', permission: 'TAB_TODAY_READ' }, - { prefix: '/staff', permission: 'TAB_STAFF_READ' }, - { prefix: '/organizations', permission: 'TAB_ORGANIZATIONS_READ' }, - { prefix: '/patients', permission: 'TAB_PATIENTS_READ' }, - { prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' }, - { prefix: '/treatment', permission: 'TAB_TREATMENT_READ' }, - { prefix: '/billing', permission: 'TAB_BILLING_READ' }, - { prefix: '/reports', permission: 'TAB_REPORTS_READ' }, +export type OrgTypeName = 'CLINIC' | 'LAB'; + +export type DashboardRouteConfig = { + prefix: string; + permission: string; + orgTypes: OrgTypeName[]; +}; + +export const DASHBOARD_ROUTES: DashboardRouteConfig[] = [ + { prefix: '/today', permission: 'TAB_TODAY_READ', orgTypes: ['CLINIC', 'LAB'] }, + { prefix: '/staff', permission: 'TAB_STAFF_READ', orgTypes: ['CLINIC', 'LAB'] }, + { prefix: '/organizations', permission: 'TAB_ORGANIZATIONS_READ', orgTypes: ['CLINIC', 'LAB'] }, + { prefix: '/patients', permission: 'TAB_PATIENTS_READ', orgTypes: ['CLINIC'] }, + { prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ', orgTypes: ['CLINIC'] }, + { prefix: '/treatment', permission: 'TAB_TREATMENT_READ', orgTypes: ['CLINIC'] }, + { prefix: '/cases', permission: 'TAB_CASES_READ', orgTypes: ['LAB'] }, + { prefix: '/tasks', permission: 'TAB_TASKS_READ', orgTypes: ['LAB'] }, + { prefix: '/billing', permission: 'TAB_BILLING_READ', orgTypes: ['CLINIC', 'LAB'] }, + { prefix: '/reports', permission: 'TAB_REPORTS_READ', orgTypes: ['CLINIC', 'LAB'] }, ]; +export function isRouteAllowedForOrgType(pathname: string, orgType: OrgTypeName | undefined): boolean { + if (!orgType) return false; + const route = DASHBOARD_ROUTES.find( + (r) => pathname === r.prefix || pathname.startsWith(`${r.prefix}/`), + ); + if (!route) return true; + return route.orgTypes.includes(orgType); +} + export function hasPermission(org: Organization | null, permission: string): boolean { if (!org) return false; return Boolean(org.permissions?.includes(permission)); @@ -26,21 +45,65 @@ export function canViewTab(org: Organization | null, readPermission: string): bo return hasPermission(org, readPermission); } -export function getRequiredReadPermissionForPath(pathname: string): string | null { - for (const { prefix, permission } of ROUTE_TAB_READ) { - if (pathname === prefix || pathname.startsWith(`${prefix}/`)) { - return permission; +export function getRouteConfigForPath(pathname: string): DashboardRouteConfig | null { + for (const route of DASHBOARD_ROUTES) { + if (pathname === route.prefix || pathname.startsWith(`${route.prefix}/`)) { + return route; } } return null; } +export function getRequiredReadPermissionForPath(pathname: string): string | null { + return getRouteConfigForPath(pathname)?.permission ?? null; +} + +export function canAccessDashboardRoute(org: Organization | null, pathname: string): boolean { + if (!org) return false; + + const route = getRouteConfigForPath(pathname); + if (!route) return true; + + if (!isRouteAllowedForOrgType(pathname, org.type)) { + return false; + } + + if (route.prefix === '/appointments') { + return canAccessAppointmentsSection(org); + } + + if (route.prefix === '/cases') { + return canViewCases(org); + } + + if (route.prefix === '/tasks') { + return canViewTasks(org); + } + + return hasPermission(org, route.permission); +} + /** First dashboard route the user may open (ordered). Fallback: account settings. */ export function firstAccessibleDashboardPath(org: Organization | null): string { if (!org) return '/today'; - for (const { prefix, permission } of ROUTE_TAB_READ) { - if (hasPermission(org, permission)) return prefix; + + for (const route of DASHBOARD_ROUTES) { + if (!route.orgTypes.includes(org.type)) continue; + if (route.prefix === '/appointments') { + if (canAccessAppointmentsSection(org)) return route.prefix; + continue; + } + if (route.prefix === '/cases') { + if (canViewCases(org)) return route.prefix; + continue; + } + if (route.prefix === '/tasks') { + if (canViewTasks(org)) return route.prefix; + continue; + } + if (hasPermission(org, route.permission)) return route.prefix; } + return '/settings/account'; } @@ -62,6 +125,9 @@ export function canEditAppointments(org: Organization | null): boolean { if (!org) { return false; } + if (org.type !== 'CLINIC') { + return false; + } if (org.isOwner) { return true; } @@ -76,6 +142,9 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean if (!org) { return false; } + if (org.type !== 'CLINIC') { + return false; + } if (org.isOwner) { return true; } @@ -90,6 +159,7 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean /** Treatment composer, scheduling columns, and saving clinical workflows */ export function canEditTreatment(org: Organization | null): boolean { if (!org) return false; + if (org.type !== 'CLINIC') return false; if (org.isOwner) return true; return hasPermission(org, 'TAB_TREATMENT_EDIT'); } @@ -97,9 +167,46 @@ export function canEditTreatment(org: Organization | null): boolean { /** View treatment workspace (read-only or edit) */ export function canViewTreatment(org: Organization | null): boolean { if (!org) return false; + if (org.type !== 'CLINIC') return false; if (org.isOwner) return true; return ( hasPermission(org, 'TAB_TREATMENT_READ') || hasPermission(org, 'TAB_TREATMENT_EDIT') ); } + +/** Lab cases inbox */ +export function canViewCases(org: Organization | null): boolean { + if (!org) return false; + if (org.type !== 'LAB') return false; + if (org.isOwner) return true; + return ( + hasPermission(org, 'TAB_CASES_READ') || + hasPermission(org, 'TAB_CASES_EDIT') + ); +} + +export function canEditCases(org: Organization | null): boolean { + if (!org) return false; + if (org.type !== 'LAB') return false; + if (org.isOwner) return true; + return hasPermission(org, 'TAB_CASES_EDIT'); +} + +/** Lab task inbox */ +export function canViewTasks(org: Organization | null): boolean { + if (!org) return false; + if (org.type !== 'LAB') return false; + if (org.isOwner) return true; + return ( + hasPermission(org, 'TAB_TASKS_READ') || + hasPermission(org, 'TAB_TASKS_EDIT') + ); +} + +export function canEditTasks(org: Organization | null): boolean { + if (!org) return false; + if (org.type !== 'LAB') return false; + if (org.isOwner) return true; + return hasPermission(org, 'TAB_TASKS_EDIT'); +} diff --git a/frontend/src/components/staff/staff-permission-form.ts b/frontend/src/components/staff/staff-permission-form.ts index a14ff02..5f4c04a 100644 --- a/frontend/src/components/staff/staff-permission-form.ts +++ b/frontend/src/components/staff/staff-permission-form.ts @@ -3,22 +3,33 @@ * Add presentational pieces under ./components/ as the UI grows. */ +import type { OrgTypeName } from '@/components/shared/permissions'; + export const STAFF_FEATURE_GROUPS = [ - { labelKey: 'featureToday', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, - { labelKey: 'featureStaff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, - { labelKey: 'featureOrganizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT' }, - { labelKey: 'featurePatients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, - { labelKey: 'featureAppointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, - { labelKey: 'featureTreatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' }, - { labelKey: 'featureBilling', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' }, - { labelKey: 'featureReports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' }, + { labelKey: 'featureToday', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT', orgTypes: ['CLINIC', 'LAB'] as const }, + { labelKey: 'featureStaff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT', orgTypes: ['CLINIC', 'LAB'] as const }, + { labelKey: 'featureOrganizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT', orgTypes: ['CLINIC', 'LAB'] as const }, + { labelKey: 'featurePatients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT', orgTypes: ['CLINIC'] as const }, + { labelKey: 'featureAppointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT', orgTypes: ['CLINIC'] as const }, + { labelKey: 'featureTreatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT', orgTypes: ['CLINIC'] as const }, + { labelKey: 'featureCases', read: 'TAB_CASES_READ', edit: 'TAB_CASES_EDIT', orgTypes: ['LAB'] as const }, + { labelKey: 'featureTasks', read: 'TAB_TASKS_READ', edit: 'TAB_TASKS_EDIT', orgTypes: ['LAB'] as const }, + { labelKey: 'featureBilling', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT', orgTypes: ['CLINIC', 'LAB'] as const }, + { labelKey: 'featureReports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT', orgTypes: ['CLINIC', 'LAB'] as const }, ] as const; export type FeaturePermState = Record; -export type OrgType = 'CLINIC' | 'LAB' | null | undefined; +export type OrgType = OrgTypeName | null | undefined; type StaffFeaturesTranslate = (key: string) => string; +export function staffFeatureGroupsForOrgType(organizationType: OrgType) { + if (!organizationType) return [...STAFF_FEATURE_GROUPS]; + return STAFF_FEATURE_GROUPS.filter((g) => + (g.orgTypes as readonly OrgTypeName[]).includes(organizationType), + ); +} + export function resolveStaffFeatureLabel( group: (typeof STAFF_FEATURE_GROUPS)[number], organizationType: OrgType, @@ -30,18 +41,21 @@ export function resolveStaffFeatureLabel( return t(group.labelKey); } -export function emptyFeaturePermissionState(): FeaturePermState { +export function emptyFeaturePermissionState(organizationType?: OrgType): FeaturePermState { const s: FeaturePermState = {}; - for (const g of STAFF_FEATURE_GROUPS) { + for (const g of staffFeatureGroupsForOrgType(organizationType)) { s[g.edit] = { read: false, edit: false }; } return s; } -export function featureStateFromPermissionNames(names: string[]): FeaturePermState { +export function featureStateFromPermissionNames( + names: string[], + organizationType?: OrgType, +): FeaturePermState { const set = new Set(names); - const s = emptyFeaturePermissionState(); - for (const g of STAFF_FEATURE_GROUPS) { + const s = emptyFeaturePermissionState(organizationType); + for (const g of staffFeatureGroupsForOrgType(organizationType)) { const hasEdit = set.has(g.edit); const hasRead = set.has(g.read) || hasEdit; s[g.edit] = { read: hasRead, edit: hasEdit }; @@ -73,7 +87,7 @@ export function formatAccessSummary( if (!permissionNames?.length) return t('noTabAccess'); const set = new Set(permissionNames); const parts: string[] = []; - for (const g of STAFF_FEATURE_GROUPS) { + for (const g of staffFeatureGroupsForOrgType(organizationType)) { const hasEdit = set.has(g.edit); const hasRead = set.has(g.read) || hasEdit; if (!hasRead) continue; diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index 0d38723..bd5a60f 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -6,8 +6,11 @@ import { Button } from '@/components/ui/shared/Button'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { Dropdown } from '@/components/ui/shared/Dropdown'; import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; -import { APPOINTMENT_PURPOSES } from '@/types/appointment'; -import { getPurposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import { + DROPDOWN_OPTION_BG, + treatmentTypeColor, +} from '@/components/ui/treatment/treatmentTypeDisplay'; import type { Patient } from '@/types/patient'; import { combineLocalDateAndTime, @@ -31,6 +34,7 @@ interface AppointmentBookingModalProps { endAt: string; purpose: AppointmentPurpose; }) => Promise; + treatmentCatalog: TreatmentCatalogEntry[]; editingAppointment?: AppointmentRecord | null; loading?: boolean; canDelete?: boolean; @@ -38,14 +42,6 @@ interface AppointmentBookingModalProps { deleting?: boolean; } -const PURPOSE_OPTION_COLORS: Record = { - consultation: '#ddd6fe', - filling: '#fed7aa', - endo: '#fecaca', - visit: '#bae6fd', - hygiene: '#d9f99d', -}; - export function AppointmentBookingModal({ open, scheduleDate, @@ -55,6 +51,7 @@ export function AppointmentBookingModal({ initialStartMinute, onClose, onSubmit, + treatmentCatalog, editingAppointment = null, loading = false, canDelete = false, @@ -65,11 +62,14 @@ export function AppointmentBookingModal({ const tCommon = useTranslations('common'); const tPatients = useTranslations('patients'); + const defaultPurpose = treatmentCatalog[0]?.code ?? ''; + const [startTime, setStartTime] = useState('09:00'); const [endTime, setEndTime] = useState('10:00'); - const [purpose, setPurpose] = useState('consultation'); + const [purpose, setPurpose] = useState(defaultPurpose); const [error, setError] = useState(''); - const purposeTextColor = PURPOSE_OPTION_COLORS[purpose]; + const purposeIndex = treatmentCatalog.findIndex((e) => e.code === purpose); + const purposeTextColor = treatmentTypeColor(purpose, purposeIndex < 0 ? 0 : purposeIndex); useEffect(() => { if (!open) { @@ -80,7 +80,7 @@ export function AppointmentBookingModal({ const end = new Date(editingAppointment.endAt); setStartTime(formatTimeForInput(start)); setEndTime(formatTimeForInput(end)); - setPurpose((editingAppointment.purpose as AppointmentPurpose) ?? 'consultation'); + setPurpose(editingAppointment.purpose || defaultPurpose); } else { const start = new Date( scheduleDate.getFullYear(), @@ -103,10 +103,10 @@ export function AppointmentBookingModal({ ); setStartTime(formatTimeForInput(start)); setEndTime(formatTimeForInput(end)); - setPurpose('consultation'); + setPurpose(defaultPurpose); } setError(''); - }, [open, scheduleDate, initialStartMinute, editingAppointment]); + }, [open, scheduleDate, initialStartMinute, editingAppointment, defaultPurpose]); if (!open || !providerUserId) { return null; @@ -224,16 +224,16 @@ export function AppointmentBookingModal({ setPurpose(e.target.value as AppointmentPurpose)} + onChange={(e) => setPurpose(e.target.value)} style={{ color: purposeTextColor }} > - {APPOINTMENT_PURPOSES.map((purposeOption) => ( + {treatmentCatalog.map((entry, index) => ( ))} diff --git a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx index 5d18b14..04b4efd 100644 --- a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx +++ b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx @@ -4,13 +4,15 @@ import { useEffect, useRef } from 'react'; import { useTranslations } from 'next-intl'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { - getPurposeLabel, - purposeStyle, + purposeBannerStyle, + purposeLabel, } from '@/components/ui/appointments/appointmentPurposeStyles'; -import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; +import type { AppointmentRecord } from '@/types/appointment'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; type AppointmentOverlapPopoverProps = { appointments: AppointmentRecord[]; + treatmentCatalog: TreatmentCatalogEntry[]; anchorRect: DOMRect; onSelect: (appointment: AppointmentRecord) => void; onClose: () => void; @@ -25,6 +27,7 @@ function formatTimeRange(apt: AppointmentRecord): string { export function AppointmentOverlapPopover({ appointments, + treatmentCatalog, anchorRect, onSelect, onClose, @@ -82,29 +85,27 @@ export function AppointmentOverlapPopover({
    - {sorted.map((apt) => { - const purpose = apt.purpose as AppointmentPurpose; - return ( -
  • - -
  • - ); - })} + {sorted.map((apt) => ( +
  • + +
  • + ))}
diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index 2f90b9d..5cabee7 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -18,9 +18,11 @@ import { findOverlapCluster, lanePositionStyles, } from '@/components/appointments/appointmentOverlapLayout'; -import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { purposeBannerStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover'; +import { formatMobileForDisplay } from '@/lib/phone'; import { startOfLocalDay } from '@/components/appointments/appointmentTime'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; const HOUR_PX = 80; const SLOT_PX = (HOUR_PX * SCHEDULE_SLOT_MINUTES) / 60; @@ -77,6 +79,7 @@ interface AppointmentScheduleGridProps { day: Date; providers: AppointmentColumnProvider[]; appointments: AppointmentRecord[]; + treatmentCatalog: TreatmentCatalogEntry[]; canBook: boolean; onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void; onAppointmentClick?: (appointment: AppointmentRecord) => void; @@ -87,6 +90,7 @@ export function AppointmentScheduleGrid({ day, providers, appointments, + treatmentCatalog, canBook, onSlotClick, onAppointmentClick, @@ -300,7 +304,9 @@ export function AppointmentScheduleGrid({ clusterSize > 1 ? t('overlappingChoose', { count: clusterSize }) : null, - !isUnderOneHour && apt.patient.phone ? apt.patient.phone : null, + !isUnderOneHour && apt.patient.mobile + ? formatMobileForDisplay(apt.patient.mobile) + : null, ] .filter(Boolean) .join(' · '); @@ -317,7 +323,7 @@ export function AppointmentScheduleGrid({ e.currentTarget, ) } - className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-left ${purposeStyle(apt.purpose)} focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${ + className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${ outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : '' } ${ isUnderOneHour @@ -329,6 +335,7 @@ export function AppointmentScheduleGrid({ height: pos.height, left: lanePos.left, width: lanePos.width, + ...purposeBannerStyle(apt.purpose, treatmentCatalog), }} title={bannerTitle} > @@ -338,10 +345,10 @@ export function AppointmentScheduleGrid({ {patientName} {!isUnderOneHour && - apt.patient.phone && + apt.patient.mobile && lane.laneCount === 1 && ( - {apt.patient.phone} + {formatMobileForDisplay(apt.patient.mobile)} )} {!isUnderOneHour && clusterSize > 1 && ( @@ -363,6 +370,7 @@ export function AppointmentScheduleGrid({ {overlapPopover && ( { const provider = providers.find((p) => p.userId === apt.providerUserId); diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx index 8ab970f..041a2e6 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx @@ -1,25 +1,33 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { - APPOINTMENT_PURPOSE_LEGEND_SWATCH, - getPurposeLabel, -} from '@/components/ui/appointments/appointmentPurposeStyles'; -import { APPOINTMENT_PURPOSES } from '@/types/appointment'; +import { purposeSwatchStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; -export function AppointmentScheduleLegend() { +interface AppointmentScheduleLegendProps { + treatmentCatalog: TreatmentCatalogEntry[]; +} + +export function AppointmentScheduleLegend({ + treatmentCatalog, +}: AppointmentScheduleLegendProps) { const t = useTranslations('appointments'); + if (treatmentCatalog.length === 0) { + return null; + } + return (

{t('legend')}

- {APPOINTMENT_PURPOSES.map((p) => ( -
+ {treatmentCatalog.map((entry) => ( +
- {getPurposeLabel(p, t)} + {entry.label}
))}
diff --git a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx index 5dce8a1..a238353 100644 --- a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx +++ b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx @@ -4,6 +4,7 @@ import { Search } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; +import { formatMobileForDisplay } from '@/lib/phone'; import type { Patient } from '@/types/patient'; interface AppointmentsPatientSearchProps { @@ -82,7 +83,7 @@ export function AppointmentsPatientSearch({ {patient.firstName} {patient.lastName}

- {patient.phone || patient.email || tPatients('noContact')} + {formatMobileForDisplay(patient.mobile) || patient.email || tPatients('noContact')}

); diff --git a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts index 813befa..71109e5 100644 --- a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts +++ b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts @@ -1,46 +1,39 @@ -import type { AppointmentPurpose } from '@/types/appointment'; +import type { CSSProperties } from 'react'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import { + treatmentTypeBannerStyle, + treatmentTypeLabelFromCatalog, + treatmentTypeSwatchStyle, +} from '@/components/ui/treatment/treatmentTypeDisplay'; -export const APPOINTMENT_PURPOSE_LABEL_KEYS = { - consultation: 'purposeConsultation', - filling: 'purposeFilling', - endo: 'purposeEndo', - visit: 'purposeVisit', - hygiene: 'purposeHygiene', -} as const satisfies Record; +/** + * Appointment purposes are treatment-type codes. Labels and colors now come from + * the shared treatment catalog + palette so the appointment and treatment + * features stay in sync. These helpers adapt the shared palette to the appointment + * components' call sites. + */ -export type AppointmentPurposeLabelKey = - (typeof APPOINTMENT_PURPOSE_LABEL_KEYS)[AppointmentPurpose]; - -export type AppointmentPurposeTranslate = (key: AppointmentPurposeLabelKey) => string; - -export function getPurposeLabel( - purpose: AppointmentPurpose, - t: AppointmentPurposeTranslate, +export function purposeLabel( + purpose: string, + catalog: TreatmentCatalogEntry[], ): string { - const key = APPOINTMENT_PURPOSE_LABEL_KEYS[purpose]; - return key ? t(key) : purpose; + return treatmentTypeLabelFromCatalog(purpose, catalog); } -/** Background + border for blocks / legend (matches reference palette). */ -export const APPOINTMENT_PURPOSE_STYLES: Record = { - consultation: - 'bg-purpose-consultation-bg border-purpose-consultation-border text-purpose-consultation-fg', - filling: 'bg-purpose-filling-bg border-purpose-filling-border text-purpose-filling-fg', - endo: 'bg-purpose-endo-bg border-purpose-endo-border text-purpose-endo-fg', - visit: 'bg-purpose-visit-bg border-purpose-visit-border text-purpose-visit-fg', - hygiene: 'bg-purpose-hygiene-bg border-purpose-hygiene-border text-purpose-hygiene-fg', -}; - -export function purposeStyle(purpose: string): string { - const p = purpose as AppointmentPurpose; - return APPOINTMENT_PURPOSE_STYLES[p] ?? 'bg-surface-elevated border-border text-text-secondary'; +/** Inline style for a colored appointment banner/block. */ +export function purposeBannerStyle( + purpose: string, + catalog: TreatmentCatalogEntry[], +): CSSProperties { + const index = catalog.findIndex((e) => e.code === purpose); + return treatmentTypeBannerStyle(purpose, index); } -/** Small swatch for legend (background + border only). */ -export const APPOINTMENT_PURPOSE_LEGEND_SWATCH: Record = { - consultation: 'bg-violet-500/85 border-violet-400/75', - filling: 'bg-orange-500/85 border-orange-400/75', - endo: 'bg-red-500/85 border-red-400/75', - visit: 'bg-sky-500/85 border-sky-400/75', - hygiene: 'bg-lime-500/80 border-lime-400/70', -}; +/** Inline style for a small legend swatch. */ +export function purposeSwatchStyle( + purpose: string, + catalog: TreatmentCatalogEntry[], +): CSSProperties { + const index = catalog.findIndex((e) => e.code === purpose); + return treatmentTypeSwatchStyle(purpose, index); +} diff --git a/frontend/src/components/ui/lab/CaseDetailPanel.tsx b/frontend/src/components/ui/lab/CaseDetailPanel.tsx new file mode 100644 index 0000000..752b2bd --- /dev/null +++ b/frontend/src/components/ui/lab/CaseDetailPanel.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { useMemo, useState, type ReactNode } from 'react'; +import { useTranslations } from 'next-intl'; +import { MessageSquare } from 'lucide-react'; +import { Badge } from '@/components/ui/shared/Badge'; +import { Button } from '@/components/ui/shared/Button'; +import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel'; +import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview'; +import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog'; +import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay'; +import { + formatToothList, + prosthesisTypeBadgeStyle, +} from '@/components/ui/treatment/prosthesisTypeDisplay'; +import { + buildCaseProsthesisRows, + formatCaseDateTime, + formatPatientName, + latestCaseAttachment, +} from '@/components/ui/lab/caseDetailUtils'; +import type { LabCaseDetail, LabTaskStatus } from '@/types/cases'; + +function CaseTaskProgressBar({ completed, total }: { completed: number; total: number }) { + const pct = total > 0 ? Math.round((completed / total) * 100) : 0; + + return ( +
+
+ + {completed}/{total} + + {pct}% +
+
+
+
+
+ ); +} + +export interface CaseDetailPanelProps { + labCase: LabCaseDetail; + locale: string; + treatmentLabel: (type: string) => string; + statusOptions: { value: LabTaskStatus; label: string }[]; + loadAttachmentBlob: (caseId: string, attachmentId: string) => Promise; + /** Extra lines below patient mobile (e.g. connection-specific clinic/lab line). */ + headerMetaLines?: ReactNode; + showCommentsButton?: boolean; + commentCount?: number; + onCommentsClick?: () => void; + canEditImportant?: boolean; + updatingImportant?: boolean; + onImportantChange?: (checked: boolean) => void; + commentsSection?: ReactNode; +} + +export function CaseDetailPanel({ + labCase, + locale, + treatmentLabel, + statusOptions, + loadAttachmentBlob, + headerMetaLines, + showCommentsButton = false, + commentCount = 0, + onCommentsClick, + canEditImportant = false, + updatingImportant = false, + onImportantChange, + commentsSection, +}: CaseDetailPanelProps) { + const t = useTranslations('cases'); + const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false); + + const prosthesisRows = useMemo(() => buildCaseProsthesisRows(labCase), [labCase]); + const previewAttachment = useMemo(() => latestCaseAttachment(labCase), [labCase]); + + return ( +
+
+
+

+ {formatPatientName(labCase.patient)} +

+ {!canEditImportant && labCase.isImportant ? ( + + {t('importantLabel')} + + ) : null} +

+ {t('patientMobile')}: {labCase.patient.mobile} +

+ {headerMetaLines} +

+ {t('sentAt', { date: formatCaseDateTime(labCase.sentAt, locale) })} +

+
+

+ {t('taskProgressLabel', { + completed: labCase.taskProgress.completed, + total: labCase.taskProgress.total, + })} +

+ +
+
+ +
+ {showCommentsButton && onCommentsClick ? ( + + ) : null} + {canEditImportant ? ( + onImportantChange?.(checked)} + /> + ) : null} + {previewAttachment && labCase.attachments.length > 0 ? ( + + ) : null} +
+
+ + + + {labCase.detail ? ( +
+

{t('treatmentDetails')}

+
+
{treatmentLabel(labCase.detail.treatmentType)}
+
+ {t('teethLabel')}: {labCase.detail.teeth.join(', ') || '—'} +
+ {labCase.detail.comment ? ( +
{labCase.detail.comment}
+ ) : null} +
+
+ ) : null} + +
+

{t('tasksByTooth')}

+ {labCase.tasksByTooth.length === 0 ? ( +

{t('noTasks')}

+ ) : ( + labCase.tasksByTooth.map((group, groupIndex) => ( +
+
+ + {group.prosthesisTypeLabel} + + + {t('toothGroupTitle', { + teeth: formatToothList(group.teeth), + prosthesis: group.prosthesisTypeLabel, + })} + +
+
    + {group.tasks.map((task) => ( +
  • +
    + + {task.stepOrder}. {task.stepLabel} + + + {statusOptions.find((opt) => opt.value === task.status)?.label ?? + task.status} + +
    +

    + {task.lastStatusChangedBy + ? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name }) + : t('lastUpdatedUnknown')} + {task.lastStatusChangedAt + ? ` · ${formatCaseDateTime(task.lastStatusChangedAt, locale)}` + : ''} +

    +
  • + ))} +
+
+ )) + )} +
+ + {commentsSection} + + setAttachmentsDialogOpen(false)} + caseId={labCase.id} + attachments={labCase.attachments} + loadBlob={loadAttachmentBlob} + /> +
+ ); +} + +export { CaseTaskProgressBar }; diff --git a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx new file mode 100644 index 0000000..acc4513 --- /dev/null +++ b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useMemo } from 'react'; +import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; +import { prosthesisTypeColor } from '@/components/ui/treatment/prosthesisTypeDisplay'; +import type { FdiToothId } from '@/types/treatment'; + +export interface CaseToothChartDetail { + teeth: string[]; +} + +export interface CaseToothChartProsthesisRow { + teeth: string[]; + prosthesisTypeCode: string; +} + +interface CaseToothChartPanelProps { + details: CaseToothChartDetail[]; + /** Prosthesis mapping from case tasks or toothProsthesis rows. */ + prosthesisRows: CaseToothChartProsthesisRow[]; + scale?: number; + compact?: boolean; + className?: string; +} + +/** Read-only FDI chart for lab case detail — prosthesis-type glow on selected teeth. */ +export function CaseToothChartPanel({ + details, + prosthesisRows, + scale = 1, + compact = true, + className = '', +}: CaseToothChartPanelProps) { + const selected = useMemo(() => { + const set = new Set(); + for (const detail of details) { + for (const tooth of detail.teeth) set.add(tooth as FdiToothId); + } + return set; + }, [details]); + + const toothColors = useMemo(() => { + const colors: Partial> = {}; + prosthesisRows.forEach((row, index) => { + const color = prosthesisTypeColor(row.prosthesisTypeCode, index); + for (const tooth of row.teeth) { + colors[tooth as FdiToothId] = color; + } + }); + return colors; + }, [prosthesisRows]); + + if (selected.size === 0) return null; + + return ( + + ); +} diff --git a/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx b/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx new file mode 100644 index 0000000..1fcc5b3 --- /dev/null +++ b/frontend/src/components/ui/lab/LabCaseAttachmentPreview.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { FileText } from 'lucide-react'; +import type { LabCaseAttachmentMeta } from '@/types/cases'; + +interface LabCaseAttachmentPreviewProps { + caseId: string; + attachment: LabCaseAttachmentMeta; + loadBlob: (caseId: string, attachmentId: string) => Promise; + className?: string; +} + +export function LabCaseAttachmentPreview({ + caseId, + attachment, + loadBlob, + className = 'h-full w-full', +}: LabCaseAttachmentPreviewProps) { + const [url, setUrl] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + let objectUrl: string | null = null; + + void (async () => { + try { + const blob = await loadBlob(caseId, attachment.id); + if (cancelled) return; + objectUrl = URL.createObjectURL(blob); + setUrl(objectUrl); + setFailed(false); + } catch { + if (!cancelled) setFailed(true); + } + })(); + + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [caseId, attachment.id, loadBlob]); + + const isImage = attachment.mimeType.startsWith('image/'); + const isPdf = attachment.mimeType === 'application/pdf'; + + return ( +
+ {url && isImage ? ( + {attachment.fileName} + ) : url && isPdf ? ( +