TreatmentType and ProsthesisType database shcema and data updated. Lab dispatch wired through new data.
This commit is contained in:
@@ -21,7 +21,8 @@
|
|||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:deploy": "prisma migrate deploy",
|
"prisma:deploy": "prisma migrate deploy",
|
||||||
"prisma:seed": "prisma db seed"
|
"prisma:seed": "prisma db seed",
|
||||||
|
"prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "ts-node prisma/seed.ts"
|
"seed": "ts-node prisma/seed.ts"
|
||||||
|
|||||||
276
backend/prisma/catalog-seed-data.ts
Normal file
276
backend/prisma/catalog-seed-data.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { CatalogEntityKind } from '@prisma/client';
|
||||||
|
|
||||||
|
export type CatalogTranslationSeed = {
|
||||||
|
entityKind: CatalogEntityKind;
|
||||||
|
entityCode: string;
|
||||||
|
locale: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TREATMENT_TYPES = [
|
||||||
|
{ code: 'restoration', labDependent: false, sortOrder: 1 },
|
||||||
|
{ code: 'specialized_restoration', labDependent: false, sortOrder: 2 },
|
||||||
|
{ code: 'radiography', labDependent: false, sortOrder: 3 },
|
||||||
|
{ code: 'endo', labDependent: false, sortOrder: 4 },
|
||||||
|
{ code: 'surgery', labDependent: false, sortOrder: 5 },
|
||||||
|
{ code: 'prosthesis', labDependent: true, sortOrder: 6 },
|
||||||
|
{ code: 'implant', labDependent: false, sortOrder: 7 },
|
||||||
|
{ code: 'orthodontics', labDependent: false, sortOrder: 8 },
|
||||||
|
{ code: 'perio', labDependent: false, sortOrder: 9 },
|
||||||
|
{ code: 'pediatrics', labDependent: false, sortOrder: 10 },
|
||||||
|
{ code: 'extraction', labDependent: false, sortOrder: 11 },
|
||||||
|
{ code: 'clinic_visit', labDependent: false, sortOrder: 12 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Legacy codes kept for historical rows; hidden from catalog. */
|
||||||
|
export const LEGACY_TREATMENT_TYPES = [
|
||||||
|
{ code: 'consultation', labDependent: false, sortOrder: 99 },
|
||||||
|
{ code: 'filling', labDependent: false, sortOrder: 100 },
|
||||||
|
{ code: 'visit', labDependent: false, sortOrder: 101 },
|
||||||
|
{ code: 'hygiene', labDependent: false, sortOrder: 102 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const LAB_WORKFLOW_STEPS = [
|
||||||
|
{ code: 'intraoral_scan', sortOrder: 1 },
|
||||||
|
{ code: 'design', sortOrder: 2 },
|
||||||
|
{ code: 'milling_dry', sortOrder: 3 },
|
||||||
|
{ code: 'milling_wet', sortOrder: 4 },
|
||||||
|
{ code: 'printer_resin', sortOrder: 5 },
|
||||||
|
{ code: 'printer_metal', sortOrder: 6 },
|
||||||
|
{ code: 'sinter', sortOrder: 7 },
|
||||||
|
{ code: 'build_up', sortOrder: 8 },
|
||||||
|
{ code: 'stain', sortOrder: 9 },
|
||||||
|
{ code: 'glaze', sortOrder: 10 },
|
||||||
|
{ code: 'polish_prep', sortOrder: 11 },
|
||||||
|
{ code: 'packing', sortOrder: 12 },
|
||||||
|
{ code: 'shipping', sortOrder: 13 },
|
||||||
|
{ code: 'pressing', sortOrder: 14 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ProsthesisTypeSeed = {
|
||||||
|
code: string;
|
||||||
|
sortOrder: number;
|
||||||
|
skipPackingShipping?: boolean;
|
||||||
|
/** Manufacturing steps between design and packing (exclusive of universal scan/design/pack/ship). */
|
||||||
|
manufacturingSteps: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||||
|
{
|
||||||
|
code: 'pfm_crown',
|
||||||
|
sortOrder: 1,
|
||||||
|
manufacturingSteps: ['milling_wet', 'build_up', 'stain', 'glaze', 'polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'pfz_crown',
|
||||||
|
sortOrder: 2,
|
||||||
|
manufacturingSteps: ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'monolithic_zirconia',
|
||||||
|
sortOrder: 3,
|
||||||
|
manufacturingSteps: ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'glass_ceramic_crown',
|
||||||
|
sortOrder: 4,
|
||||||
|
manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'full_metal_crown',
|
||||||
|
sortOrder: 5,
|
||||||
|
manufacturingSteps: ['milling_wet', 'polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'temporary_resin_crown',
|
||||||
|
sortOrder: 6,
|
||||||
|
manufacturingSteps: ['milling_wet', 'polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'pmma',
|
||||||
|
sortOrder: 7,
|
||||||
|
manufacturingSteps: ['milling_dry', 'polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'peek_crown',
|
||||||
|
sortOrder: 8,
|
||||||
|
manufacturingSteps: ['milling_dry', 'polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'veneer_zirconia',
|
||||||
|
sortOrder: 9,
|
||||||
|
manufacturingSteps: ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'veneer_ips_press',
|
||||||
|
sortOrder: 10,
|
||||||
|
manufacturingSteps: [
|
||||||
|
'printer_resin',
|
||||||
|
'build_up',
|
||||||
|
'stain',
|
||||||
|
'glaze',
|
||||||
|
'polish_prep',
|
||||||
|
'pressing',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'veneer_ips_cad',
|
||||||
|
sortOrder: 11,
|
||||||
|
manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'soft_structure',
|
||||||
|
sortOrder: 12,
|
||||||
|
manufacturingSteps: ['milling_dry', 'sinter'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'customized_abutment',
|
||||||
|
sortOrder: 13,
|
||||||
|
manufacturingSteps: ['milling_wet', 'polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'prefabricated_abutment',
|
||||||
|
sortOrder: 14,
|
||||||
|
manufacturingSteps: ['polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ti_base_abutment',
|
||||||
|
sortOrder: 15,
|
||||||
|
manufacturingSteps: ['polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'multi_unit_abutment',
|
||||||
|
sortOrder: 16,
|
||||||
|
manufacturingSteps: ['polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'zirconia_abutment',
|
||||||
|
sortOrder: 17,
|
||||||
|
manufacturingSteps: ['milling_dry', 'sinter', 'polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'screw_retained',
|
||||||
|
sortOrder: 18,
|
||||||
|
manufacturingSteps: [
|
||||||
|
'milling_wet',
|
||||||
|
'printer_metal',
|
||||||
|
'sinter',
|
||||||
|
'build_up',
|
||||||
|
'stain',
|
||||||
|
'glaze',
|
||||||
|
'polish_prep',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'zirconia_overlay',
|
||||||
|
sortOrder: 19,
|
||||||
|
manufacturingSteps: ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ips_overlay',
|
||||||
|
sortOrder: 20,
|
||||||
|
manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'smile_design',
|
||||||
|
sortOrder: 21,
|
||||||
|
skipPackingShipping: true,
|
||||||
|
manufacturingSteps: ['printer_resin'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'mockup',
|
||||||
|
sortOrder: 22,
|
||||||
|
manufacturingSteps: ['printer_resin'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const UNIVERSAL_PREFIX = ['intraoral_scan', 'design'] as const;
|
||||||
|
const UNIVERSAL_SUFFIX = ['packing', 'shipping'] as const;
|
||||||
|
|
||||||
|
export function buildProsthesisStepCodes(type: ProsthesisTypeSeed): string[] {
|
||||||
|
const steps = [...UNIVERSAL_PREFIX, ...type.manufacturingSteps];
|
||||||
|
if (!type.skipPackingShipping) {
|
||||||
|
steps.push(...UNIVERSAL_SUFFIX);
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TREATMENT_LABELS: Record<string, Record<string, string>> = {
|
||||||
|
restoration: { en: 'Restoration', fa: 'ترمیم', nl: 'Restauratie' },
|
||||||
|
specialized_restoration: { en: 'Specialized Restoration', fa: 'ترمیم تخصصی', nl: 'Gespecialiseerde Restauratie' },
|
||||||
|
radiography: { en: 'Radiography', fa: 'رادیوگرافی', nl: 'Röntgen' },
|
||||||
|
endo: { en: 'Endo', fa: 'اندو', nl: 'Endo' },
|
||||||
|
surgery: { en: 'Surgery', fa: 'جراحی', nl: 'Chirurgie' },
|
||||||
|
prosthesis: { en: 'Prosthesis', fa: 'پروتز', nl: 'Prothese' },
|
||||||
|
implant: { en: 'Implant', fa: 'ایمپلنت', nl: 'Implantaat' },
|
||||||
|
orthodontics: { en: 'Orthodontics', fa: 'ارتودنسی', nl: 'Orthodontie' },
|
||||||
|
perio: { en: 'Perio', fa: 'پریو', nl: 'Paro' },
|
||||||
|
pediatrics: { en: 'Pediatrics', fa: 'اطفال', nl: 'Kinderen' },
|
||||||
|
extraction: { en: 'Extraction', fa: 'کشیدن', nl: 'Extractie' },
|
||||||
|
clinic_visit: { en: 'Clinic Visit', fa: 'درمانگاه', nl: 'Kliniekbezoek' },
|
||||||
|
consultation: { en: 'Consultation', fa: 'مشاوره', nl: 'Consult' },
|
||||||
|
filling: { en: 'Filling', fa: 'پر کردن', nl: 'Vulling' },
|
||||||
|
visit: { en: 'Visit', fa: 'ویزیت', nl: 'Bezoek' },
|
||||||
|
hygiene: { en: 'Hygiene', fa: 'بهداشت', nl: 'Hygiëne' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROSTHESIS_LABELS: Record<string, Record<string, string>> = {
|
||||||
|
pfm_crown: { en: 'PFM Crown', fa: 'روکش PFM', nl: 'PFM Kroon' },
|
||||||
|
pfz_crown: { en: 'PFZ Crown', fa: 'روکش PFZ', nl: 'PFZ Kroon' },
|
||||||
|
monolithic_zirconia: { en: 'Monolithic Zirconia', fa: 'زیرکونیا مونولیتیک', nl: 'Monolithisch Zirconia' },
|
||||||
|
glass_ceramic_crown: { en: 'Glass Ceramic Crown', fa: 'روکش سرامیک شیشهای', nl: 'Glaskeramische Kroon' },
|
||||||
|
full_metal_crown: { en: 'Full Metal Crown', fa: 'روکش تمام فلز', nl: 'Volledige Metalen Kroon' },
|
||||||
|
temporary_resin_crown: { en: 'Temporary Resin Crown', fa: 'روکش موقت رزینی', nl: 'Tijdelijke Harskroon' },
|
||||||
|
pmma: { en: 'PMMA', fa: 'PMMA', nl: 'PMMA' },
|
||||||
|
peek_crown: { en: 'PEEK Crown', fa: 'روکش PEEK', nl: 'PEEK Kroon' },
|
||||||
|
veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' },
|
||||||
|
veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' },
|
||||||
|
veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' },
|
||||||
|
soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' },
|
||||||
|
customized_abutment: { en: 'Customized Abutment', fa: 'اباتمنت سفارشی', nl: 'Aangepast Abutment' },
|
||||||
|
prefabricated_abutment: { en: 'Prefabricated Abutment', fa: 'اباتمنت آماده', nl: 'Prefab Abutment' },
|
||||||
|
ti_base_abutment: { en: 'Ti Base Abutment', fa: 'اباتمنت پایه تیتانیوم', nl: 'Ti Basis Abutment' },
|
||||||
|
multi_unit_abutment: { en: 'Multi Unit Abutment', fa: 'اباتمنت مولتی یونیت', nl: 'Multi Unit Abutment' },
|
||||||
|
zirconia_abutment: { en: 'Zirconia Abutment', fa: 'اباتمنت زیرکونیا', nl: 'Zirconia Abutment' },
|
||||||
|
screw_retained: { en: 'Screw Retained', fa: 'پیچی', nl: 'Schroefgehouden' },
|
||||||
|
zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' },
|
||||||
|
ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' },
|
||||||
|
smile_design: { en: 'Smile Design', fa: 'طراحی لبخند', nl: 'Smile Design' },
|
||||||
|
mockup: { en: 'Mockup', fa: 'ماکاپ', nl: 'Mockup' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
|
||||||
|
intraoral_scan: { en: 'Intraoral Scan', fa: 'اسکن داخل دهان', nl: 'Intraorale Scan' },
|
||||||
|
design: { en: 'Design', fa: 'طراحی', nl: 'Ontwerp' },
|
||||||
|
milling_dry: { en: 'Milling Dry', fa: 'فرز خشک', nl: 'Droog Frezen' },
|
||||||
|
milling_wet: { en: 'Milling Wet', fa: 'فرز تر', nl: 'Nat Frezen' },
|
||||||
|
printer_resin: { en: 'Printer Resin', fa: 'پرینتر رزین', nl: 'Harsprinter' },
|
||||||
|
printer_metal: { en: 'Printer Metal', fa: 'پرینتر فلز', nl: 'Metaalprinter' },
|
||||||
|
sinter: { en: 'Sinter', fa: 'سینتر', nl: 'Sinteren' },
|
||||||
|
build_up: { en: 'Build Up', fa: 'بیلدآپ', nl: 'Opbouw' },
|
||||||
|
stain: { en: 'Stain', fa: 'رنگآمیزی', nl: 'Kleuren' },
|
||||||
|
glaze: { en: 'Glaze', fa: 'گلیز', nl: 'Glazuur' },
|
||||||
|
polish_prep: { en: 'Polish/Prep', fa: 'پولیش/آمادهسازی', nl: 'Polijsten/Voorbereiding' },
|
||||||
|
packing: { en: 'Packing', fa: 'بستهبندی', nl: 'Verpakken' },
|
||||||
|
shipping: { en: 'Shipping', fa: 'ارسال', nl: 'Verzending' },
|
||||||
|
pressing: { en: 'Pressing', fa: 'پرس', nl: 'Persen' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function labelsToTranslations(
|
||||||
|
entityKind: CatalogEntityKind,
|
||||||
|
labels: Record<string, Record<string, string>>,
|
||||||
|
): CatalogTranslationSeed[] {
|
||||||
|
const out: CatalogTranslationSeed[] = [];
|
||||||
|
for (const [entityCode, locales] of Object.entries(labels)) {
|
||||||
|
for (const [locale, label] of Object.entries(locales)) {
|
||||||
|
out.push({ entityKind, entityCode, locale, label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATALOG_TRANSLATIONS: CatalogTranslationSeed[] = [
|
||||||
|
...labelsToTranslations(CatalogEntityKind.TREATMENT_TYPE, TREATMENT_LABELS),
|
||||||
|
...labelsToTranslations(CatalogEntityKind.PROSTHESIS_TYPE, PROSTHESIS_LABELS),
|
||||||
|
...labelsToTranslations(CatalogEntityKind.LAB_WORKFLOW_STEP, WORKFLOW_STEP_LABELS),
|
||||||
|
];
|
||||||
@@ -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");
|
||||||
41
backend/prisma/reset-treatment-data.ts
Normal file
41
backend/prisma/reset-treatment-data.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Dev-only: truncate treatment and lab case data (preserves catalog tables).
|
||||||
|
* Usage: npx ts-node prisma/reset-treatment-data.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const envPath = path.join(__dirname, '..', '.env');
|
||||||
|
config({ path: envPath });
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
console.error('reset-treatment-data is not allowed in production');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Truncating treatment and lab case data...');
|
||||||
|
|
||||||
|
await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_tasks" CASCADE');
|
||||||
|
await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_sends" CASCADE');
|
||||||
|
await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_tooth_prosthesis" CASCADE');
|
||||||
|
await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_case_details" CASCADE');
|
||||||
|
await prisma.$executeRawUnsafe('TRUNCATE TABLE "lab_cases" CASCADE');
|
||||||
|
await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatment_detail_attachments" CASCADE');
|
||||||
|
await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatment_details" CASCADE');
|
||||||
|
await prisma.$executeRawUnsafe('TRUNCATE TABLE "treatments" CASCADE');
|
||||||
|
|
||||||
|
console.log('Done.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -155,6 +155,7 @@ model TreatmentDetail {
|
|||||||
attachments TreatmentDetailAttachment[]
|
attachments TreatmentDetailAttachment[]
|
||||||
labCaseLink LabCaseDetail?
|
labCaseLink LabCaseDetail?
|
||||||
labCaseTasks LabCaseTask[]
|
labCaseTasks LabCaseTask[]
|
||||||
|
toothProsthesis LabCaseToothProsthesis[]
|
||||||
|
|
||||||
@@index([treatmentId, sortOrder])
|
@@index([treatmentId, sortOrder])
|
||||||
@@map("treatment_details")
|
@@map("treatment_details")
|
||||||
@@ -192,6 +193,7 @@ model LabCase {
|
|||||||
details LabCaseDetail[]
|
details LabCaseDetail[]
|
||||||
sends LabCaseSend[]
|
sends LabCaseSend[]
|
||||||
tasks LabCaseTask[]
|
tasks LabCaseTask[]
|
||||||
|
toothProsthesis LabCaseToothProsthesis[]
|
||||||
|
|
||||||
@@index([treatmentId, sortOrder])
|
@@index([treatmentId, sortOrder])
|
||||||
@@map("lab_cases")
|
@@map("lab_cases")
|
||||||
@@ -226,22 +228,76 @@ model TreatmentType {
|
|||||||
code String @unique
|
code String @unique
|
||||||
labDependent Boolean @default(false)
|
labDependent Boolean @default(false)
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
|
isActive Boolean @default(true)
|
||||||
workflowSteps TreatmentWorkflowStep[]
|
|
||||||
|
|
||||||
@@map("treatment_types")
|
@@map("treatment_types")
|
||||||
}
|
}
|
||||||
|
|
||||||
model TreatmentWorkflowStep {
|
enum CatalogEntityKind {
|
||||||
|
TREATMENT_TYPE
|
||||||
|
PROSTHESIS_TYPE
|
||||||
|
LAB_WORKFLOW_STEP
|
||||||
|
}
|
||||||
|
|
||||||
|
model CatalogTranslation {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
treatmentTypeId String
|
entityKind CatalogEntityKind
|
||||||
stepOrder Int
|
entityCode String
|
||||||
|
locale String
|
||||||
label String
|
label String
|
||||||
|
|
||||||
treatmentType TreatmentType @relation(fields: [treatmentTypeId], references: [id], onDelete: Cascade)
|
@@unique([entityKind, entityCode, locale])
|
||||||
|
@@map("catalog_translations")
|
||||||
|
}
|
||||||
|
|
||||||
@@unique([treatmentTypeId, stepOrder])
|
model ProsthesisType {
|
||||||
@@map("treatment_workflow_steps")
|
id String @id @default(uuid())
|
||||||
|
code String @unique
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
skipPackingShipping Boolean @default(false)
|
||||||
|
|
||||||
|
steps ProsthesisTypeStep[]
|
||||||
|
|
||||||
|
@@map("prosthesis_types")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LabWorkflowStep {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
code String @unique
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
|
||||||
|
prosthesisSteps ProsthesisTypeStep[]
|
||||||
|
|
||||||
|
@@map("lab_workflow_steps")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProsthesisTypeStep {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
prosthesisTypeId String
|
||||||
|
labWorkflowStepId String
|
||||||
|
stepOrder Int
|
||||||
|
|
||||||
|
prosthesisType ProsthesisType @relation(fields: [prosthesisTypeId], references: [id], onDelete: Cascade)
|
||||||
|
labWorkflowStep LabWorkflowStep @relation(fields: [labWorkflowStepId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([prosthesisTypeId, stepOrder])
|
||||||
|
@@unique([prosthesisTypeId, labWorkflowStepId])
|
||||||
|
@@map("prosthesis_type_steps")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LabCaseToothProsthesis {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
labCaseId String
|
||||||
|
treatmentDetailId String
|
||||||
|
tooth String
|
||||||
|
prosthesisTypeCode String
|
||||||
|
|
||||||
|
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
||||||
|
detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([labCaseId, treatmentDetailId, tooth])
|
||||||
|
@@map("lab_case_tooth_prosthesis")
|
||||||
}
|
}
|
||||||
|
|
||||||
model LabCaseTask {
|
model LabCaseTask {
|
||||||
@@ -250,6 +306,8 @@ model LabCaseTask {
|
|||||||
treatmentDetailId String
|
treatmentDetailId String
|
||||||
tooth String
|
tooth String
|
||||||
treatmentType String
|
treatmentType String
|
||||||
|
prosthesisTypeCode String
|
||||||
|
workflowStepCode String
|
||||||
stepOrder Int
|
stepOrder Int
|
||||||
stepLabel String
|
stepLabel String
|
||||||
assigneeUserId String?
|
assigneeUserId String?
|
||||||
@@ -264,7 +322,7 @@ model LabCaseTask {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([labCaseId, tooth, treatmentType, stepOrder])
|
@@unique([labCaseId, treatmentDetailId, tooth, stepOrder])
|
||||||
@@index([labCaseId, status])
|
@@index([labCaseId, status])
|
||||||
@@index([assigneeUserId, priority, createdAt])
|
@@index([assigneeUserId, priority, createdAt])
|
||||||
@@index([assignedAt, labCaseId, priority])
|
@@index([assignedAt, labCaseId, priority])
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import {
|
||||||
|
TREATMENT_TYPES,
|
||||||
|
LEGACY_TREATMENT_TYPES,
|
||||||
|
LAB_WORKFLOW_STEPS,
|
||||||
|
PROSTHESIS_TYPES,
|
||||||
|
CATALOG_TRANSLATIONS,
|
||||||
|
buildProsthesisStepCodes,
|
||||||
|
} from './catalog-seed-data';
|
||||||
|
|
||||||
// Load environment variables from the correct path
|
// Load environment variables from the correct path
|
||||||
const envPath = path.join(__dirname, '..', '.env');
|
const envPath = path.join(__dirname, '..', '.env');
|
||||||
@@ -128,57 +136,118 @@ async function main() {
|
|||||||
}
|
}
|
||||||
console.log('✅ Created features and permissions');
|
console.log('✅ Created features and permissions');
|
||||||
|
|
||||||
const workflowSteps = [
|
for (const type of [...TREATMENT_TYPES, ...LEGACY_TREATMENT_TYPES]) {
|
||||||
{ code: 'endo', stepOrder: 1, label: 'Access review' },
|
const isActive = TREATMENT_TYPES.some((t) => t.code === type.code);
|
||||||
{ code: 'endo', stepOrder: 2, label: 'Fabrication' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const treatmentTypes = [
|
|
||||||
{ code: 'consultation', labDependent: false, sortOrder: 1 },
|
|
||||||
{ code: 'filling', labDependent: false, sortOrder: 2 },
|
|
||||||
{ code: 'endo', labDependent: true, sortOrder: 3 },
|
|
||||||
{ code: 'visit', labDependent: false, sortOrder: 4 },
|
|
||||||
{ code: 'hygiene', labDependent: false, sortOrder: 5 },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
for (const type of treatmentTypes) {
|
|
||||||
await prisma.treatmentType.upsert({
|
await prisma.treatmentType.upsert({
|
||||||
where: { code: type.code },
|
where: { code: type.code },
|
||||||
update: { labDependent: type.labDependent, sortOrder: type.sortOrder },
|
update: {
|
||||||
|
labDependent: type.labDependent,
|
||||||
|
sortOrder: type.sortOrder,
|
||||||
|
isActive,
|
||||||
|
},
|
||||||
create: {
|
create: {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
code: type.code,
|
code: type.code,
|
||||||
labDependent: type.labDependent,
|
labDependent: type.labDependent,
|
||||||
sortOrder: type.sortOrder,
|
sortOrder: type.sortOrder,
|
||||||
|
isActive,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log('✅ Seeded treatment type catalog');
|
console.log('✅ Seeded treatment type catalog');
|
||||||
|
|
||||||
for (const step of workflowSteps) {
|
for (const step of LAB_WORKFLOW_STEPS) {
|
||||||
const treatmentType = await prisma.treatmentType.findUniqueOrThrow({
|
await prisma.labWorkflowStep.upsert({
|
||||||
where: { code: step.code },
|
where: { code: step.code },
|
||||||
select: { id: true },
|
update: { sortOrder: step.sortOrder },
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.treatmentWorkflowStep.upsert({
|
|
||||||
where: {
|
|
||||||
treatmentTypeId_stepOrder: {
|
|
||||||
treatmentTypeId: treatmentType.id,
|
|
||||||
stepOrder: step.stepOrder,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: { label: step.label },
|
|
||||||
create: {
|
create: {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
treatmentTypeId: treatmentType.id,
|
code: step.code,
|
||||||
stepOrder: step.stepOrder,
|
sortOrder: step.sortOrder,
|
||||||
label: step.label,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log('✅ Seeded lab workflow steps');
|
console.log('✅ Seeded lab workflow steps');
|
||||||
|
|
||||||
|
const workflowStepByCode = new Map(
|
||||||
|
(
|
||||||
|
await prisma.labWorkflowStep.findMany({
|
||||||
|
select: { id: true, code: true },
|
||||||
|
})
|
||||||
|
).map((s) => [s.code, s.id]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const type of PROSTHESIS_TYPES) {
|
||||||
|
const prosthesisType = await prisma.prosthesisType.upsert({
|
||||||
|
where: { code: type.code },
|
||||||
|
update: {
|
||||||
|
sortOrder: type.sortOrder,
|
||||||
|
skipPackingShipping: type.skipPackingShipping ?? false,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: randomUUID(),
|
||||||
|
code: type.code,
|
||||||
|
sortOrder: type.sortOrder,
|
||||||
|
skipPackingShipping: type.skipPackingShipping ?? false,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepCodes = buildProsthesisStepCodes(type);
|
||||||
|
for (const [index, stepCode] of stepCodes.entries()) {
|
||||||
|
const labWorkflowStepId = workflowStepByCode.get(stepCode);
|
||||||
|
if (!labWorkflowStepId) {
|
||||||
|
throw new Error(`Unknown workflow step code: ${stepCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.prosthesisTypeStep.upsert({
|
||||||
|
where: {
|
||||||
|
prosthesisTypeId_stepOrder: {
|
||||||
|
prosthesisTypeId: prosthesisType.id,
|
||||||
|
stepOrder: index + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: { labWorkflowStepId },
|
||||||
|
create: {
|
||||||
|
id: randomUUID(),
|
||||||
|
prosthesisTypeId: prosthesisType.id,
|
||||||
|
labWorkflowStepId,
|
||||||
|
stepOrder: index + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('✅ Seeded prosthesis types and workflow mappings');
|
||||||
|
|
||||||
|
for (const tr of CATALOG_TRANSLATIONS) {
|
||||||
|
const existing = await prisma.catalogTranslation.findFirst({
|
||||||
|
where: {
|
||||||
|
entityKind: tr.entityKind,
|
||||||
|
entityCode: tr.entityCode,
|
||||||
|
locale: tr.locale,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.catalogTranslation.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { label: tr.label },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.catalogTranslation.create({
|
||||||
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
|
entityKind: tr.entityKind,
|
||||||
|
entityCode: tr.entityCode,
|
||||||
|
locale: tr.locale,
|
||||||
|
label: tr.label,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('✅ Seeded catalog translations');
|
||||||
|
|
||||||
console.log('🌱 Seeding completed successfully!');
|
console.log('🌱 Seeding completed successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { TreatmentsModule } from './modules/treatments/treatments.module';
|
|||||||
import { CasesModule } from './modules/cases/cases.module';
|
import { CasesModule } from './modules/cases/cases.module';
|
||||||
import { TasksModule } from './modules/tasks/tasks.module';
|
import { TasksModule } from './modules/tasks/tasks.module';
|
||||||
import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module';
|
import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module';
|
||||||
|
import { CatalogModule } from './modules/catalog/catalog.module';
|
||||||
|
import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -22,7 +24,9 @@ import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-ca
|
|||||||
load: [configurations],
|
load: [configurations],
|
||||||
}),
|
}),
|
||||||
PrismaModule, // ✅ ADD THIS
|
PrismaModule, // ✅ ADD THIS
|
||||||
|
CatalogModule,
|
||||||
TreatmentCatalogModule,
|
TreatmentCatalogModule,
|
||||||
|
ProsthesisCatalogModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
PatientsModule,
|
PatientsModule,
|
||||||
AppointmentsModule,
|
AppointmentsModule,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export class CasesController {
|
|||||||
@ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' })
|
@ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' })
|
||||||
getOne(@Param('id') id: string, @Req() req) {
|
getOne(@Param('id') id: string, @Req() req) {
|
||||||
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
||||||
return this.casesService.getOne(id, organizationId, req.user.id);
|
return this.casesService.getOne(id, organizationId, req.user.id, req.user.language);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/tasks/:taskId')
|
@Patch(':id/tasks/:taskId')
|
||||||
@@ -58,6 +58,6 @@ export class CasesController {
|
|||||||
@Req() req,
|
@Req() req,
|
||||||
) {
|
) {
|
||||||
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
||||||
return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id);
|
return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id, req.user.language);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { LabTaskStatus, Prisma } from '@prisma/client';
|
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { normalizeMobile } from '../../common/phone';
|
import { normalizeMobile } from '../../common/phone';
|
||||||
|
import {
|
||||||
|
CatalogLabelService,
|
||||||
|
normalizeCatalogLocale,
|
||||||
|
} from '../catalog/catalog-label.service';
|
||||||
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||||
import { normalizeTeeth } from '../treatments/treatment.utils';
|
import { normalizeTeeth } from '../treatments/treatment.utils';
|
||||||
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
|
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
|
||||||
@@ -52,6 +56,7 @@ export class CasesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly treatmentCatalog: TreatmentCatalogService,
|
private readonly treatmentCatalog: TreatmentCatalogService,
|
||||||
|
private readonly catalogLabels: CatalogLabelService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||||
@@ -142,8 +147,8 @@ export class CasesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const treatmentTypes = this.treatmentCatalog
|
const catalog = await this.treatmentCatalog.list();
|
||||||
.list()
|
const treatmentTypes = catalog
|
||||||
.filter((entry) => entry.labDependent && typeCodes.has(entry.code))
|
.filter((entry) => entry.labDependent && typeCodes.has(entry.code))
|
||||||
.map((entry) => ({ code: entry.code, labDependent: entry.labDependent }));
|
.map((entry) => ({ code: entry.code, labDependent: entry.labDependent }));
|
||||||
|
|
||||||
@@ -218,6 +223,7 @@ export class CasesService {
|
|||||||
labCaseId: string,
|
labCaseId: string,
|
||||||
clinicOrganizationId: string,
|
clinicOrganizationId: string,
|
||||||
labOrganizationId: string,
|
labOrganizationId: string,
|
||||||
|
localeInput?: string | null,
|
||||||
) {
|
) {
|
||||||
const labCase = await this.prisma.labCase.findFirst({
|
const labCase = await this.prisma.labCase.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -233,10 +239,18 @@ export class CasesService {
|
|||||||
throw new NotFoundException('Case not found');
|
throw new NotFoundException('Case not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, data: this.mapLabCaseDetail(labCase) };
|
return {
|
||||||
|
success: true,
|
||||||
|
data: await this.mapLabCaseDetail(labCase, localeInput),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) {
|
async getOne(
|
||||||
|
labCaseId: string,
|
||||||
|
labOrganizationId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
localeInput?: string | null,
|
||||||
|
) {
|
||||||
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
||||||
|
|
||||||
const labCase = await this.prisma.labCase.findFirst({
|
const labCase = await this.prisma.labCase.findFirst({
|
||||||
@@ -252,7 +266,7 @@ export class CasesService {
|
|||||||
throw new NotFoundException('Case not found');
|
throw new NotFoundException('Case not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, data: this.mapLabCaseDetail(labCase) };
|
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTask(
|
async updateTask(
|
||||||
@@ -261,6 +275,7 @@ export class CasesService {
|
|||||||
dto: UpdateLabCaseTaskDto,
|
dto: UpdateLabCaseTaskDto,
|
||||||
labOrganizationId: string,
|
labOrganizationId: string,
|
||||||
actorUserId: string,
|
actorUserId: string,
|
||||||
|
localeInput?: string | null,
|
||||||
) {
|
) {
|
||||||
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
||||||
|
|
||||||
@@ -299,7 +314,14 @@ export class CasesService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, data: this.mapTask(updated) };
|
const locale = normalizeCatalogLocale(localeInput);
|
||||||
|
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
||||||
|
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||||
|
[updated.prosthesisTypeCode],
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, data: this.mapTask(updated, prosthesisLabels) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async listAssignableMembers(labOrganizationId: string, actorUserId: string) {
|
async listAssignableMembers(labOrganizationId: string, actorUserId: string) {
|
||||||
@@ -431,9 +453,19 @@ export class CasesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapLabCaseDetail(lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>) {
|
private async mapLabCaseDetail(
|
||||||
|
lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>,
|
||||||
|
localeInput?: string | null,
|
||||||
|
) {
|
||||||
|
const locale = normalizeCatalogLocale(localeInput);
|
||||||
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
|
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
|
||||||
const tasksByTooth = this.groupTasksByTooth(lc.tasks);
|
const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))];
|
||||||
|
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
||||||
|
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||||
|
prosthesisCodes,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
const tasksByTooth = this.groupTasksByTooth(lc.tasks, prosthesisLabels);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: lc.id,
|
id: lc.id,
|
||||||
@@ -454,7 +486,7 @@ export class CasesService {
|
|||||||
organizationName: s.organization.name,
|
organizationName: s.organization.name,
|
||||||
sentAt: s.sentAt.toISOString(),
|
sentAt: s.sentAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
tasks: lc.tasks.map((t) => this.mapTask(t)),
|
tasks: lc.tasks.map((t) => this.mapTask(t, prosthesisLabels)),
|
||||||
tasksByTooth,
|
tasksByTooth,
|
||||||
taskProgress: {
|
taskProgress: {
|
||||||
completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length,
|
completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length,
|
||||||
@@ -468,6 +500,7 @@ export class CasesService {
|
|||||||
id: string;
|
id: string;
|
||||||
tooth: string;
|
tooth: string;
|
||||||
treatmentType: string;
|
treatmentType: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
stepOrder: number;
|
stepOrder: number;
|
||||||
stepLabel: string;
|
stepLabel: string;
|
||||||
status: LabTaskStatus;
|
status: LabTaskStatus;
|
||||||
@@ -477,34 +510,43 @@ export class CasesService {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
assignee: { id: string; name: string; email: string } | null;
|
assignee: { id: string; name: string; email: string } | null;
|
||||||
}>,
|
}>,
|
||||||
|
prosthesisLabels: Map<string, string>,
|
||||||
) {
|
) {
|
||||||
const groups = new Map<
|
const groups = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
tooth: string;
|
tooth: string;
|
||||||
treatmentType: string;
|
treatmentType: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
prosthesisTypeLabel: string;
|
||||||
tasks: ReturnType<CasesService['mapTask']>[];
|
tasks: ReturnType<CasesService['mapTask']>[];
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
const key = `${task.tooth}:${task.treatmentType}`;
|
const key = `${task.tooth}:${task.treatmentType}:${task.prosthesisTypeCode}`;
|
||||||
const entry = groups.get(key) ?? {
|
const entry = groups.get(key) ?? {
|
||||||
tooth: task.tooth,
|
tooth: task.tooth,
|
||||||
treatmentType: task.treatmentType,
|
treatmentType: task.treatmentType,
|
||||||
|
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||||
|
prosthesisTypeLabel:
|
||||||
|
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
|
||||||
tasks: [],
|
tasks: [],
|
||||||
};
|
};
|
||||||
entry.tasks.push(this.mapTask(task));
|
entry.tasks.push(this.mapTask(task, prosthesisLabels));
|
||||||
groups.set(key, entry);
|
groups.set(key, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...groups.values()];
|
return [...groups.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapTask(task: {
|
private mapTask(
|
||||||
|
task: {
|
||||||
id: string;
|
id: string;
|
||||||
tooth: string;
|
tooth: string;
|
||||||
treatmentType: string;
|
treatmentType: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
workflowStepCode?: string;
|
||||||
stepOrder: number;
|
stepOrder: number;
|
||||||
stepLabel: string;
|
stepLabel: string;
|
||||||
status: LabTaskStatus;
|
status: LabTaskStatus;
|
||||||
@@ -513,11 +555,17 @@ export class CasesService {
|
|||||||
assignedAt: Date | null;
|
assignedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
assignee: { id: string; name: string; email: string } | null;
|
assignee: { id: string; name: string; email: string } | null;
|
||||||
}) {
|
},
|
||||||
|
prosthesisLabels: Map<string, string>,
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
tooth: task.tooth,
|
tooth: task.tooth,
|
||||||
treatmentType: task.treatmentType,
|
treatmentType: task.treatmentType,
|
||||||
|
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||||
|
prosthesisTypeLabel:
|
||||||
|
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
|
||||||
|
workflowStepCode: task.workflowStepCode ?? '',
|
||||||
stepOrder: task.stepOrder,
|
stepOrder: task.stepOrder,
|
||||||
stepLabel: task.stepLabel,
|
stepLabel: task.stepLabel,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
|
|||||||
154
backend/src/modules/cases/lab-case-task.generator.spec.ts
Normal file
154
backend/src/modules/cases/lab-case-task.generator.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { CatalogEntityKind } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
PROSTHESIS_TYPES,
|
||||||
|
buildProsthesisStepCodes,
|
||||||
|
} from '../../../prisma/catalog-seed-data';
|
||||||
|
import { generateLabCaseTasks } from './lab-case-task.generator';
|
||||||
|
|
||||||
|
function buildMockTx(options: {
|
||||||
|
existingCount?: number;
|
||||||
|
toothProsthesisRows: Array<{
|
||||||
|
treatmentDetailId: string;
|
||||||
|
tooth: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
treatmentType?: string;
|
||||||
|
}>;
|
||||||
|
prosthesisTypes: Array<{
|
||||||
|
code: string;
|
||||||
|
steps: Array<{ stepOrder: number; workflowStepCode: string }>;
|
||||||
|
}>;
|
||||||
|
stepLabels?: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
const created: unknown[] = [];
|
||||||
|
|
||||||
|
const tx = {
|
||||||
|
labCaseTask: {
|
||||||
|
count: jest.fn().mockResolvedValue(options.existingCount ?? 0),
|
||||||
|
createMany: jest.fn().mockImplementation(({ data }) => {
|
||||||
|
created.push(...data);
|
||||||
|
return { count: data.length };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
labCaseToothProsthesis: {
|
||||||
|
findMany: jest.fn().mockResolvedValue(
|
||||||
|
options.toothProsthesisRows.map((row) => ({
|
||||||
|
treatmentDetailId: row.treatmentDetailId,
|
||||||
|
tooth: row.tooth,
|
||||||
|
prosthesisTypeCode: row.prosthesisTypeCode,
|
||||||
|
detail: {
|
||||||
|
id: row.treatmentDetailId,
|
||||||
|
treatmentType: row.treatmentType ?? 'prosthesis',
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
prosthesisType: {
|
||||||
|
findMany: jest.fn().mockResolvedValue(
|
||||||
|
options.prosthesisTypes.map((type) => ({
|
||||||
|
code: type.code,
|
||||||
|
isActive: true,
|
||||||
|
steps: type.steps.map((step) => ({
|
||||||
|
stepOrder: step.stepOrder,
|
||||||
|
labWorkflowStep: { code: step.workflowStepCode },
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
catalogTranslation: {
|
||||||
|
findMany: jest.fn().mockImplementation(({ where }) => {
|
||||||
|
const codes = where.entityCode?.in ?? [];
|
||||||
|
return codes.map((code: string) => ({
|
||||||
|
entityCode: code,
|
||||||
|
locale: 'en',
|
||||||
|
label: options.stepLabels?.[code] ?? code,
|
||||||
|
entityKind: CatalogEntityKind.LAB_WORKFLOW_STEP,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { tx, created };
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepsFromSeed(code: string) {
|
||||||
|
const seed = PROSTHESIS_TYPES.find((type) => type.code === code);
|
||||||
|
if (!seed) {
|
||||||
|
throw new Error(`Unknown prosthesis code: ${code}`);
|
||||||
|
}
|
||||||
|
return buildProsthesisStepCodes(seed).map((workflowStepCode, index) => ({
|
||||||
|
stepOrder: index + 1,
|
||||||
|
workflowStepCode,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('generateLabCaseTasks', () => {
|
||||||
|
it('creates tasks for pfm_crown with universal and type-specific steps', async () => {
|
||||||
|
const pfmSteps = stepsFromSeed('pfm_crown');
|
||||||
|
const { tx, created } = buildMockTx({
|
||||||
|
toothProsthesisRows: [
|
||||||
|
{
|
||||||
|
treatmentDetailId: 'detail-1',
|
||||||
|
tooth: '14',
|
||||||
|
prosthesisTypeCode: 'pfm_crown',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }],
|
||||||
|
stepLabels: { intraoral_scan: 'Intraoral Scan', packing: 'Packing' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = await generateLabCaseTasks(tx as never, 'lab-case-1', 'en');
|
||||||
|
|
||||||
|
expect(count).toBe(pfmSteps.length);
|
||||||
|
expect(created).toHaveLength(pfmSteps.length);
|
||||||
|
expect(created[0]).toMatchObject({
|
||||||
|
tooth: '14',
|
||||||
|
prosthesisTypeCode: 'pfm_crown',
|
||||||
|
workflowStepCode: 'intraoral_scan',
|
||||||
|
stepLabel: 'Intraoral Scan',
|
||||||
|
});
|
||||||
|
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
|
||||||
|
(row) => row.workflowStepCode,
|
||||||
|
);
|
||||||
|
expect(stepCodes).toEqual(pfmSteps.map((step) => step.workflowStepCode));
|
||||||
|
expect(stepCodes).toContain('packing');
|
||||||
|
expect(stepCodes).toContain('shipping');
|
||||||
|
expect(stepCodes).toContain('milling_wet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits packing and shipping for smile_design', async () => {
|
||||||
|
const smileSteps = stepsFromSeed('smile_design');
|
||||||
|
const { tx, created } = buildMockTx({
|
||||||
|
toothProsthesisRows: [
|
||||||
|
{
|
||||||
|
treatmentDetailId: 'detail-1',
|
||||||
|
tooth: '11',
|
||||||
|
prosthesisTypeCode: 'smile_design',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prosthesisTypes: [{ code: 'smile_design', steps: smileSteps }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = await generateLabCaseTasks(tx as never, 'lab-case-2', 'en');
|
||||||
|
|
||||||
|
expect(count).toBe(smileSteps.length);
|
||||||
|
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
|
||||||
|
(row) => row.workflowStepCode,
|
||||||
|
);
|
||||||
|
expect(stepCodes).not.toContain('packing');
|
||||||
|
expect(stepCodes).not.toContain('shipping');
|
||||||
|
expect(stepCodes).toContain('printer_resin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips generation when tasks already exist', async () => {
|
||||||
|
const { tx } = buildMockTx({
|
||||||
|
existingCount: 3,
|
||||||
|
toothProsthesisRows: [],
|
||||||
|
prosthesisTypes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = await generateLabCaseTasks(tx as never, 'lab-case-3', 'en');
|
||||||
|
|
||||||
|
expect(count).toBe(0);
|
||||||
|
expect(tx.labCaseTask.createMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,86 +1,85 @@
|
|||||||
import { LabTaskStatus, Prisma } from '@prisma/client';
|
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
|
||||||
import { normalizeTeeth } from '../treatments/treatment.utils';
|
import { normalizeCatalogLocale } from '../catalog/catalog-label.service';
|
||||||
|
|
||||||
type TransactionClient = Prisma.TransactionClient;
|
type TransactionClient = Prisma.TransactionClient;
|
||||||
|
|
||||||
export async function generateLabCaseTasks(
|
export async function generateLabCaseTasks(
|
||||||
tx: TransactionClient,
|
tx: TransactionClient,
|
||||||
labCaseId: string,
|
labCaseId: string,
|
||||||
|
localeInput?: string | null,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const existingCount = await tx.labCaseTask.count({ where: { labCaseId } });
|
const existingCount = await tx.labCaseTask.count({ where: { labCaseId } });
|
||||||
if (existingCount > 0) {
|
if (existingCount > 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const labCase = await tx.labCase.findUnique({
|
const locale = normalizeCatalogLocale(localeInput);
|
||||||
where: { id: labCaseId },
|
|
||||||
|
const toothProsthesisRows = await tx.labCaseToothProsthesis.findMany({
|
||||||
|
where: { labCaseId },
|
||||||
include: {
|
include: {
|
||||||
details: {
|
detail: { select: { id: true, treatmentType: true } },
|
||||||
include: {
|
|
||||||
detail: {
|
|
||||||
select: { id: true, treatmentType: true, teeth: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!labCase?.details.length) {
|
if (toothProsthesisRows.length === 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const treatmentTypeCodes = [...new Set(labCase.details.map((d) => d.detail.treatmentType))];
|
const prosthesisCodes = [...new Set(toothProsthesisRows.map((r) => r.prosthesisTypeCode))];
|
||||||
|
|
||||||
const labDependentTypes = await tx.treatmentType.findMany({
|
const prosthesisTypes = await tx.prosthesisType.findMany({
|
||||||
where: { code: { in: treatmentTypeCodes }, labDependent: true },
|
where: { code: { in: prosthesisCodes }, isActive: true },
|
||||||
select: { id: true, code: true },
|
include: {
|
||||||
|
steps: {
|
||||||
|
orderBy: { stepOrder: 'asc' },
|
||||||
|
include: { labWorkflowStep: { select: { code: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (labDependentTypes.length === 0) {
|
const stepsByProsthesisCode = new Map(
|
||||||
return 0;
|
prosthesisTypes.map((type) => [
|
||||||
}
|
type.code,
|
||||||
|
type.steps.map((s) => ({
|
||||||
|
stepOrder: s.stepOrder,
|
||||||
|
workflowStepCode: s.labWorkflowStep.code,
|
||||||
|
})),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
const labDependentCodes = new Set(labDependentTypes.map((t) => t.code));
|
const allStepCodes = [
|
||||||
|
...new Set(
|
||||||
|
prosthesisTypes.flatMap((type) =>
|
||||||
|
type.steps.map((s) => s.labWorkflowStep.code),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
const workflowSteps = await tx.treatmentWorkflowStep.findMany({
|
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
|
||||||
where: { treatmentTypeId: { in: labDependentTypes.map((t) => t.id) } },
|
|
||||||
orderBy: [{ treatmentTypeId: 'asc' }, { stepOrder: 'asc' }],
|
|
||||||
include: { treatmentType: { select: { code: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const stepsByTypeCode = new Map<string, { stepOrder: number; label: string }[]>();
|
|
||||||
for (const step of workflowSteps) {
|
|
||||||
const code = step.treatmentType.code;
|
|
||||||
const list = stepsByTypeCode.get(code) ?? [];
|
|
||||||
list.push({ stepOrder: step.stepOrder, label: step.label });
|
|
||||||
stepsByTypeCode.set(code, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
|
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
|
||||||
|
|
||||||
for (const link of labCase.details) {
|
for (const row of toothProsthesisRows) {
|
||||||
const detail = link.detail;
|
const typeSteps = stepsByProsthesisCode.get(row.prosthesisTypeCode) ?? [];
|
||||||
if (!labDependentCodes.has(detail.treatmentType)) {
|
if (typeSteps.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const teeth = normalizeTeeth(detail.teeth);
|
|
||||||
const typeSteps = stepsByTypeCode.get(detail.treatmentType) ?? [];
|
|
||||||
|
|
||||||
for (const tooth of teeth) {
|
|
||||||
for (const step of typeSteps) {
|
for (const step of typeSteps) {
|
||||||
taskRows.push({
|
taskRows.push({
|
||||||
labCaseId,
|
labCaseId,
|
||||||
treatmentDetailId: detail.id,
|
treatmentDetailId: row.treatmentDetailId,
|
||||||
tooth,
|
tooth: row.tooth,
|
||||||
treatmentType: detail.treatmentType,
|
treatmentType: row.detail.treatmentType,
|
||||||
|
prosthesisTypeCode: row.prosthesisTypeCode,
|
||||||
|
workflowStepCode: step.workflowStepCode,
|
||||||
stepOrder: step.stepOrder,
|
stepOrder: step.stepOrder,
|
||||||
stepLabel: step.label,
|
stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode,
|
||||||
status: LabTaskStatus.PENDING,
|
status: LabTaskStatus.PENDING,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (taskRows.length === 0) {
|
if (taskRows.length === 0) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -89,3 +88,37 @@ export async function generateLabCaseTasks(
|
|||||||
await tx.labCaseTask.createMany({ data: taskRows });
|
await tx.labCaseTask.createMany({ data: taskRows });
|
||||||
return taskRows.length;
|
return taskRows.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveStepLabels(
|
||||||
|
tx: TransactionClient,
|
||||||
|
stepCodes: string[],
|
||||||
|
locale: string,
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
if (stepCodes.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await tx.catalogTranslation.findMany({
|
||||||
|
where: {
|
||||||
|
entityKind: CatalogEntityKind.LAB_WORKFLOW_STEP,
|
||||||
|
entityCode: { in: stepCodes },
|
||||||
|
locale: { in: [locale, 'en'] },
|
||||||
|
},
|
||||||
|
select: { entityCode: true, locale: true, label: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const byCode = new Map<string, { en?: string; locale?: string }>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const entry = byCode.get(row.entityCode) ?? {};
|
||||||
|
if (row.locale === 'en') entry.en = row.label;
|
||||||
|
if (row.locale === locale) entry.locale = row.label;
|
||||||
|
byCode.set(row.entityCode, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
for (const code of stepCodes) {
|
||||||
|
const entry = byCode.get(code);
|
||||||
|
result.set(code, entry?.locale ?? entry?.en ?? code);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
67
backend/src/modules/catalog/catalog-label.service.ts
Normal file
67
backend/src/modules/catalog/catalog-label.service.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { CatalogEntityKind } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
|
||||||
|
const SUPPORTED_LOCALES = ['en', 'fa', 'nl'] as const;
|
||||||
|
export type CatalogLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
|
|
||||||
|
export function normalizeCatalogLocale(language?: string | null): CatalogLocale {
|
||||||
|
if (language === 'fa' || language === 'nl') return language;
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CatalogLabelService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async resolveLabels(
|
||||||
|
entityKind: CatalogEntityKind,
|
||||||
|
codes: string[],
|
||||||
|
locale: CatalogLocale,
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const uniqueCodes = [...new Set(codes.filter(Boolean))];
|
||||||
|
if (uniqueCodes.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.prisma.catalogTranslation.findMany({
|
||||||
|
where: {
|
||||||
|
entityKind,
|
||||||
|
entityCode: { in: uniqueCodes },
|
||||||
|
locale: { in: [locale, 'en'] },
|
||||||
|
},
|
||||||
|
select: { entityCode: true, locale: true, label: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const byCode = new Map<string, { en?: string; locale?: string }>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const entry = byCode.get(row.entityCode) ?? {};
|
||||||
|
if (row.locale === 'en') entry.en = row.label;
|
||||||
|
if (row.locale === locale) entry.locale = row.label;
|
||||||
|
byCode.set(row.entityCode, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
for (const code of uniqueCodes) {
|
||||||
|
const entry = byCode.get(code);
|
||||||
|
result.set(code, entry?.locale ?? entry?.en ?? formatCodeAsLabel(code));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveLabel(
|
||||||
|
entityKind: CatalogEntityKind,
|
||||||
|
code: string,
|
||||||
|
locale: CatalogLocale,
|
||||||
|
): Promise<string> {
|
||||||
|
const map = await this.resolveLabels(entityKind, [code], locale);
|
||||||
|
return map.get(code) ?? formatCodeAsLabel(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCodeAsLabel(code: string): string {
|
||||||
|
return code
|
||||||
|
.split('_')
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
10
backend/src/modules/catalog/catalog.module.ts
Normal file
10
backend/src/modules/catalog/catalog.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { CatalogLabelService } from './catalog-label.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [CatalogLabelService, PrismaService],
|
||||||
|
exports: [CatalogLabelService],
|
||||||
|
})
|
||||||
|
export class CatalogModule {}
|
||||||
@@ -143,7 +143,7 @@ export class OrganizationController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiOperation({ summary: 'Get one case exchanged with a connected organization' })
|
@ApiOperation({ summary: 'Get one case exchanged with a connected organization' })
|
||||||
getConnectionCase(
|
getConnectionCase(
|
||||||
@Req() req: { user: { id: string; organizationId?: string } },
|
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
|
||||||
@Param('connectionId') connectionId: string,
|
@Param('connectionId') connectionId: string,
|
||||||
@Param('caseId') caseId: string,
|
@Param('caseId') caseId: string,
|
||||||
) {
|
) {
|
||||||
@@ -153,6 +153,7 @@ export class OrganizationController {
|
|||||||
organizationId,
|
organizationId,
|
||||||
connectionId,
|
connectionId,
|
||||||
caseId,
|
caseId,
|
||||||
|
req.user.language,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -379,6 +379,7 @@ export class OrganizationService {
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
caseId: string,
|
caseId: string,
|
||||||
|
localeInput?: string | null,
|
||||||
) {
|
) {
|
||||||
const actor = await this.getActorMembership(userId, organizationId);
|
const actor = await this.getActorMembership(userId, organizationId);
|
||||||
if (!actor || !this.canEditOrganizations(actor)) {
|
if (!actor || !this.canEditOrganizations(actor)) {
|
||||||
@@ -392,6 +393,7 @@ export class OrganizationService {
|
|||||||
caseId,
|
caseId,
|
||||||
clinicOrganizationId,
|
clinicOrganizationId,
|
||||||
labOrganizationId,
|
labOrganizationId,
|
||||||
|
localeInput,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Injectable, BadRequestException, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { CatalogEntityKind } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
CatalogLabelService,
|
||||||
|
CatalogLocale,
|
||||||
|
normalizeCatalogLocale,
|
||||||
|
} from '../catalog/catalog-label.service';
|
||||||
|
|
||||||
|
export type ProsthesisTypeCatalogEntry = {
|
||||||
|
code: string;
|
||||||
|
sortOrder: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProsthesisCatalogService implements OnModuleInit {
|
||||||
|
private loaded = false;
|
||||||
|
private byCode = new Map<string, { sortOrder: number }>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly catalogLabels: CatalogLabelService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
const rows = await this.prisma.prosthesisType.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }],
|
||||||
|
select: { code: true, sortOrder: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.byCode = new Map(rows.map((row) => [row.code, { sortOrder: row.sortOrder }]));
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(localeInput?: string | null): Promise<ProsthesisTypeCatalogEntry[]> {
|
||||||
|
this.ensureLoaded();
|
||||||
|
const locale = normalizeCatalogLocale(localeInput);
|
||||||
|
const codes = [...this.byCode.keys()];
|
||||||
|
const labels = await this.catalogLabels.resolveLabels(
|
||||||
|
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||||
|
codes,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
|
||||||
|
return codes
|
||||||
|
.map((code) => ({
|
||||||
|
code,
|
||||||
|
sortOrder: this.byCode.get(code)!.sortOrder,
|
||||||
|
label: labels.get(code) ?? code,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
|
||||||
|
}
|
||||||
|
|
||||||
|
assertKnownProsthesisType(code: string): void {
|
||||||
|
this.ensureLoaded();
|
||||||
|
if (!this.byCode.has(code)) {
|
||||||
|
throw new BadRequestException(`Unknown prosthesis type: ${code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStepCodesForProsthesisType(prosthesisTypeCode: string): Promise<string[]> {
|
||||||
|
const type = await this.prisma.prosthesisType.findUnique({
|
||||||
|
where: { code: prosthesisTypeCode },
|
||||||
|
select: {
|
||||||
|
steps: {
|
||||||
|
orderBy: { stepOrder: 'asc' },
|
||||||
|
select: { labWorkflowStep: { select: { code: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return type.steps.map((s) => s.labWorkflowStep.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveStepLabels(stepCodes: string[], locale: CatalogLocale): Promise<Map<string, string>> {
|
||||||
|
return this.catalogLabels.resolveLabels(
|
||||||
|
CatalogEntityKind.LAB_WORKFLOW_STEP,
|
||||||
|
stepCodes,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureLoaded() {
|
||||||
|
if (!this.loaded) {
|
||||||
|
throw new Error('Prosthesis catalog is not loaded yet');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export class TasksController {
|
|||||||
@ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' })
|
@ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' })
|
||||||
list(@Query() query: ListLabTasksDto, @Req() req) {
|
list(@Query() query: ListLabTasksDto, @Req() req) {
|
||||||
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
|
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
|
||||||
return this.tasksService.list(organizationId, req.user.id, query);
|
return this.tasksService.list(organizationId, req.user.id, query, req.user.language);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':taskId')
|
@Patch(':taskId')
|
||||||
@@ -27,6 +27,12 @@ export class TasksController {
|
|||||||
@Req() req,
|
@Req() req,
|
||||||
) {
|
) {
|
||||||
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
|
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
|
||||||
return this.tasksService.updateStatus(taskId, dto, organizationId, req.user.id);
|
return this.tasksService.updateStatus(
|
||||||
|
taskId,
|
||||||
|
dto,
|
||||||
|
organizationId,
|
||||||
|
req.user.id,
|
||||||
|
req.user.language,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { LabTaskStatus, Prisma } from '@prisma/client';
|
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
CatalogLabelService,
|
||||||
|
normalizeCatalogLocale,
|
||||||
|
} from '../catalog/catalog-label.service';
|
||||||
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
|
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
|
||||||
|
|
||||||
const taskListInclude = {
|
const taskListInclude = {
|
||||||
@@ -24,7 +28,10 @@ const taskListInclude = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TasksService {
|
export class TasksService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly catalogLabels: CatalogLabelService,
|
||||||
|
) {}
|
||||||
|
|
||||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||||
if (!user?.organizationId) {
|
if (!user?.organizationId) {
|
||||||
@@ -33,7 +40,12 @@ export class TasksService {
|
|||||||
return user.organizationId;
|
return user.organizationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(labOrganizationId: string, actorUserId: string, query: ListLabTasksDto) {
|
async list(
|
||||||
|
labOrganizationId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
query: ListLabTasksDto,
|
||||||
|
localeInput?: string | null,
|
||||||
|
) {
|
||||||
await this.assertCanReadTasks(actorUserId, labOrganizationId);
|
await this.assertCanReadTasks(actorUserId, labOrganizationId);
|
||||||
|
|
||||||
const membership = await this.getMembership(actorUserId, labOrganizationId);
|
const membership = await this.getMembership(actorUserId, labOrganizationId);
|
||||||
@@ -71,10 +83,18 @@ export class TasksService {
|
|||||||
this.prisma.labCaseTask.count({ where }),
|
this.prisma.labCaseTask.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const locale = normalizeCatalogLocale(localeInput);
|
||||||
|
const prosthesisCodes = [...new Set(items.map((t) => t.prosthesisTypeCode).filter(Boolean))];
|
||||||
|
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
||||||
|
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||||
|
prosthesisCodes,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
items: items.map((task) => this.mapTaskListItem(task)),
|
items: items.map((task) => this.mapTaskListItem(task, prosthesisLabels)),
|
||||||
pagination: {
|
pagination: {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
@@ -90,6 +110,7 @@ export class TasksService {
|
|||||||
dto: UpdateLabTaskDto,
|
dto: UpdateLabTaskDto,
|
||||||
labOrganizationId: string,
|
labOrganizationId: string,
|
||||||
actorUserId: string,
|
actorUserId: string,
|
||||||
|
localeInput?: string | null,
|
||||||
) {
|
) {
|
||||||
await this.assertCanEditTasks(actorUserId, labOrganizationId);
|
await this.assertCanEditTasks(actorUserId, labOrganizationId);
|
||||||
|
|
||||||
@@ -123,17 +144,28 @@ export class TasksService {
|
|||||||
include: taskListInclude,
|
include: taskListInclude,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, data: this.mapTaskListItem(updated) };
|
const locale = normalizeCatalogLocale(localeInput);
|
||||||
|
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
||||||
|
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||||
|
[updated.prosthesisTypeCode],
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapTaskListItem(
|
private mapTaskListItem(
|
||||||
task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
|
task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
|
||||||
|
prosthesisLabels: Map<string, string>,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
labCaseId: task.labCaseId,
|
labCaseId: task.labCaseId,
|
||||||
tooth: task.tooth,
|
tooth: task.tooth,
|
||||||
treatmentType: task.treatmentType,
|
treatmentType: task.treatmentType,
|
||||||
|
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||||
|
prosthesisTypeLabel:
|
||||||
|
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
|
||||||
stepOrder: task.stepOrder,
|
stepOrder: task.stepOrder,
|
||||||
stepLabel: task.stepLabel,
|
stepLabel: task.stepLabel,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { TreatmentCatalogService } from './treatment-catalog.service';
|
import { TreatmentCatalogService } from './treatment-catalog.service';
|
||||||
@@ -11,11 +11,9 @@ export class TreatmentCatalogController {
|
|||||||
constructor(private readonly treatmentCatalogService: TreatmentCatalogService) {}
|
constructor(private readonly treatmentCatalogService: TreatmentCatalogService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List treatment types from the catalog (data-driven)' })
|
@ApiOperation({ summary: 'List active treatment types with localized labels' })
|
||||||
list() {
|
async list(@Req() req: { user?: { language?: string | null } }) {
|
||||||
return {
|
const data = await this.treatmentCatalogService.list(req.user?.language);
|
||||||
success: true,
|
return { success: true, data };
|
||||||
data: this.treatmentCatalogService.list(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { BadRequestException, Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { CatalogEntityKind } from '@prisma/client';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
CatalogLabelService,
|
||||||
|
normalizeCatalogLocale,
|
||||||
|
} from '../catalog/catalog-label.service';
|
||||||
|
|
||||||
export type TreatmentTypeCatalogEntry = {
|
export type TreatmentTypeCatalogEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
labDependent: boolean;
|
labDependent: boolean;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -13,7 +20,10 @@ export class TreatmentCatalogService implements OnModuleInit {
|
|||||||
private loaded = false;
|
private loaded = false;
|
||||||
private byCode = new Map<string, TreatmentTypeCatalogEntry>();
|
private byCode = new Map<string, TreatmentTypeCatalogEntry>();
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly catalogLabels: CatalogLabelService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
@@ -21,17 +31,46 @@ export class TreatmentCatalogService implements OnModuleInit {
|
|||||||
|
|
||||||
async refresh(): Promise<void> {
|
async refresh(): Promise<void> {
|
||||||
const rows = await this.prisma.treatmentType.findMany({
|
const rows = await this.prisma.treatmentType.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }],
|
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }],
|
||||||
select: { id: true, code: true, labDependent: true, sortOrder: true },
|
select: { id: true, code: true, labDependent: true, sortOrder: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
this.byCode = new Map(rows.map((row) => [row.code, row]));
|
this.byCode = new Map(
|
||||||
|
rows.map((row) => [
|
||||||
|
row.code,
|
||||||
|
{
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
labDependent: row.labDependent,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
label: row.code,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
list(): TreatmentTypeCatalogEntry[] {
|
async list(localeInput?: string | null): Promise<TreatmentTypeCatalogEntry[]> {
|
||||||
|
await this.ensureLabels(localeInput);
|
||||||
|
return [...this.byCode.values()].sort(
|
||||||
|
(a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureLabels(localeInput?: string | null) {
|
||||||
this.ensureLoaded();
|
this.ensureLoaded();
|
||||||
return [...this.byCode.values()];
|
const locale = normalizeCatalogLocale(localeInput);
|
||||||
|
const codes = [...this.byCode.keys()];
|
||||||
|
const labels = await this.catalogLabels.resolveLabels(
|
||||||
|
CatalogEntityKind.TREATMENT_TYPE,
|
||||||
|
codes,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [code, entry] of this.byCode) {
|
||||||
|
entry.label = labels.get(code) ?? entry.code;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getByCode(code: string): TreatmentTypeCatalogEntry | undefined {
|
getByCode(code: string): TreatmentTypeCatalogEntry | undefined {
|
||||||
|
|||||||
@@ -45,6 +45,19 @@ export class SaveTreatmentDraftDto {
|
|||||||
details: SaveTreatmentDetailDto[];
|
details: SaveTreatmentDetailDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class LabCaseToothProsthesisDto {
|
||||||
|
@IsUUID()
|
||||||
|
treatmentDetailId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(8)
|
||||||
|
tooth: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class SaveLabCaseDto {
|
export class SaveLabCaseDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(64)
|
@MaxLength(64)
|
||||||
@@ -67,6 +80,12 @@ export class SaveLabCaseDto {
|
|||||||
@ArrayMinSize(1)
|
@ArrayMinSize(1)
|
||||||
@IsUUID(undefined, { each: true })
|
@IsUUID(undefined, { each: true })
|
||||||
treatmentDetailIds: string[];
|
treatmentDetailIds: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => LabCaseToothProsthesisDto)
|
||||||
|
toothProsthesis?: LabCaseToothProsthesisDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SaveTreatmentLabCasesDto {
|
export class SaveTreatmentLabCasesDto {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
37
backend/src/modules/treatments/lab-case-send.validation.ts
Normal file
37
backend/src/modules/treatments/lab-case-send.validation.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { normalizeTeeth } from './treatment.utils';
|
||||||
|
|
||||||
|
export type LabCaseProsthesisLink = {
|
||||||
|
treatmentDetailId: string;
|
||||||
|
detail: { id: string; treatmentType: string; teeth: unknown };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LabCaseToothProsthesisRow = {
|
||||||
|
treatmentDetailId: string;
|
||||||
|
tooth: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function assertCompleteToothProsthesisMap(labCase: {
|
||||||
|
details: LabCaseProsthesisLink[];
|
||||||
|
toothProsthesis: LabCaseToothProsthesisRow[];
|
||||||
|
}) {
|
||||||
|
const prosthesisByKey = new Set(
|
||||||
|
labCase.toothProsthesis.map((tp) => `${tp.treatmentDetailId}:${tp.tooth}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const link of labCase.details) {
|
||||||
|
if (link.detail.treatmentType !== 'prosthesis') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const teeth = normalizeTeeth(link.detail.teeth);
|
||||||
|
for (const tooth of teeth) {
|
||||||
|
const key = `${link.treatmentDetailId}:${tooth}`;
|
||||||
|
if (!prosthesisByKey.has(key)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Each tooth must have a prosthesis type before sending (missing tooth ${tooth})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,9 +184,14 @@ export class TreatmentsController {
|
|||||||
@ApiOperation({ summary: 'Send a lab case to its destination organization (TAB_TREATMENT_EDIT)' })
|
@ApiOperation({ summary: 'Send a lab case to its destination organization (TAB_TREATMENT_EDIT)' })
|
||||||
sendLabCase(
|
sendLabCase(
|
||||||
@Param('labCaseId') labCaseId: string,
|
@Param('labCaseId') labCaseId: string,
|
||||||
@Req() req: { user: { id: string; organizationId?: string } },
|
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
|
||||||
) {
|
) {
|
||||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||||
return this.treatmentsService.sendLabCase(labCaseId, organizationId, req.user.id);
|
return this.treatmentsService.sendLabCase(
|
||||||
|
labCaseId,
|
||||||
|
organizationId,
|
||||||
|
req.user.id,
|
||||||
|
req.user.language,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||||
|
import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module';
|
||||||
import { TreatmentsController } from './treatments.controller';
|
import { TreatmentsController } from './treatments.controller';
|
||||||
import { TreatmentsService } from './treatments.service';
|
import { TreatmentsService } from './treatments.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ProsthesisCatalogModule],
|
||||||
controllers: [TreatmentsController],
|
controllers: [TreatmentsController],
|
||||||
providers: [TreatmentsService, PrismaService, ClinicOrgGuard],
|
providers: [TreatmentsService, PrismaService, ClinicOrgGuard],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { join } from 'path';
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
|
import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
|
||||||
|
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
|
||||||
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||||
import {
|
import {
|
||||||
SaveTreatmentDraftDto,
|
SaveTreatmentDraftDto,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
generateTreatmentTitle,
|
generateTreatmentTitle,
|
||||||
normalizeTeeth,
|
normalizeTeeth,
|
||||||
} from './treatment.utils';
|
} from './treatment.utils';
|
||||||
|
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
|
||||||
|
|
||||||
const treatmentInclude = {
|
const treatmentInclude = {
|
||||||
details: {
|
details: {
|
||||||
@@ -53,6 +55,7 @@ const treatmentInclude = {
|
|||||||
orderBy: [{ sentAt: 'asc' as const }],
|
orderBy: [{ sentAt: 'asc' as const }],
|
||||||
include: { organization: { select: { id: true, name: true } } },
|
include: { organization: { select: { id: true, name: true } } },
|
||||||
},
|
},
|
||||||
|
toothProsthesis: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -64,6 +67,7 @@ export class TreatmentsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly treatmentCatalog: TreatmentCatalogService,
|
private readonly treatmentCatalog: TreatmentCatalogService,
|
||||||
|
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||||
@@ -326,7 +330,7 @@ export class TreatmentsService {
|
|||||||
|
|
||||||
const details = await this.prisma.treatmentDetail.findMany({
|
const details = await this.prisma.treatmentDetail.findMany({
|
||||||
where: { treatmentId: treatment.id, id: { in: detailIds } },
|
where: { treatmentId: treatment.id, id: { in: detailIds } },
|
||||||
select: { id: true, treatmentType: true },
|
select: { id: true, treatmentType: true, teeth: true },
|
||||||
});
|
});
|
||||||
if (details.length !== uniqueDetailIds.size) {
|
if (details.length !== uniqueDetailIds.size) {
|
||||||
throw new BadRequestException('One or more treatment details were not found');
|
throw new BadRequestException('One or more treatment details were not found');
|
||||||
@@ -336,12 +340,30 @@ export class TreatmentsService {
|
|||||||
this.treatmentCatalog.assertLabDependentTreatmentType(detail.treatmentType);
|
this.treatmentCatalog.assertLabDependentTreatmentType(detail.treatmentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const detailById = new Map(details.map((d) => [d.id, d]));
|
||||||
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
|
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
|
||||||
|
|
||||||
for (const lc of dto.labCases) {
|
for (const lc of dto.labCases) {
|
||||||
if (lc.destinationOrganizationId && !linkedOrgIds.has(lc.destinationOrganizationId)) {
|
if (lc.destinationOrganizationId && !linkedOrgIds.has(lc.destinationOrganizationId)) {
|
||||||
throw new BadRequestException('Destination organization is not an active linked counterpart');
|
throw new BadRequestException('Destination organization is not an active linked counterpart');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const row of lc.toothProsthesis ?? []) {
|
||||||
|
if (!lc.treatmentDetailIds.includes(row.treatmentDetailId)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Tooth prosthesis must reference a detail included in this lab case',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const detail = detailById.get(row.treatmentDetailId);
|
||||||
|
if (!detail) {
|
||||||
|
throw new BadRequestException('Tooth prosthesis references an unknown treatment detail');
|
||||||
|
}
|
||||||
|
const teeth = normalizeTeeth(detail.teeth);
|
||||||
|
if (!teeth.includes(row.tooth)) {
|
||||||
|
throw new BadRequestException(`Tooth ${row.tooth} is not on the selected treatment detail`);
|
||||||
|
}
|
||||||
|
this.prosthesisCatalog.assertKnownProsthesisType(row.prosthesisTypeCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saved = await this.prisma.$transaction(async (tx) => {
|
const saved = await this.prisma.$transaction(async (tx) => {
|
||||||
@@ -395,6 +417,18 @@ export class TreatmentsService {
|
|||||||
treatmentDetailId,
|
treatmentDetailId,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.labCaseToothProsthesis.deleteMany({ where: { labCaseId: row.id } });
|
||||||
|
if (lc.toothProsthesis?.length) {
|
||||||
|
await tx.labCaseToothProsthesis.createMany({
|
||||||
|
data: lc.toothProsthesis.map((tp) => ({
|
||||||
|
labCaseId: row.id,
|
||||||
|
treatmentDetailId: tp.treatmentDetailId,
|
||||||
|
tooth: tp.tooth,
|
||||||
|
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.treatment.findUniqueOrThrow({
|
return tx.treatment.findUniqueOrThrow({
|
||||||
@@ -410,6 +444,7 @@ export class TreatmentsService {
|
|||||||
labCaseId: string,
|
labCaseId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
actorUserId: string,
|
actorUserId: string,
|
||||||
|
actorLanguage?: string | null,
|
||||||
) {
|
) {
|
||||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||||
|
|
||||||
@@ -421,7 +456,12 @@ export class TreatmentsService {
|
|||||||
include: {
|
include: {
|
||||||
treatment: { select: { providerUserId: true } },
|
treatment: { select: { providerUserId: true } },
|
||||||
sends: { select: { organizationId: true } },
|
sends: { select: { organizationId: true } },
|
||||||
details: { select: { treatmentDetailId: true } },
|
details: {
|
||||||
|
include: {
|
||||||
|
detail: { select: { id: true, treatmentType: true, teeth: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toothProsthesis: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -437,6 +477,8 @@ export class TreatmentsService {
|
|||||||
throw new BadRequestException('Lab case must include at least one treatment detail');
|
throw new BadRequestException('Lab case must include at least one treatment detail');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertCompleteToothProsthesisMap(labCase);
|
||||||
|
|
||||||
if (labCase.treatment.providerUserId !== actorUserId) {
|
if (labCase.treatment.providerUserId !== actorUserId) {
|
||||||
const membership = await this.getMembership(actorUserId, organizationId);
|
const membership = await this.getMembership(actorUserId, organizationId);
|
||||||
if (!membership?.isOwner) {
|
if (!membership?.isOwner) {
|
||||||
@@ -473,7 +515,7 @@ export class TreatmentsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await generateLabCaseTasks(tx, labCaseId);
|
await generateLabCaseTasks(tx, labCaseId, actorLanguage);
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
|
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
|
||||||
@@ -490,6 +532,7 @@ export class TreatmentsService {
|
|||||||
orderBy: [{ sentAt: 'asc' }],
|
orderBy: [{ sentAt: 'asc' }],
|
||||||
include: { organization: { select: { id: true, name: true } } },
|
include: { organization: { select: { id: true, name: true } } },
|
||||||
},
|
},
|
||||||
|
toothProsthesis: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -722,6 +765,11 @@ export class TreatmentsService {
|
|||||||
sentAt: Date;
|
sentAt: Date;
|
||||||
organization?: { id: string; name: string };
|
organization?: { id: string; name: string };
|
||||||
}>;
|
}>;
|
||||||
|
toothProsthesis?: Array<{
|
||||||
|
treatmentDetailId: string;
|
||||||
|
tooth: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
}>;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
id: lc.id,
|
id: lc.id,
|
||||||
@@ -736,6 +784,11 @@ export class TreatmentsService {
|
|||||||
treatmentType: d.detail?.treatmentType ?? '',
|
treatmentType: d.detail?.treatmentType ?? '',
|
||||||
teeth: d.detail ? normalizeTeeth(d.detail.teeth) : [],
|
teeth: d.detail ? normalizeTeeth(d.detail.teeth) : [],
|
||||||
})),
|
})),
|
||||||
|
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
|
||||||
|
treatmentDetailId: tp.treatmentDetailId,
|
||||||
|
tooth: tp.tooth,
|
||||||
|
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||||
|
})),
|
||||||
sends:
|
sends:
|
||||||
lc.sends?.map((s) => ({
|
lc.sends?.map((s) => ({
|
||||||
organizationId: s.organizationId,
|
organizationId: s.organizationId,
|
||||||
|
|||||||
@@ -330,7 +330,7 @@
|
|||||||
"treatmentDetails": "Treatment details",
|
"treatmentDetails": "Treatment details",
|
||||||
"teethLabel": "Teeth",
|
"teethLabel": "Teeth",
|
||||||
"tasksByTooth": "Tasks by tooth",
|
"tasksByTooth": "Tasks by tooth",
|
||||||
"toothGroupTitle": "Tooth {tooth} · {type}",
|
"toothGroupTitle": "Tooth {tooth} · {prosthesis} · {type}",
|
||||||
"noTasks": "No tasks were generated for this case.",
|
"noTasks": "No tasks were generated for this case.",
|
||||||
"unassigned": "Unassigned",
|
"unassigned": "Unassigned",
|
||||||
"statusPending": "Pending",
|
"statusPending": "Pending",
|
||||||
@@ -497,8 +497,14 @@
|
|||||||
"labShipmentNoIncludedDetails": "No details were included in this shipment.",
|
"labShipmentNoIncludedDetails": "No details were included in this shipment.",
|
||||||
"labShipmentNoDetailsAvailable": "All lab details are already in other shipments or have been sent.",
|
"labShipmentNoDetailsAvailable": "All lab details are already in other shipments or have been sent.",
|
||||||
"labDetailLine": "Detail {n} · {type} · {teeth}",
|
"labDetailLine": "Detail {n} · {type} · {teeth}",
|
||||||
"noLabDetails": "No lab-dependent treatment details yet. Add a lab type (e.g. endo) in treatment details above.",
|
"noLabDetails": "No prosthesis treatment details yet. Add prosthesis in treatment details above.",
|
||||||
"labDispatchEmpty": "Add a lab shipment to group details and send them to a lab.",
|
"labDispatchEmpty": "Add a lab shipment to group details and send them to a lab.",
|
||||||
|
"prosthesisTypesTitle": "Prosthesis types",
|
||||||
|
"prosthesisApplyAll": "Apply to all teeth",
|
||||||
|
"prosthesisSelectPlaceholder": "Select prosthesis type…",
|
||||||
|
"prosthesisColTooth": "Tooth",
|
||||||
|
"prosthesisColDetail": "Detail",
|
||||||
|
"prosthesisColType": "Prosthesis type",
|
||||||
"labComment": "Message for the lab",
|
"labComment": "Message for the lab",
|
||||||
"labCommentPlaceholder": "Optional instructions for this shipment…",
|
"labCommentPlaceholder": "Optional instructions for this shipment…",
|
||||||
"selectLab": "Destination lab",
|
"selectLab": "Destination lab",
|
||||||
|
|||||||
@@ -330,7 +330,7 @@
|
|||||||
"treatmentDetails": "جزئیات درمان",
|
"treatmentDetails": "جزئیات درمان",
|
||||||
"teethLabel": "دندانها",
|
"teethLabel": "دندانها",
|
||||||
"tasksByTooth": "وظایف به تفکیک دندان",
|
"tasksByTooth": "وظایف به تفکیک دندان",
|
||||||
"toothGroupTitle": "دندان {tooth} · {type}",
|
"toothGroupTitle": "دندان {tooth} · {prosthesis} · {type}",
|
||||||
"noTasks": "برای این پرونده وظیفهای ایجاد نشده است.",
|
"noTasks": "برای این پرونده وظیفهای ایجاد نشده است.",
|
||||||
"unassigned": "بدون مسئول",
|
"unassigned": "بدون مسئول",
|
||||||
"statusPending": "در انتظار",
|
"statusPending": "در انتظار",
|
||||||
@@ -497,8 +497,14 @@
|
|||||||
"labShipmentNoIncludedDetails": "جزئیاتی در این محموله گنجانده نشده است.",
|
"labShipmentNoIncludedDetails": "جزئیاتی در این محموله گنجانده نشده است.",
|
||||||
"labShipmentNoDetailsAvailable": "همه جزئیات لاب در محمولههای دیگر هستند یا ارسال شدهاند.",
|
"labShipmentNoDetailsAvailable": "همه جزئیات لاب در محمولههای دیگر هستند یا ارسال شدهاند.",
|
||||||
"labDetailLine": "جزئیات {n} · {type} · {teeth}",
|
"labDetailLine": "جزئیات {n} · {type} · {teeth}",
|
||||||
"noLabDetails": "هنوز جزئیات وابسته به لاب وجود ندارد. نوع لاب (مثلاً اندو) در جزئیات درمان بالا اضافه کنید.",
|
"noLabDetails": "هنوز جزئیات پروتز وجود ندارد. پروتز را در جزئیات درمان بالا اضافه کنید.",
|
||||||
"labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروهبندی و ارسال کنید.",
|
"labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروهبندی و ارسال کنید.",
|
||||||
|
"prosthesisTypesTitle": "انواع پروتز",
|
||||||
|
"prosthesisApplyAll": "اعمال برای همه دندانها",
|
||||||
|
"prosthesisSelectPlaceholder": "نوع پروتز را انتخاب کنید…",
|
||||||
|
"prosthesisColTooth": "دندان",
|
||||||
|
"prosthesisColDetail": "جزئیات",
|
||||||
|
"prosthesisColType": "نوع پروتز",
|
||||||
"labComment": "پیام برای لابراتوار",
|
"labComment": "پیام برای لابراتوار",
|
||||||
"labCommentPlaceholder": "دستورالعمل اختیاری برای این محموله…",
|
"labCommentPlaceholder": "دستورالعمل اختیاری برای این محموله…",
|
||||||
"selectLab": "لابراتوار مقصد",
|
"selectLab": "لابراتوار مقصد",
|
||||||
|
|||||||
@@ -330,7 +330,7 @@
|
|||||||
"treatmentDetails": "Behandeldetails",
|
"treatmentDetails": "Behandeldetails",
|
||||||
"teethLabel": "Tanden",
|
"teethLabel": "Tanden",
|
||||||
"tasksByTooth": "Taken per tand",
|
"tasksByTooth": "Taken per tand",
|
||||||
"toothGroupTitle": "Tand {tooth} · {type}",
|
"toothGroupTitle": "Tand {tooth} · {prosthesis} · {type}",
|
||||||
"noTasks": "Er zijn geen taken gegenereerd voor dit dossier.",
|
"noTasks": "Er zijn geen taken gegenereerd voor dit dossier.",
|
||||||
"unassigned": "Niet toegewezen",
|
"unassigned": "Niet toegewezen",
|
||||||
"statusPending": "In afwachting",
|
"statusPending": "In afwachting",
|
||||||
@@ -497,8 +497,14 @@
|
|||||||
"labShipmentNoIncludedDetails": "Geen details opgenomen in deze zending.",
|
"labShipmentNoIncludedDetails": "Geen details opgenomen in deze zending.",
|
||||||
"labShipmentNoDetailsAvailable": "Alle labdetails zitten al in andere zendingen of zijn verzonden.",
|
"labShipmentNoDetailsAvailable": "Alle labdetails zitten al in andere zendingen of zijn verzonden.",
|
||||||
"labDetailLine": "Detail {n} · {type} · {teeth}",
|
"labDetailLine": "Detail {n} · {type} · {teeth}",
|
||||||
"noLabDetails": "Nog geen lab-afhankelijke details. Voeg een labtype (bijv. endo) toe in de behandeldetails hierboven.",
|
"noLabDetails": "Nog geen prothese-details. Voeg prothese toe in de behandeldetails hierboven.",
|
||||||
"labDispatchEmpty": "Voeg een labzending toe om details te groeperen en naar een lab te sturen.",
|
"labDispatchEmpty": "Voeg een labzending toe om details te groeperen en naar een lab te sturen.",
|
||||||
|
"prosthesisTypesTitle": "Prothesetypes",
|
||||||
|
"prosthesisApplyAll": "Toepassen op alle tanden",
|
||||||
|
"prosthesisSelectPlaceholder": "Selecteer prothesetype…",
|
||||||
|
"prosthesisColTooth": "Tand",
|
||||||
|
"prosthesisColDetail": "Detail",
|
||||||
|
"prosthesisColType": "Prothesetype",
|
||||||
"labComment": "Bericht voor het lab",
|
"labComment": "Bericht voor het lab",
|
||||||
"labCommentPlaceholder": "Optionele instructies voor deze zending…",
|
"labCommentPlaceholder": "Optionele instructies voor deze zending…",
|
||||||
"selectLab": "Bestemmingslab",
|
"selectLab": "Bestemmingslab",
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import { useToast } from '@/lib/hooks/useToast';
|
|||||||
import { canEditCases } from '@/components/shared/permissions';
|
import { canEditCases } from '@/components/shared/permissions';
|
||||||
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
|
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
|
||||||
import { casesApi } from '@/lib/api/cases';
|
import { casesApi } from '@/lib/api/cases';
|
||||||
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||||
|
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type {
|
import type {
|
||||||
AssignableMember,
|
AssignableMember,
|
||||||
CasesFilterOptions,
|
CasesFilterOptions,
|
||||||
@@ -22,14 +25,6 @@ import type {
|
|||||||
PaginatedLabCases,
|
PaginatedLabCases,
|
||||||
} from '@/types/cases';
|
} from '@/types/cases';
|
||||||
|
|
||||||
const TREATMENT_TYPE_KEYS = {
|
|
||||||
consultation: 'typeConsultation',
|
|
||||||
filling: 'typeFilling',
|
|
||||||
endo: 'typeEndo',
|
|
||||||
visit: 'typeVisit',
|
|
||||||
hygiene: 'typeHygiene',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const;
|
const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const;
|
||||||
|
|
||||||
@@ -101,6 +96,7 @@ export default function CasesPage() {
|
|||||||
clinics: [],
|
clinics: [],
|
||||||
treatmentTypes: [],
|
treatmentTypes: [],
|
||||||
});
|
});
|
||||||
|
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
||||||
|
|
||||||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||||
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
|
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
|
||||||
@@ -113,11 +109,8 @@ export default function CasesPage() {
|
|||||||
const locale = user?.language ?? 'en';
|
const locale = user?.language ?? 'en';
|
||||||
|
|
||||||
const treatmentLabel = useCallback(
|
const treatmentLabel = useCallback(
|
||||||
(type: string) => {
|
(type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog),
|
||||||
const key = TREATMENT_TYPE_KEYS[type as keyof typeof TREATMENT_TYPE_KEYS];
|
[treatmentCatalog],
|
||||||
return key ? tTreatment(key) : type;
|
|
||||||
},
|
|
||||||
[tTreatment],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
|
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
|
||||||
@@ -179,6 +172,7 @@ export default function CasesPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
|
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
|
||||||
void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {});
|
void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {});
|
||||||
|
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -490,6 +484,7 @@ export default function CasesPage() {
|
|||||||
<div className="text-sm font-medium text-text-primary">
|
<div className="text-sm font-medium text-text-primary">
|
||||||
{t('toothGroupTitle', {
|
{t('toothGroupTitle', {
|
||||||
tooth: group.tooth,
|
tooth: group.tooth,
|
||||||
|
prosthesis: group.prosthesisTypeLabel,
|
||||||
type: treatmentLabel(group.treatmentType),
|
type: treatmentLabel(group.treatmentType),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
|||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import { tasksApi } from '@/lib/api/tasks';
|
import { tasksApi } from '@/lib/api/tasks';
|
||||||
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||||
|
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
|
import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
|
||||||
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@ export default function TasksPage() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||||
|
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
||||||
|
|
||||||
const canView = canViewTasks(currentOrganization);
|
const canView = canViewTasks(currentOrganization);
|
||||||
const canEdit = canEditTasks(currentOrganization);
|
const canEdit = canEditTasks(currentOrganization);
|
||||||
@@ -64,6 +68,10 @@ export default function TasksPage() {
|
|||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canView) return;
|
if (!canView) return;
|
||||||
|
|
||||||
@@ -166,6 +174,7 @@ export default function TasksPage() {
|
|||||||
<p className="text-[11px] text-text-secondary truncate">
|
<p className="text-[11px] text-text-secondary truncate">
|
||||||
{t('fromClinic', { name: task.clinic.name })} ·{' '}
|
{t('fromClinic', { name: task.clinic.name })} ·{' '}
|
||||||
{formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })}
|
{formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })}
|
||||||
|
{task.prosthesisTypeLabel ? ` · ${task.prosthesisTypeLabel}` : ''}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-text-muted truncate flex flex-wrap items-center gap-x-1 gap-y-0.5">
|
<p className="text-[11px] text-text-muted truncate flex flex-wrap items-center gap-x-1 gap-y-0.5">
|
||||||
<span>{t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}</span>
|
<span>{t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}</span>
|
||||||
@@ -213,7 +222,10 @@ export default function TasksPage() {
|
|||||||
<Badge variant="default" fixedWidth={false}>
|
<Badge variant="default" fixedWidth={false}>
|
||||||
{t('priorityLabel', { n: task.priority })}
|
{t('priorityLabel', { n: task.priority })}
|
||||||
</Badge>
|
</Badge>
|
||||||
<TreatmentTypeBadge type={task.treatmentType} />
|
<TreatmentTypeBadge
|
||||||
|
type={task.treatmentType}
|
||||||
|
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,20 +6,15 @@ import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
|||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import { organizationApi } from '@/lib/api/organization';
|
import { organizationApi } from '@/lib/api/organization';
|
||||||
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||||
|
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
|
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
import type { CounterpartItemDto } from '@/lib/api/organization';
|
import type { CounterpartItemDto } from '@/lib/api/organization';
|
||||||
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
|
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
|
||||||
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
const TREATMENT_TYPE_KEYS = {
|
|
||||||
consultation: 'typeConsultation',
|
|
||||||
filling: 'typeFilling',
|
|
||||||
endo: 'typeEndo',
|
|
||||||
visit: 'typeVisit',
|
|
||||||
hygiene: 'typeHygiene',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
@@ -78,7 +73,6 @@ export function ConnectionCaseHistoryContent({
|
|||||||
}: ConnectionCaseHistoryContentProps) {
|
}: ConnectionCaseHistoryContentProps) {
|
||||||
const t = useTranslations('organizations');
|
const t = useTranslations('organizations');
|
||||||
const tCases = useTranslations('cases');
|
const tCases = useTranslations('cases');
|
||||||
const tTreatment = useTranslations('treatment');
|
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { currentOrganization, user } = useAuth();
|
const { currentOrganization, user } = useAuth();
|
||||||
const { showError, setError, messages: toastMessages } = useToast();
|
const { showError, setError, messages: toastMessages } = useToast();
|
||||||
@@ -94,6 +88,7 @@ export function ConnectionCaseHistoryContent({
|
|||||||
});
|
});
|
||||||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||||
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
|
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
|
||||||
|
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
||||||
const [loadingList, setLoadingList] = useState(false);
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
|
|
||||||
@@ -104,13 +99,14 @@ export function ConnectionCaseHistoryContent({
|
|||||||
tRef.current = t;
|
tRef.current = t;
|
||||||
|
|
||||||
const treatmentLabel = useCallback(
|
const treatmentLabel = useCallback(
|
||||||
(type: string) => {
|
(type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog),
|
||||||
const key = TREATMENT_TYPE_KEYS[type as keyof typeof TREATMENT_TYPE_KEYS];
|
[treatmentCatalog],
|
||||||
return key ? tTreatment(key) : type;
|
|
||||||
},
|
|
||||||
[tTreatment],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
|
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ value: 'PENDING', label: tCases('statusPending') },
|
{ value: 'PENDING', label: tCases('statusPending') },
|
||||||
@@ -375,6 +371,7 @@ export function ConnectionCaseHistoryContent({
|
|||||||
<div className="text-sm font-medium text-text-primary">
|
<div className="text-sm font-medium text-text-primary">
|
||||||
{tCases('toothGroupTitle', {
|
{tCases('toothGroupTitle', {
|
||||||
tooth: group.tooth,
|
tooth: group.tooth,
|
||||||
|
prosthesis: group.prosthesisTypeLabel,
|
||||||
type: treatmentLabel(group.treatmentType),
|
type: treatmentLabel(group.treatmentType),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,16 +6,10 @@ import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeSty
|
|||||||
import { Card } from '@/components/ui/shared/Card';
|
import { Card } from '@/components/ui/shared/Card';
|
||||||
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
|
import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
|
||||||
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
|
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||||
|
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type { TreatmentAppointment } from '@/types/treatment';
|
import type { TreatmentAppointment } from '@/types/treatment';
|
||||||
|
|
||||||
const TREATMENT_TYPE_KEYS = {
|
|
||||||
consultation: 'typeConsultation',
|
|
||||||
filling: 'typeFilling',
|
|
||||||
endo: 'typeEndo',
|
|
||||||
visit: 'typeVisit',
|
|
||||||
hygiene: 'typeHygiene',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
interface AppointmentsStripProps {
|
interface AppointmentsStripProps {
|
||||||
stripHidden: boolean;
|
stripHidden: boolean;
|
||||||
onToggleStripHidden: () => void;
|
onToggleStripHidden: () => void;
|
||||||
@@ -24,6 +18,7 @@ interface AppointmentsStripProps {
|
|||||||
appointments: TreatmentAppointment[];
|
appointments: TreatmentAppointment[];
|
||||||
selectedAppointmentId: string | null;
|
selectedAppointmentId: string | null;
|
||||||
onSelectAppointment: (id: string) => void;
|
onSelectAppointment: (id: string) => void;
|
||||||
|
treatmentCatalog: TreatmentCatalogEntry[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +30,7 @@ export function AppointmentsStrip({
|
|||||||
appointments,
|
appointments,
|
||||||
selectedAppointmentId,
|
selectedAppointmentId,
|
||||||
onSelectAppointment,
|
onSelectAppointment,
|
||||||
|
treatmentCatalog,
|
||||||
loading = false,
|
loading = false,
|
||||||
}: AppointmentsStripProps) {
|
}: AppointmentsStripProps) {
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
@@ -96,7 +92,7 @@ export function AppointmentsStrip({
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})}`;
|
})}`;
|
||||||
const palette = purposeStyle(a.purpose);
|
const palette = purposeStyle(a.purpose);
|
||||||
const purposeKey = TREATMENT_TYPE_KEYS[a.purpose as keyof typeof TREATMENT_TYPE_KEYS];
|
const purposeLabel = treatmentTypeLabelFromCatalog(a.purpose, treatmentCatalog);
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
as="button"
|
as="button"
|
||||||
@@ -115,9 +111,7 @@ export function AppointmentsStrip({
|
|||||||
<p className="text-sm font-medium leading-tight truncate mt-0.5">
|
<p className="text-sm font-medium leading-tight truncate mt-0.5">
|
||||||
{a.patientFirstName} {a.patientLastName}
|
{a.patientFirstName} {a.patientLastName}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] opacity-90 capitalize mt-0.5">
|
<p className="text-[11px] opacity-90 mt-0.5 truncate">{purposeLabel}</p>
|
||||||
{purposeKey ? t(purposeKey) : a.purpose}
|
|
||||||
</p>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||||
|
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
|
import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
|
||||||
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
||||||
import { TREATMENT_TYPE_KEYS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
|
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
|
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||||
|
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
|
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
|
||||||
|
|
||||||
interface LabCasesDispatchPanelProps {
|
interface LabCasesDispatchPanelProps {
|
||||||
details: TreatmentDetailDraft[];
|
details: TreatmentDetailDraft[];
|
||||||
labCases: LabCaseDraft[];
|
labCases: LabCaseDraft[];
|
||||||
labDependentCodes: Set<string>;
|
labDependentCodes: Set<string>;
|
||||||
|
treatmentCatalog: TreatmentCatalogEntry[];
|
||||||
activeLabCaseId: string | null;
|
activeLabCaseId: string | null;
|
||||||
onActiveLabCaseChange: (id: string) => void;
|
onActiveLabCaseChange: (id: string) => void;
|
||||||
onLabCasesChange: (labCases: LabCaseDraft[]) => void;
|
onLabCasesChange: (labCases: LabCaseDraft[]) => void;
|
||||||
@@ -52,7 +56,6 @@ function detailInOtherDraftShipment(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lab-dependent details not yet sent to any lab. */
|
|
||||||
function unsentLabDetails(
|
function unsentLabDetails(
|
||||||
details: TreatmentDetailDraft[],
|
details: TreatmentDetailDraft[],
|
||||||
labCases: LabCaseDraft[],
|
labCases: LabCaseDraft[],
|
||||||
@@ -62,7 +65,6 @@ function unsentLabDetails(
|
|||||||
return details.filter((d) => labDependentCodes.has(d.treatmentType) && !sent.has(d.clientId));
|
return details.filter((d) => labDependentCodes.has(d.treatmentType) && !sent.has(d.clientId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unsent lab details not already assigned to another draft shipment. */
|
|
||||||
function detailsAvailableForNewShipment(
|
function detailsAvailableForNewShipment(
|
||||||
details: TreatmentDetailDraft[],
|
details: TreatmentDetailDraft[],
|
||||||
labCases: LabCaseDraft[],
|
labCases: LabCaseDraft[],
|
||||||
@@ -73,7 +75,6 @@ function detailsAvailableForNewShipment(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Details the user can pick for the active draft shipment. */
|
|
||||||
function selectableDetailsForDraftShipment(
|
function selectableDetailsForDraftShipment(
|
||||||
details: TreatmentDetailDraft[],
|
details: TreatmentDetailDraft[],
|
||||||
labCases: LabCaseDraft[],
|
labCases: LabCaseDraft[],
|
||||||
@@ -89,10 +90,40 @@ function selectableDetailsForDraftShipment(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prosthesisTeethRows(
|
||||||
|
labCase: LabCaseDraft,
|
||||||
|
details: TreatmentDetailDraft[],
|
||||||
|
): Array<{ detailClientId: string; tooth: string; detailNumber: number }> {
|
||||||
|
const rows: Array<{ detailClientId: string; tooth: string; detailNumber: number }> = [];
|
||||||
|
for (const clientId of labCase.detailClientIds) {
|
||||||
|
const detail = details.find((d) => d.clientId === clientId);
|
||||||
|
if (!detail || detail.treatmentType !== 'prosthesis') continue;
|
||||||
|
const detailNumber = details.findIndex((d) => d.clientId === clientId) + 1;
|
||||||
|
for (const tooth of detail.teeth) {
|
||||||
|
rows.push({ detailClientId: clientId, tooth, detailNumber });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProsthesisMapComplete(labCase: LabCaseDraft, details: TreatmentDetailDraft[]): boolean {
|
||||||
|
const rows = prosthesisTeethRows(labCase, details);
|
||||||
|
if (rows.length === 0) return true;
|
||||||
|
return rows.every((row) =>
|
||||||
|
labCase.toothProsthesis.some(
|
||||||
|
(tp) =>
|
||||||
|
tp.detailClientId === row.detailClientId &&
|
||||||
|
tp.tooth === row.tooth &&
|
||||||
|
Boolean(tp.prosthesisTypeCode),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LabCasesDispatchPanel({
|
export function LabCasesDispatchPanel({
|
||||||
details,
|
details,
|
||||||
labCases,
|
labCases,
|
||||||
labDependentCodes,
|
labDependentCodes,
|
||||||
|
treatmentCatalog,
|
||||||
activeLabCaseId,
|
activeLabCaseId,
|
||||||
onActiveLabCaseChange,
|
onActiveLabCaseChange,
|
||||||
onLabCasesChange,
|
onLabCasesChange,
|
||||||
@@ -108,6 +139,9 @@ export function LabCasesDispatchPanel({
|
|||||||
onSendLabCase,
|
onSendLabCase,
|
||||||
}: LabCasesDispatchPanelProps) {
|
}: LabCasesDispatchPanelProps) {
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
|
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
|
||||||
|
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
|
||||||
|
|
||||||
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
||||||
const filteredOrganizations = (() => {
|
const filteredOrganizations = (() => {
|
||||||
const q = organizationSearch.trim().toLowerCase();
|
const q = organizationSearch.trim().toLowerCase();
|
||||||
@@ -136,17 +170,39 @@ export function LabCasesDispatchPanel({
|
|||||||
? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
|
? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const prosthesisRows = activeLabCase ? prosthesisTeethRows(activeLabCase, details) : [];
|
||||||
|
const prosthesisComplete = activeLabCase
|
||||||
|
? isProsthesisMapComplete(activeLabCase, details)
|
||||||
|
: true;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeLabCase?.destinationOrganizationId) {
|
||||||
|
setProsthesisOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void prosthesisCatalogApi
|
||||||
|
.list(activeLabCase.destinationOrganizationId)
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setProsthesisOptions(res.data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setProsthesisOptions([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [activeLabCase?.destinationOrganizationId]);
|
||||||
|
|
||||||
function detailNumber(d: TreatmentDetailDraft) {
|
function detailNumber(d: TreatmentDetailDraft) {
|
||||||
const idx = details.findIndex((row) => row.clientId === d.clientId);
|
const idx = details.findIndex((row) => row.clientId === d.clientId);
|
||||||
return idx >= 0 ? idx + 1 : 0;
|
return idx >= 0 ? idx + 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailSummary(d: TreatmentDetailDraft) {
|
function detailSummary(d: TreatmentDetailDraft) {
|
||||||
const typeKey = treatmentTypeLabelKey(d.treatmentType);
|
const typeLabel = treatmentTypeLabelFromCatalog(d.treatmentType, treatmentCatalog);
|
||||||
const typeLabel =
|
|
||||||
d.treatmentType in TREATMENT_TYPE_KEYS
|
|
||||||
? t(typeKey as 'typeEndo')
|
|
||||||
: d.treatmentType;
|
|
||||||
const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
|
const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
|
||||||
return `${t('detailLabel', { n: detailNumber(d) })} · ${typeLabel} · ${teeth}`;
|
return `${t('detailLabel', { n: detailNumber(d) })} · ${typeLabel} · ${teeth}`;
|
||||||
}
|
}
|
||||||
@@ -158,6 +214,31 @@ export function LabCasesDispatchPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setToothProsthesis(
|
||||||
|
detailClientId: string,
|
||||||
|
tooth: string,
|
||||||
|
prosthesisTypeCode: string,
|
||||||
|
) {
|
||||||
|
if (!activeLabCase) return;
|
||||||
|
const rest = activeLabCase.toothProsthesis.filter(
|
||||||
|
(tp) => !(tp.detailClientId === detailClientId && tp.tooth === tooth),
|
||||||
|
);
|
||||||
|
const next = prosthesisTypeCode
|
||||||
|
? [...rest, { detailClientId, tooth, prosthesisTypeCode }]
|
||||||
|
: rest;
|
||||||
|
updateActiveLabCase({ toothProsthesis: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyProsthesisToAll(code: string) {
|
||||||
|
if (!activeLabCase || !code) return;
|
||||||
|
const next = prosthesisRows.map((row) => ({
|
||||||
|
detailClientId: row.detailClientId,
|
||||||
|
tooth: row.tooth,
|
||||||
|
prosthesisTypeCode: code,
|
||||||
|
}));
|
||||||
|
updateActiveLabCase({ toothProsthesis: next });
|
||||||
|
}
|
||||||
|
|
||||||
function toggleDetailInActiveLabCase(detailClientId: string, checked: boolean) {
|
function toggleDetailInActiveLabCase(detailClientId: string, checked: boolean) {
|
||||||
if (!activeLabCase || sent) return;
|
if (!activeLabCase || sent) return;
|
||||||
|
|
||||||
@@ -169,7 +250,12 @@ export function LabCasesDispatchPanel({
|
|||||||
const set = new Set(lc.detailClientIds);
|
const set = new Set(lc.detailClientIds);
|
||||||
if (checked) set.add(detailClientId);
|
if (checked) set.add(detailClientId);
|
||||||
else set.delete(detailClientId);
|
else set.delete(detailClientId);
|
||||||
return { ...lc, detailClientIds: [...set] };
|
|
||||||
|
const keptProsthesis = lc.toothProsthesis.filter((tp) =>
|
||||||
|
[...set].includes(tp.detailClientId),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...lc, detailClientIds: [...set], toothProsthesis: keptProsthesis };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checked) {
|
if (checked) {
|
||||||
@@ -375,11 +461,14 @@ export function LabCasesDispatchPanel({
|
|||||||
)}
|
)}
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={activeLabCase.destinationOrganizationId ?? ''}
|
value={activeLabCase.destinationOrganizationId ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
|
const nextOrgId = e.target.value || null;
|
||||||
updateActiveLabCase({
|
updateActiveLabCase({
|
||||||
destinationOrganizationId: e.target.value || null,
|
destinationOrganizationId: nextOrgId,
|
||||||
})
|
toothProsthesis: [],
|
||||||
}
|
});
|
||||||
|
setApplyAllProsthesis('');
|
||||||
|
}}
|
||||||
disabled={disabled || filteredOrganizations.length === 0}
|
disabled={disabled || filteredOrganizations.length === 0}
|
||||||
>
|
>
|
||||||
<option value="">{t('selectLabPlaceholder')}</option>
|
<option value="">{t('selectLabPlaceholder')}</option>
|
||||||
@@ -394,6 +483,84 @@ export function LabCasesDispatchPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{prosthesisRows.length > 0 && activeLabCase.destinationOrganizationId ? (
|
||||||
|
<div className="space-y-3 border-t border-border/60 pt-3">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">
|
||||||
|
{t('prosthesisTypesTitle')}
|
||||||
|
</p>
|
||||||
|
<label className="block text-xs text-text-muted space-y-1">
|
||||||
|
{t('prosthesisApplyAll')}
|
||||||
|
<select
|
||||||
|
value={applyAllProsthesis}
|
||||||
|
disabled={disabled || prosthesisOptions.length === 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
const code = e.target.value;
|
||||||
|
setApplyAllProsthesis(code);
|
||||||
|
if (code) applyProsthesisToAll(code);
|
||||||
|
}}
|
||||||
|
className={`${FORM_SELECT_CLASS} w-full mt-1`}
|
||||||
|
>
|
||||||
|
<option value="">{t('prosthesisSelectPlaceholder')}</option>
|
||||||
|
{prosthesisOptions.map((opt) => (
|
||||||
|
<option key={opt.code} value={opt.code}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-text-muted">
|
||||||
|
<th className="pb-2 pr-3 font-medium">{t('prosthesisColTooth')}</th>
|
||||||
|
<th className="pb-2 pr-3 font-medium">{t('prosthesisColDetail')}</th>
|
||||||
|
<th className="pb-2 font-medium">{t('prosthesisColType')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{prosthesisRows.map((row) => {
|
||||||
|
const current =
|
||||||
|
activeLabCase.toothProsthesis.find(
|
||||||
|
(tp) =>
|
||||||
|
tp.detailClientId === row.detailClientId &&
|
||||||
|
tp.tooth === row.tooth,
|
||||||
|
)?.prosthesisTypeCode ?? '';
|
||||||
|
return (
|
||||||
|
<tr key={`${row.detailClientId}-${row.tooth}`} className="border-t border-border/40">
|
||||||
|
<td className="py-2 pr-3 text-text-primary">{row.tooth}</td>
|
||||||
|
<td className="py-2 pr-3 text-text-secondary">
|
||||||
|
{t('detailLabel', { n: row.detailNumber })}
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<select
|
||||||
|
value={current}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
setToothProsthesis(
|
||||||
|
row.detailClientId,
|
||||||
|
row.tooth,
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={`${FORM_SELECT_CLASS} w-full min-w-[160px]`}
|
||||||
|
>
|
||||||
|
<option value="">{t('prosthesisSelectPlaceholder')}</option>
|
||||||
|
{prosthesisOptions.map((opt) => (
|
||||||
|
<option key={opt.code} value={opt.code}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 pt-1">
|
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -402,7 +569,8 @@ export function LabCasesDispatchPanel({
|
|||||||
disabled ||
|
disabled ||
|
||||||
sendBusyId === activeLabCase.clientId ||
|
sendBusyId === activeLabCase.clientId ||
|
||||||
!activeLabCase.destinationOrganizationId ||
|
!activeLabCase.destinationOrganizationId ||
|
||||||
activeLabCase.detailClientIds.length === 0
|
activeLabCase.detailClientIds.length === 0 ||
|
||||||
|
!prosthesisComplete
|
||||||
}
|
}
|
||||||
isLoading={sendBusyId === activeLabCase.clientId}
|
isLoading={sendBusyId === activeLabCase.clientId}
|
||||||
onClick={() => onSendLabCase(activeLabCase)}
|
onClick={() => onSendLabCase(activeLabCase)}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { TreatmentHistoryDetailLine } from '@/components/ui/treatment/TreatmentHistoryDetailLine';
|
import { TreatmentHistoryDetailLine } from '@/components/ui/treatment/TreatmentHistoryDetailLine';
|
||||||
import type { PastTreatment } from '@/types/treatment';
|
import type { PastTreatment } from '@/types/treatment';
|
||||||
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
interface PastTreatmentsPanelProps {
|
interface PastTreatmentsPanelProps {
|
||||||
items: PastTreatment[];
|
items: PastTreatment[];
|
||||||
|
treatmentCatalog: TreatmentCatalogEntry[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
selectedPreviewId?: string | null;
|
selectedPreviewId?: string | null;
|
||||||
onSelectTreatment?: (treatment: PastTreatment) => void;
|
onSelectTreatment?: (treatment: PastTreatment) => void;
|
||||||
@@ -13,6 +15,7 @@ interface PastTreatmentsPanelProps {
|
|||||||
|
|
||||||
export function PastTreatmentsPanel({
|
export function PastTreatmentsPanel({
|
||||||
items,
|
items,
|
||||||
|
treatmentCatalog,
|
||||||
loading,
|
loading,
|
||||||
selectedPreviewId,
|
selectedPreviewId,
|
||||||
onSelectTreatment,
|
onSelectTreatment,
|
||||||
@@ -78,6 +81,7 @@ export function PastTreatmentsPanel({
|
|||||||
<TreatmentHistoryDetailLine
|
<TreatmentHistoryDetailLine
|
||||||
detail={detail}
|
detail={detail}
|
||||||
detailNumber={idx + 1}
|
detailNumber={idx + 1}
|
||||||
|
treatmentCatalog={treatmentCatalog}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
|
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
|
||||||
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
|
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
|
||||||
|
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment';
|
import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment';
|
||||||
|
|
||||||
interface TreatmentDetailSummaryRowProps {
|
interface TreatmentDetailSummaryRowProps {
|
||||||
detail: PastTreatmentDetail;
|
detail: PastTreatmentDetail;
|
||||||
detailNumber: number;
|
detailNumber: number;
|
||||||
labDependentCodes: Set<string>;
|
labDependentCodes: Set<string>;
|
||||||
|
treatmentCatalog: TreatmentCatalogEntry[];
|
||||||
orgs?: LinkedOrganizationOption[];
|
orgs?: LinkedOrganizationOption[];
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
@@ -17,6 +20,7 @@ export function TreatmentDetailSummaryRow({
|
|||||||
detail,
|
detail,
|
||||||
detailNumber,
|
detailNumber,
|
||||||
labDependentCodes,
|
labDependentCodes,
|
||||||
|
treatmentCatalog,
|
||||||
orgs,
|
orgs,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: TreatmentDetailSummaryRowProps) {
|
}: TreatmentDetailSummaryRowProps) {
|
||||||
@@ -35,7 +39,10 @@ export function TreatmentDetailSummaryRow({
|
|||||||
<span className={`text-text-muted tabular-nums ${compact ? 'text-[11px]' : 'text-xs'}`}>
|
<span className={`text-text-muted tabular-nums ${compact ? 'text-[11px]' : 'text-xs'}`}>
|
||||||
{t('detailLabel', { n: detailNumber })}
|
{t('detailLabel', { n: detailNumber })}
|
||||||
</span>
|
</span>
|
||||||
<TreatmentTypeBadge type={detail.treatmentType} />
|
<TreatmentTypeBadge
|
||||||
|
type={detail.treatmentType}
|
||||||
|
label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DetailLabSendBadge detail={detail} labDependentCodes={labDependentCodes} orgs={orgs} />
|
<DetailLabSendBadge detail={detail} labDependentCodes={labDependentCodes} orgs={orgs} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
labSentBannerClass,
|
labSentBannerClass,
|
||||||
} from '@/components/ui/treatment/treatmentStatusStyles';
|
} from '@/components/ui/treatment/treatmentStatusStyles';
|
||||||
import type { TreatmentDetailDraft } from '@/types/treatment';
|
import type { TreatmentDetailDraft } from '@/types/treatment';
|
||||||
import { TREATMENT_TYPE_COLORS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
|
|
||||||
interface TreatmentDetailsEditorProps {
|
interface TreatmentDetailsEditorProps {
|
||||||
details: TreatmentDetailDraft[];
|
details: TreatmentDetailDraft[];
|
||||||
@@ -19,6 +20,7 @@ interface TreatmentDetailsEditorProps {
|
|||||||
onDetailsChange: (details: TreatmentDetailDraft[]) => void;
|
onDetailsChange: (details: TreatmentDetailDraft[]) => void;
|
||||||
isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
|
isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
|
||||||
labDependentCodes: Set<string>;
|
labDependentCodes: Set<string>;
|
||||||
|
treatmentCatalog: TreatmentCatalogEntry[];
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
|
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
|
||||||
@@ -34,6 +36,7 @@ export function TreatmentDetailsEditor({
|
|||||||
onDetailsChange,
|
onDetailsChange,
|
||||||
isDetailLocked,
|
isDetailLocked,
|
||||||
labDependentCodes,
|
labDependentCodes,
|
||||||
|
treatmentCatalog,
|
||||||
disabled,
|
disabled,
|
||||||
canEdit,
|
canEdit,
|
||||||
saveStatus,
|
saveStatus,
|
||||||
@@ -49,7 +52,10 @@ export function TreatmentDetailsEditor({
|
|||||||
|
|
||||||
const locked = isDetailLocked(activeDetail);
|
const locked = isDetailLocked(activeDetail);
|
||||||
const readOnly = disabled || locked;
|
const readOnly = disabled || locked;
|
||||||
const treatmentTypeTextColor = TREATMENT_TYPE_COLORS[activeDetail.treatmentType];
|
const treatmentTypeTextColor = treatmentTypeColor(
|
||||||
|
activeDetail.treatmentType,
|
||||||
|
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
|
||||||
|
);
|
||||||
const isLabDependent = labDependentCodes.has(activeDetail.treatmentType);
|
const isLabDependent = labDependentCodes.has(activeDetail.treatmentType);
|
||||||
const showPendingLabHint = isLabDependent && !locked && !readOnly;
|
const showPendingLabHint = isLabDependent && !locked && !readOnly;
|
||||||
|
|
||||||
@@ -125,14 +131,20 @@ export function TreatmentDetailsEditor({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
className="capitalize"
|
|
||||||
style={{ color: treatmentTypeTextColor }}
|
style={{ color: treatmentTypeTextColor }}
|
||||||
>
|
>
|
||||||
<option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }} className="capitalize">{t('typeConsultation')}</option>
|
{treatmentCatalog.map((entry, index) => (
|
||||||
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }} className="capitalize">{t('typeFilling')}</option>
|
<option
|
||||||
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }} className="capitalize">{t('typeEndo')}</option>
|
key={entry.code}
|
||||||
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }} className="capitalize">{t('typeVisit')}</option>
|
value={entry.code}
|
||||||
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }} className="capitalize">{t('typeHygiene')}</option>
|
style={{
|
||||||
|
color: treatmentTypeColor(entry.code, index),
|
||||||
|
backgroundColor: '#14253d',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,20 @@
|
|||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
|
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
|
||||||
|
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type { PastTreatmentDetail } from '@/types/treatment';
|
import type { PastTreatmentDetail } from '@/types/treatment';
|
||||||
|
|
||||||
interface TreatmentHistoryDetailLineProps {
|
interface TreatmentHistoryDetailLineProps {
|
||||||
detail: PastTreatmentDetail;
|
detail: PastTreatmentDetail;
|
||||||
detailNumber: number;
|
detailNumber: number;
|
||||||
|
treatmentCatalog: TreatmentCatalogEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreatmentHistoryDetailLine({
|
export function TreatmentHistoryDetailLine({
|
||||||
detail,
|
detail,
|
||||||
detailNumber,
|
detailNumber,
|
||||||
|
treatmentCatalog,
|
||||||
}: TreatmentHistoryDetailLineProps) {
|
}: TreatmentHistoryDetailLineProps) {
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
const teeth = detail.teeth.length ? [...detail.teeth].sort().join(', ') : t('teethNone');
|
const teeth = detail.teeth.length ? [...detail.teeth].sort().join(', ') : t('teethNone');
|
||||||
@@ -20,7 +24,10 @@ export function TreatmentHistoryDetailLine({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 min-w-0 text-[11px] leading-tight">
|
<div className="flex items-center gap-2 min-w-0 text-[11px] leading-tight">
|
||||||
<span className="text-text-muted tabular-nums shrink-0">{detailNumber}.</span>
|
<span className="text-text-muted tabular-nums shrink-0">{detailNumber}.</span>
|
||||||
<TreatmentTypeBadge type={detail.treatmentType} />
|
<TreatmentTypeBadge
|
||||||
|
type={detail.treatmentType}
|
||||||
|
label={treatmentTypeLabelFromCatalog(detail.treatmentType, treatmentCatalog)}
|
||||||
|
/>
|
||||||
<span className="text-text-secondary truncate min-w-0">{teeth}</span>
|
<span className="text-text-secondary truncate min-w-0">{teeth}</span>
|
||||||
{attachmentCount > 0 && (
|
{attachmentCount > 0 && (
|
||||||
<span className="text-text-muted shrink-0 tabular-nums">
|
<span className="text-text-muted shrink-0 tabular-nums">
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow';
|
import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow';
|
||||||
import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
|
import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
|
||||||
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
interface TreatmentPreviewCardProps {
|
interface TreatmentPreviewCardProps {
|
||||||
treatment: PastTreatment | null;
|
treatment: PastTreatment | null;
|
||||||
labDependentCodes: Set<string>;
|
labDependentCodes: Set<string>;
|
||||||
|
treatmentCatalog: TreatmentCatalogEntry[];
|
||||||
orgs?: LinkedOrganizationOption[];
|
orgs?: LinkedOrganizationOption[];
|
||||||
openDisabled?: boolean;
|
openDisabled?: boolean;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
@@ -16,6 +18,7 @@ interface TreatmentPreviewCardProps {
|
|||||||
export function TreatmentPreviewCard({
|
export function TreatmentPreviewCard({
|
||||||
treatment,
|
treatment,
|
||||||
labDependentCodes,
|
labDependentCodes,
|
||||||
|
treatmentCatalog,
|
||||||
orgs,
|
orgs,
|
||||||
openDisabled = false,
|
openDisabled = false,
|
||||||
onOpen,
|
onOpen,
|
||||||
@@ -53,6 +56,7 @@ export function TreatmentPreviewCard({
|
|||||||
detail={detail}
|
detail={detail}
|
||||||
detailNumber={idx + 1}
|
detailNumber={idx + 1}
|
||||||
labDependentCodes={labDependentCodes}
|
labDependentCodes={labDependentCodes}
|
||||||
|
treatmentCatalog={treatmentCatalog}
|
||||||
orgs={orgs}
|
orgs={orgs}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||||
import { TREATMENT_TYPE_KEYS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
|
import { formatCodeAsLabel } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
|
|
||||||
interface TreatmentTypeBadgeProps {
|
interface TreatmentTypeBadgeProps {
|
||||||
type: string;
|
type: string;
|
||||||
|
label?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreatmentTypeBadge({ type, className = '' }: TreatmentTypeBadgeProps) {
|
export function TreatmentTypeBadge({ type, label, className = '' }: TreatmentTypeBadgeProps) {
|
||||||
const t = useTranslations('treatment');
|
const display = label ?? formatCodeAsLabel(type);
|
||||||
const typeKey = treatmentTypeLabelKey(type);
|
|
||||||
const label =
|
|
||||||
type in TREATMENT_TYPE_KEYS ? t(typeKey as 'typeEndo') : type;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center justify-center box-border rounded-md border min-h-[1.75rem] px-2.5 py-1 text-xs font-medium capitalize leading-none shrink-0 ${purposeStyle(type)} ${className}`.trim()}
|
className={`inline-flex items-center justify-center box-border rounded-md border min-h-[1.75rem] px-2.5 py-1 text-xs font-medium leading-none shrink-0 ${purposeStyle(type)} ${className}`.trim()}
|
||||||
>
|
>
|
||||||
{label}
|
{display}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPan
|
|||||||
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
|
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
|
||||||
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
|
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
|
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
import {
|
import {
|
||||||
addCalendarDays,
|
addCalendarDays,
|
||||||
compareLocalDayStart,
|
compareLocalDayStart,
|
||||||
@@ -25,6 +25,7 @@ import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
|||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import type { Organization } from '@/types/organization';
|
import type { Organization } from '@/types/organization';
|
||||||
import type { AppointmentRecord } from '@/types/appointment';
|
import type { AppointmentRecord } from '@/types/appointment';
|
||||||
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type {
|
import type {
|
||||||
FdiToothId,
|
FdiToothId,
|
||||||
LabCaseDraft,
|
LabCaseDraft,
|
||||||
@@ -93,7 +94,7 @@ function newDetail(): TreatmentDetailDraft {
|
|||||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
treatmentType: 'consultation',
|
treatmentType: 'restoration',
|
||||||
teeth: [],
|
teeth: [],
|
||||||
comment: '',
|
comment: '',
|
||||||
attachmentMetas: [],
|
attachmentMetas: [],
|
||||||
@@ -111,6 +112,7 @@ function newLabCaseDraft(): LabCaseDraft {
|
|||||||
destinationOrganizationId: null,
|
destinationOrganizationId: null,
|
||||||
labComment: '',
|
labComment: '',
|
||||||
detailClientIds: [],
|
detailClientIds: [],
|
||||||
|
toothProsthesis: [],
|
||||||
sentAt: null,
|
sentAt: null,
|
||||||
sends: [],
|
sends: [],
|
||||||
};
|
};
|
||||||
@@ -145,12 +147,20 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
|
function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
|
||||||
|
const detailClientById = new Map(lc.details.map((d) => [d.id, d.clientId]));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clientId: lc.clientId,
|
clientId: lc.clientId,
|
||||||
id: lc.id,
|
id: lc.id,
|
||||||
destinationOrganizationId: lc.destinationOrganizationId,
|
destinationOrganizationId: lc.destinationOrganizationId,
|
||||||
labComment: lc.labComment ?? '',
|
labComment: lc.labComment ?? '',
|
||||||
detailClientIds: lc.details.map((d) => d.clientId),
|
detailClientIds: lc.details.map((d) => d.clientId),
|
||||||
|
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
|
||||||
|
detailClientId:
|
||||||
|
detailClientById.get(tp.treatmentDetailId) ?? tp.treatmentDetailId,
|
||||||
|
tooth: tp.tooth,
|
||||||
|
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||||
|
})),
|
||||||
sentAt: lc.sentAt ?? null,
|
sentAt: lc.sentAt ?? null,
|
||||||
sends: lc.sends ?? [],
|
sends: lc.sends ?? [],
|
||||||
};
|
};
|
||||||
@@ -231,6 +241,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
|
|
||||||
const [orgs, setOrgs] = useState<LinkedOrganizationOption[]>([]);
|
const [orgs, setOrgs] = useState<LinkedOrganizationOption[]>([]);
|
||||||
const [labDependentCodes, setLabDependentCodes] = useState<Set<string>>(new Set());
|
const [labDependentCodes, setLabDependentCodes] = useState<Set<string>>(new Set());
|
||||||
|
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
||||||
|
|
||||||
const [details, setDetails] = useState<TreatmentDetailDraft[]>(() => [newDetail()]);
|
const [details, setDetails] = useState<TreatmentDetailDraft[]>(() => [newDetail()]);
|
||||||
const [labCaseDrafts, setLabCaseDrafts] = useState<LabCaseDraft[]>([]);
|
const [labCaseDrafts, setLabCaseDrafts] = useState<LabCaseDraft[]>([]);
|
||||||
@@ -414,6 +425,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setOrgs(orgsResponse.data);
|
setOrgs(orgsResponse.data);
|
||||||
|
setTreatmentCatalog(catalogResponse.data);
|
||||||
setLabDependentCodes(
|
setLabDependentCodes(
|
||||||
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
|
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
|
||||||
);
|
);
|
||||||
@@ -765,6 +777,17 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
treatmentDetailIds: lc.detailClientIds
|
treatmentDetailIds: lc.detailClientIds
|
||||||
.map((clientId) => detailIdByClientId.get(clientId))
|
.map((clientId) => detailIdByClientId.get(clientId))
|
||||||
.filter((id): id is string => Boolean(id)),
|
.filter((id): id is string => Boolean(id)),
|
||||||
|
toothProsthesis: lc.toothProsthesis
|
||||||
|
.map((tp) => {
|
||||||
|
const detailId = detailIdByClientId.get(tp.detailClientId);
|
||||||
|
if (!detailId) return null;
|
||||||
|
return {
|
||||||
|
treatmentDetailId: detailId,
|
||||||
|
tooth: tp.tooth,
|
||||||
|
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((row): row is { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string } => row !== null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (payload.length === 0) {
|
if (payload.length === 0) {
|
||||||
@@ -881,6 +904,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
appointments={appointments}
|
appointments={appointments}
|
||||||
selectedAppointmentId={selectedAppointmentId}
|
selectedAppointmentId={selectedAppointmentId}
|
||||||
onSelectAppointment={onPickAppointment}
|
onSelectAppointment={onPickAppointment}
|
||||||
|
treatmentCatalog={treatmentCatalog}
|
||||||
loading={apptsLoading}
|
loading={apptsLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -906,10 +930,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-text-secondary">
|
<p className="text-[11px] text-text-secondary">
|
||||||
{t('purposeLabel')}{' '}
|
{t('purposeLabel')}{' '}
|
||||||
<span className="capitalize text-text-primary">
|
<span className="text-text-primary">
|
||||||
{t(
|
{treatmentTypeLabelFromCatalog(selectedAppointment.purpose, treatmentCatalog)}
|
||||||
treatmentTypeLabelKey(selectedAppointment.purpose) as 'typeConsultation',
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -922,6 +944,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
<TreatmentPreviewCard
|
<TreatmentPreviewCard
|
||||||
treatment={previewTreatment}
|
treatment={previewTreatment}
|
||||||
labDependentCodes={labDependentCodes}
|
labDependentCodes={labDependentCodes}
|
||||||
|
treatmentCatalog={treatmentCatalog}
|
||||||
orgs={orgs}
|
orgs={orgs}
|
||||||
openDisabled={isPreviewAlreadyOpen}
|
openDisabled={isPreviewAlreadyOpen}
|
||||||
onOpen={handleOpenTreatment}
|
onOpen={handleOpenTreatment}
|
||||||
@@ -929,6 +952,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
|
|
||||||
<PastTreatmentsPanel
|
<PastTreatmentsPanel
|
||||||
items={historyPanelItems}
|
items={historyPanelItems}
|
||||||
|
treatmentCatalog={treatmentCatalog}
|
||||||
loading={historyLoading}
|
loading={historyLoading}
|
||||||
selectedPreviewId={selectedPreviewId}
|
selectedPreviewId={selectedPreviewId}
|
||||||
onSelectTreatment={handleSelectPreviewTreatment}
|
onSelectTreatment={handleSelectPreviewTreatment}
|
||||||
@@ -960,6 +984,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
onDetailsChange={setDetails}
|
onDetailsChange={setDetails}
|
||||||
isDetailLocked={isDetailLocked}
|
isDetailLocked={isDetailLocked}
|
||||||
labDependentCodes={labDependentCodes}
|
labDependentCodes={labDependentCodes}
|
||||||
|
treatmentCatalog={treatmentCatalog}
|
||||||
disabled={!canEditTreatmentForDay}
|
disabled={!canEditTreatmentForDay}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
@@ -976,6 +1001,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
details={details}
|
details={details}
|
||||||
labCases={labCaseDrafts}
|
labCases={labCaseDrafts}
|
||||||
labDependentCodes={labDependentCodes}
|
labDependentCodes={labDependentCodes}
|
||||||
|
treatmentCatalog={treatmentCatalog}
|
||||||
activeLabCaseId={activeLabCaseId}
|
activeLabCaseId={activeLabCaseId}
|
||||||
onActiveLabCaseChange={setActiveLabCaseId}
|
onActiveLabCaseChange={setActiveLabCaseId}
|
||||||
onLabCasesChange={setLabCaseDrafts}
|
onLabCasesChange={setLabCaseDrafts}
|
||||||
|
|||||||
@@ -1,19 +1,41 @@
|
|||||||
export const TREATMENT_TYPE_KEYS = {
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
consultation: 'typeConsultation',
|
|
||||||
filling: 'typeFilling',
|
|
||||||
endo: 'typeEndo',
|
|
||||||
visit: 'typeVisit',
|
|
||||||
hygiene: 'typeHygiene',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const TREATMENT_TYPE_COLORS: Record<string, string> = {
|
const TREATMENT_TYPE_COLORS: Record<string, string> = {
|
||||||
consultation: '#ddd6fe',
|
restoration: '#fed7aa',
|
||||||
filling: '#fed7aa',
|
specialized_restoration: '#fdba74',
|
||||||
|
radiography: '#e2e8f0',
|
||||||
endo: '#fecaca',
|
endo: '#fecaca',
|
||||||
visit: '#bae6fd',
|
surgery: '#fca5a5',
|
||||||
hygiene: '#d9f99d',
|
prosthesis: '#c4b5fd',
|
||||||
|
implant: '#a5b4fc',
|
||||||
|
orthodontics: '#93c5fd',
|
||||||
|
perio: '#86efac',
|
||||||
|
pediatrics: '#fde68a',
|
||||||
|
extraction: '#f87171',
|
||||||
|
clinic_visit: '#bae6fd',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function treatmentTypeLabelKey(code: string): string {
|
const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d'];
|
||||||
return TREATMENT_TYPE_KEYS[code as keyof typeof TREATMENT_TYPE_KEYS] ?? code;
|
|
||||||
|
export function treatmentTypeColor(code: string, index = 0): string {
|
||||||
|
return TREATMENT_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function treatmentTypeLabelFromCatalog(
|
||||||
|
code: string,
|
||||||
|
catalog: TreatmentCatalogEntry[],
|
||||||
|
): string {
|
||||||
|
return catalog.find((e) => e.code === code)?.label ?? formatCodeAsLabel(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCodeAsLabel(code: string): string {
|
||||||
|
return code
|
||||||
|
.split('_')
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use treatmentTypeLabelFromCatalog with API catalog */
|
||||||
|
export function treatmentTypeLabelKey(code: string): string {
|
||||||
|
return `type_${code}`;
|
||||||
}
|
}
|
||||||
|
|||||||
13
frontend/src/lib/api/prosthesis-catalog.ts
Normal file
13
frontend/src/lib/api/prosthesis-catalog.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
|
export const prosthesisCatalogApi = {
|
||||||
|
list: async (
|
||||||
|
labOrganizationId?: string,
|
||||||
|
): Promise<{ success: boolean; data: ProsthesisCatalogEntry[] }> => {
|
||||||
|
const response = await apiClient.get('/prosthesis-catalog', {
|
||||||
|
params: labOrganizationId ? { labOrganizationId } : undefined,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -18,6 +18,9 @@ export interface LabCaseTask {
|
|||||||
id: string;
|
id: string;
|
||||||
tooth: string;
|
tooth: string;
|
||||||
treatmentType: string;
|
treatmentType: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
prosthesisTypeLabel: string;
|
||||||
|
workflowStepCode?: string;
|
||||||
stepOrder: number;
|
stepOrder: number;
|
||||||
stepLabel: string;
|
stepLabel: string;
|
||||||
status: LabTaskStatus;
|
status: LabTaskStatus;
|
||||||
@@ -31,6 +34,8 @@ export interface LabCaseTask {
|
|||||||
export interface LabCaseTasksByTooth {
|
export interface LabCaseTasksByTooth {
|
||||||
tooth: string;
|
tooth: string;
|
||||||
treatmentType: string;
|
treatmentType: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
prosthesisTypeLabel: string;
|
||||||
tasks: LabCaseTask[];
|
tasks: LabCaseTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +105,8 @@ export interface LabTaskListItem {
|
|||||||
labCaseId: string;
|
labCaseId: string;
|
||||||
tooth: string;
|
tooth: string;
|
||||||
treatmentType: string;
|
treatmentType: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
prosthesisTypeLabel: string;
|
||||||
stepOrder: number;
|
stepOrder: number;
|
||||||
stepLabel: string;
|
stepLabel: string;
|
||||||
status: LabTaskStatus;
|
status: LabTaskStatus;
|
||||||
|
|||||||
@@ -3,4 +3,11 @@ export interface TreatmentCatalogEntry {
|
|||||||
code: string;
|
code: string;
|
||||||
labDependent: boolean;
|
labDependent: boolean;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProsthesisCatalogEntry {
|
||||||
|
code: string;
|
||||||
|
sortOrder: number;
|
||||||
|
label: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,15 +51,14 @@ export interface TreatmentAttachmentMeta {
|
|||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TREATMENT_TYPES = [
|
export type TreatmentType = string;
|
||||||
'consultation',
|
|
||||||
'filling',
|
|
||||||
'endo',
|
|
||||||
'visit',
|
|
||||||
'hygiene',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type TreatmentType = (typeof TREATMENT_TYPES)[number];
|
export interface LabCaseToothProsthesisDraft {
|
||||||
|
/** Treatment detail client id in the UI; mapped to UUID when saving. */
|
||||||
|
detailClientId: string;
|
||||||
|
tooth: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LabCaseSendInfo {
|
export interface LabCaseSendInfo {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -99,6 +98,11 @@ export interface PastLabCase {
|
|||||||
treatmentType: string;
|
treatmentType: string;
|
||||||
teeth: FdiToothId[];
|
teeth: FdiToothId[];
|
||||||
}>;
|
}>;
|
||||||
|
toothProsthesis?: Array<{
|
||||||
|
treatmentDetailId: string;
|
||||||
|
tooth: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
}>;
|
||||||
sends?: LabCaseSendInfo[];
|
sends?: LabCaseSendInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +145,7 @@ export interface LabCaseDraft {
|
|||||||
destinationOrganizationId: string | null;
|
destinationOrganizationId: string | null;
|
||||||
labComment: string;
|
labComment: string;
|
||||||
detailClientIds: string[];
|
detailClientIds: string[];
|
||||||
|
toothProsthesis: LabCaseToothProsthesisDraft[];
|
||||||
sentAt?: string | null;
|
sentAt?: string | null;
|
||||||
sends?: LabCaseSendInfo[];
|
sends?: LabCaseSendInfo[];
|
||||||
}
|
}
|
||||||
@@ -163,6 +168,11 @@ export interface SaveLabCasePayload {
|
|||||||
destinationOrganizationId?: string;
|
destinationOrganizationId?: string;
|
||||||
labComment?: string;
|
labComment?: string;
|
||||||
treatmentDetailIds: string[];
|
treatmentDetailIds: string[];
|
||||||
|
toothProsthesis?: Array<{
|
||||||
|
treatmentDetailId: string;
|
||||||
|
tooth: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveTreatmentPayload {
|
export interface SaveTreatmentPayload {
|
||||||
@@ -185,4 +195,5 @@ export interface LabCaseResponse {
|
|||||||
teeth: string[];
|
teeth: string[];
|
||||||
}>;
|
}>;
|
||||||
sends: LabCaseSendInfo[];
|
sends: LabCaseSendInfo[];
|
||||||
|
toothProsthesis?: LabCaseToothProsthesisDraft[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user