From 8b4ef6195dadd31a5e3516369f8076eb1294a0fc Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 28 Jun 2026 15:34:56 +0330 Subject: [PATCH] feature: Phase 2- splitting the treatment schema into TreatmentDetail and LabCase. --- .../migration.sql | 99 ++++ backend/prisma/schema.prisma | 84 ++-- .../modules/treatments/dto/treatment.dto.ts | 34 +- .../treatments/treatments.controller.ts | 67 ++- .../modules/treatments/treatments.service.ts | 448 +++++++++++++----- .../components/ui/treatment/CaseSentLabel.tsx | 17 +- .../ui/treatment/PastTreatmentsPanel.tsx | 2 +- .../ui/treatment/TreatmentPreviewCard.tsx | 10 +- .../ui/treatment/TreatmentPreviewDialog.tsx | 8 +- .../ui/treatment/TreatmentWorkspace.tsx | 81 +++- frontend/src/lib/api/treatments.ts | 58 ++- frontend/src/types/treatment.ts | 73 ++- 12 files changed, 749 insertions(+), 232 deletions(-) create mode 100644 backend/prisma/migrations/20260628140000_treatment_details_lab_cases/migration.sql diff --git a/backend/prisma/migrations/20260628140000_treatment_details_lab_cases/migration.sql b/backend/prisma/migrations/20260628140000_treatment_details_lab_cases/migration.sql new file mode 100644 index 0000000..d983434 --- /dev/null +++ b/backend/prisma/migrations/20260628140000_treatment_details_lab_cases/migration.sql @@ -0,0 +1,99 @@ +-- Split treatment_cases into treatment_details + lab_cases (test data cleared). + +DELETE FROM "treatment_case_sends"; +DELETE FROM "treatment_case_attachments"; +DELETE FROM "treatment_cases"; +DELETE FROM "treatments"; + +DROP TABLE IF EXISTS "treatment_case_sends"; +DROP TABLE IF EXISTS "treatment_case_attachments"; +DROP TABLE IF EXISTS "treatment_cases"; + +CREATE TABLE "treatment_details" ( + "id" TEXT NOT NULL, + "treatmentId" TEXT NOT NULL, + "clientKey" TEXT, + "sortOrder" INTEGER NOT NULL, + "treatmentType" TEXT NOT NULL, + "teeth" JSONB NOT NULL, + "comment" TEXT, + + CONSTRAINT "treatment_details_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "treatment_detail_attachments" ( + "id" TEXT NOT NULL, + "detailId" TEXT, + "appointmentId" TEXT, + "detailClientKey" TEXT, + "fileName" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "sizeBytes" INTEGER NOT NULL, + "storagePath" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "treatment_detail_attachments_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "lab_cases" ( + "id" TEXT NOT NULL, + "treatmentId" TEXT NOT NULL, + "clientKey" TEXT, + "sortOrder" INTEGER NOT NULL, + "destinationOrganizationId" TEXT, + "labComment" TEXT, + "sentAt" TIMESTAMP(3), + + CONSTRAINT "lab_cases_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "lab_case_details" ( + "labCaseId" TEXT NOT NULL, + "treatmentDetailId" TEXT NOT NULL, + + CONSTRAINT "lab_case_details_pkey" PRIMARY KEY ("labCaseId", "treatmentDetailId") +); + +CREATE TABLE "lab_case_sends" ( + "id" TEXT NOT NULL, + "labCaseId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "lab_case_sends_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "treatment_details_treatmentId_sortOrder_idx" ON "treatment_details"("treatmentId", "sortOrder"); +CREATE INDEX "treatment_detail_attachments_appointmentId_detailClientKey_idx" ON "treatment_detail_attachments"("appointmentId", "detailClientKey"); +CREATE INDEX "treatment_detail_attachments_detailId_idx" ON "treatment_detail_attachments"("detailId"); +CREATE INDEX "lab_cases_treatmentId_sortOrder_idx" ON "lab_cases"("treatmentId", "sortOrder"); +CREATE UNIQUE INDEX "lab_case_details_treatmentDetailId_key" ON "lab_case_details"("treatmentDetailId"); +CREATE UNIQUE INDEX "lab_case_sends_labCaseId_organizationId_key" ON "lab_case_sends"("labCaseId", "organizationId"); + +ALTER TABLE "treatment_details" + ADD CONSTRAINT "treatment_details_treatmentId_fkey" + FOREIGN KEY ("treatmentId") REFERENCES "treatments"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "treatment_detail_attachments" + ADD CONSTRAINT "treatment_detail_attachments_detailId_fkey" + FOREIGN KEY ("detailId") REFERENCES "treatment_details"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_cases" + ADD CONSTRAINT "lab_cases_treatmentId_fkey" + FOREIGN KEY ("treatmentId") REFERENCES "treatments"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_details" + ADD CONSTRAINT "lab_case_details_labCaseId_fkey" + FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_details" + ADD CONSTRAINT "lab_case_details_treatmentDetailId_fkey" + FOREIGN KEY ("treatmentDetailId") REFERENCES "treatment_details"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_sends" + ADD CONSTRAINT "lab_case_sends_labCaseId_fkey" + FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_sends" + ADD CONSTRAINT "lab_case_sends_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 117d9e8..bf6f441 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -63,7 +63,7 @@ model Organization { createdPatients Patient[] @relation("PatientCreatedBy") appointments Appointment[] treatments Treatment[] - caseSends TreatmentCaseSend[] + labCaseSends LabCaseSend[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -131,7 +131,8 @@ model Treatment { organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull) - cases TreatmentCase[] + details TreatmentDetail[] + labCases LabCase[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -141,54 +142,81 @@ model Treatment { @@map("treatments") } -model TreatmentCase { - id String @id @default(uuid()) +model TreatmentDetail { + id String @id @default(uuid()) treatmentId String clientKey String? sortOrder Int treatmentType String teeth Json comment String? - sentAt DateTime? - treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) - attachments TreatmentCaseAttachment[] - sends TreatmentCaseSend[] + treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) + attachments TreatmentDetailAttachment[] + labCaseLink LabCaseDetail? @@index([treatmentId, sortOrder]) - @@map("treatment_cases") + @@map("treatment_details") } -model TreatmentCaseAttachment { - id String @id @default(uuid()) - caseId String? - appointmentId String? - caseClientKey String? - fileName String - mimeType String - sizeBytes Int - storagePath String +model TreatmentDetailAttachment { + id String @id @default(uuid()) + detailId String? + appointmentId String? + detailClientKey String? + fileName String + mimeType String + sizeBytes Int + storagePath String - case TreatmentCase? @relation(fields: [caseId], references: [id], onDelete: Cascade) + detail TreatmentDetail? @relation(fields: [detailId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) - @@index([appointmentId, caseClientKey]) - @@index([caseId]) - @@map("treatment_case_attachments") + @@index([appointmentId, detailClientKey]) + @@index([detailId]) + @@map("treatment_detail_attachments") } -model TreatmentCaseSend { +model LabCase { + id String @id @default(uuid()) + treatmentId String + clientKey String? + sortOrder Int + destinationOrganizationId String? + labComment String? + sentAt DateTime? + + treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) + details LabCaseDetail[] + sends LabCaseSend[] + + @@index([treatmentId, sortOrder]) + @@map("lab_cases") +} + +model LabCaseDetail { + labCaseId String + treatmentDetailId String @unique + + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) + + @@id([labCaseId, treatmentDetailId]) + @@map("lab_case_details") +} + +model LabCaseSend { id String @id @default(uuid()) - caseId String + labCaseId String organizationId String sentAt DateTime @default(now()) - case TreatmentCase @relation(fields: [caseId], references: [id], onDelete: Cascade) - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - @@unique([caseId, organizationId]) - @@map("treatment_case_sends") + @@unique([labCaseId, organizationId]) + @@map("lab_case_sends") } model Plan { diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts index 3d3c462..b61c3e7 100644 --- a/backend/src/modules/treatments/dto/treatment.dto.ts +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -12,7 +12,7 @@ import { Type } from 'class-transformer'; const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const; -export class SaveTreatmentCaseDto { +export class SaveTreatmentDetailDto { @IsString() @MaxLength(64) clientId: string; @@ -43,15 +43,39 @@ export class SaveTreatmentDraftDto { @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) - @Type(() => SaveTreatmentCaseDto) - cases: SaveTreatmentCaseDto[]; + @Type(() => SaveTreatmentDetailDto) + details: SaveTreatmentDetailDto[]; } -export class SendTreatmentCaseDto { +export class SaveLabCaseDto { + @IsString() + @MaxLength(64) + clientId: string; + + @IsOptional() + @IsUUID() + id?: string; + + @IsOptional() + @IsUUID() + destinationOrganizationId?: string; + + @IsOptional() + @IsString() + @MaxLength(5000) + labComment?: string; + @IsArray() @ArrayMinSize(1) @IsUUID(undefined, { each: true }) - organizationIds: string[]; + treatmentDetailIds: string[]; +} + +export class SaveTreatmentLabCasesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SaveLabCaseDto) + labCases: SaveLabCaseDto[]; } export class ListPatientTreatmentHistoryDto { diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts index 7293618..5f91bba 100644 --- a/backend/src/modules/treatments/treatments.controller.ts +++ b/backend/src/modules/treatments/treatments.controller.ts @@ -19,7 +19,10 @@ import { memoryStorage } from 'multer'; import type { Response } from 'express'; import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto'; +import { + SaveTreatmentDraftDto, + SaveTreatmentLabCasesDto, +} from './dto/treatment.dto'; import { TreatmentsService } from './treatments.service'; @ApiTags('treatments') @@ -67,7 +70,7 @@ export class TreatmentsController { } @Put('appointments/:appointmentId/draft') - @ApiOperation({ summary: 'Save draft treatment for an appointment (TAB_TREATMENT_EDIT)' }) + @ApiOperation({ summary: 'Save draft treatment details for an appointment (TAB_TREATMENT_EDIT)' }) saveDraft( @Param('appointmentId') appointmentId: string, @Body() dto: SaveTreatmentDraftDto, @@ -82,8 +85,24 @@ export class TreatmentsController { ); } - @Post('appointments/:appointmentId/cases/:caseClientKey/attachments') - @ApiOperation({ summary: 'Upload attachments for a draft case (TAB_TREATMENT_EDIT)' }) + @Put('appointments/:appointmentId/lab-cases') + @ApiOperation({ summary: 'Save lab case groupings for a draft treatment (TAB_TREATMENT_EDIT)' }) + saveLabCases( + @Param('appointmentId') appointmentId: string, + @Body() dto: SaveTreatmentLabCasesDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.saveLabCasesForAppointment( + appointmentId, + dto, + organizationId, + req.user.id, + ); + } + + @Post('appointments/:appointmentId/details/:detailClientKey/attachments') + @ApiOperation({ summary: 'Upload attachments for a draft treatment detail (TAB_TREATMENT_EDIT)' }) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { @@ -101,14 +120,39 @@ export class TreatmentsController { storage: memoryStorage(), }), ) - uploadAttachments( + uploadDetailAttachments( + @Param('appointmentId') appointmentId: string, + @Param('detailClientKey') detailClientKey: string, + @UploadedFiles() files: Express.Multer.File[], + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.uploadDetailAttachments( + appointmentId, + detailClientKey, + files, + organizationId, + req.user.id, + ); + } + + /** @deprecated Use details/:detailClientKey/attachments */ + @Post('appointments/:appointmentId/cases/:caseClientKey/attachments') + @ApiOperation({ summary: 'Legacy alias for detail attachment upload' }) + @ApiConsumes('multipart/form-data') + @UseInterceptors( + FilesInterceptor('files', 20, { + storage: memoryStorage(), + }), + ) + uploadDetailAttachmentsLegacy( @Param('appointmentId') appointmentId: string, @Param('caseClientKey') caseClientKey: string, @UploadedFiles() files: Express.Multer.File[], @Req() req: { user: { id: string; organizationId?: string } }, ) { const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); - return this.treatmentsService.uploadCaseAttachments( + return this.treatmentsService.uploadDetailAttachments( appointmentId, caseClientKey, files, @@ -136,14 +180,13 @@ export class TreatmentsController { file.stream.pipe(res); } - @Post('cases/:caseId/send') - @ApiOperation({ summary: 'Send a treatment case to linked organizations (TAB_TREATMENT_EDIT)' }) - sendCase( - @Param('caseId') caseId: string, - @Body() dto: SendTreatmentCaseDto, + @Post('lab-cases/:labCaseId/send') + @ApiOperation({ summary: 'Send a lab case to its destination organization (TAB_TREATMENT_EDIT)' }) + sendLabCase( + @Param('labCaseId') labCaseId: string, @Req() req: { user: { id: string; organizationId?: string } }, ) { const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); - return this.treatmentsService.sendCase(caseId, dto, organizationId, req.user.id); + return this.treatmentsService.sendLabCase(labCaseId, organizationId, req.user.id); } } diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index cf09b4f..b0cbe38 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -9,7 +9,10 @@ import { createReadStream, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { PrismaService } from '../../../prisma/prisma.service'; -import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto'; +import { + SaveTreatmentDraftDto, + SaveTreatmentLabCasesDto, +} from './dto/treatment.dto'; import { generateTreatmentTitle, isTreatmentType, @@ -18,10 +21,34 @@ import { } from './treatment.utils'; const treatmentInclude = { - cases: { + details: { orderBy: [{ sortOrder: 'asc' as const }], include: { attachments: { orderBy: [{ createdAt: 'asc' as const }] }, + labCaseLink: { + include: { + labCase: { + include: { + sends: { + orderBy: [{ sentAt: 'asc' as const }], + include: { organization: { select: { id: true, name: true } } }, + }, + }, + }, + }, + }, + }, + }, + labCases: { + orderBy: [{ sortOrder: 'asc' as const }], + include: { + details: { + include: { + detail: { + select: { id: true, clientKey: true, treatmentType: true, teeth: true }, + }, + }, + }, sends: { orderBy: [{ sentAt: 'asc' as const }], include: { organization: { select: { id: true, name: true } } }, @@ -80,7 +107,7 @@ export class TreatmentsService { limit = 20, ) { await this.assertCanReadTreatment(actorUserId, organizationId); - await this.ensurePatientInOrg(patientId, organizationId); + await this.ensurePatientExists(patientId); const items = await this.prisma.treatment.findMany({ where: { @@ -135,22 +162,22 @@ export class TreatmentsService { true, ); - for (const c of dto.cases) { - if (!isTreatmentType(c.treatmentType)) { - throw new BadRequestException(`Invalid treatment type: ${c.treatmentType}`); + for (const d of dto.details) { + if (!isTreatmentType(d.treatmentType)) { + throw new BadRequestException(`Invalid treatment type: ${d.treatmentType}`); } } - const normalizedCases = dto.cases.map((c, index) => ({ - ...c, + const normalizedDetails = dto.details.map((d, index) => ({ + ...d, sortOrder: index, - teeth: normalizeTeeth(c.teeth), - comment: c.comment?.trim() || null, - attachmentIds: c.attachmentIds ?? [], + teeth: normalizeTeeth(d.teeth), + comment: d.comment?.trim() || null, + attachmentIds: d.attachmentIds ?? [], })); const title = generateTreatmentTitle( - normalizedCases.map((c) => ({ treatmentType: c.treatmentType, teeth: c.teeth })), + normalizedDetails.map((d) => ({ treatmentType: d.treatmentType, teeth: d.teeth })), ); const treatment = await this.prisma.$transaction(async (tx) => { @@ -182,77 +209,80 @@ export class TreatmentsService { }, }); - const keepCaseIds = normalizedCases.map((c) => c.id).filter(Boolean) as string[]; - const existingCases = existing - ? await tx.treatmentCase.findMany({ + const keepDetailIds = normalizedDetails.map((d) => d.id).filter(Boolean) as string[]; + + const existingDetails = existing + ? await tx.treatmentDetail.findMany({ where: { treatmentId: saved.id }, - select: { id: true, sentAt: true }, + select: { id: true, labCaseLink: { select: { labCase: { select: { sentAt: true } } } } }, }) : []; - const sentCaseIds = new Set( - existingCases.filter((c) => c.sentAt).map((c) => c.id), + const lockedDetailIds = new Set( + existingDetails + .filter((d) => d.labCaseLink?.labCase.sentAt) + .map((d) => d.id), ); - const removableCaseIds = existingCases - .filter((c) => !keepCaseIds.includes(c.id) && !c.sentAt) - .map((c) => c.id); + const removableDetailIds = existingDetails + .filter((d) => !keepDetailIds.includes(d.id) && !lockedDetailIds.has(d.id)) + .map((d) => d.id); - if (removableCaseIds.length > 0) { - await tx.treatmentCase.deleteMany({ - where: { id: { in: removableCaseIds }, treatmentId: saved.id }, + if (removableDetailIds.length > 0) { + await tx.treatmentDetail.deleteMany({ + where: { id: { in: removableDetailIds }, treatmentId: saved.id }, }); } - for (const c of normalizedCases) { - if (c.id && sentCaseIds.has(c.id)) { + for (const d of normalizedDetails) { + if (d.id && lockedDetailIds.has(d.id)) { continue; } - const row = c.id - ? await tx.treatmentCase.update({ - where: { id: c.id }, + const row = d.id + ? await tx.treatmentDetail.update({ + where: { id: d.id }, data: { - clientKey: c.clientId, - sortOrder: c.sortOrder, - treatmentType: c.treatmentType, - teeth: c.teeth, - comment: c.comment, + clientKey: d.clientId, + sortOrder: d.sortOrder, + treatmentType: d.treatmentType, + teeth: d.teeth, + comment: d.comment, }, }) - : await tx.treatmentCase.create({ + : await tx.treatmentDetail.create({ data: { treatmentId: saved.id, - clientKey: c.clientId, - sortOrder: c.sortOrder, - treatmentType: c.treatmentType, - teeth: c.teeth, - comment: c.comment, + clientKey: d.clientId, + sortOrder: d.sortOrder, + treatmentType: d.treatmentType, + teeth: d.teeth, + comment: d.comment, }, }); - const allowedAttachmentIds = new Set(c.attachmentIds); - const pendingAttachments = await tx.treatmentCaseAttachment.findMany({ + const allowedAttachmentIds = new Set(d.attachmentIds); + const pendingAttachments = await tx.treatmentDetailAttachment.findMany({ where: { appointmentId: appointment.id, - caseClientKey: c.clientId, + detailClientKey: d.clientId, }, }); for (const attachment of pendingAttachments) { if (!allowedAttachmentIds.has(attachment.id)) { - await tx.treatmentCaseAttachment.delete({ where: { id: attachment.id } }); + await tx.treatmentDetailAttachment.delete({ where: { id: attachment.id } }); } else { - await tx.treatmentCaseAttachment.update({ + await tx.treatmentDetailAttachment.update({ where: { id: attachment.id }, - data: { caseId: row.id, appointmentId: null, caseClientKey: null }, + data: { detailId: row.id, appointmentId: null, detailClientKey: null }, }); } } - await tx.treatmentCaseAttachment.deleteMany({ + await tx.treatmentDetailAttachment.deleteMany({ where: { - caseId: row.id, + detailId: row.id, id: { notIn: [...allowedAttachmentIds] }, }, }); @@ -267,74 +297,191 @@ export class TreatmentsService { return { success: true, data: this.mapTreatment(treatment) }; } - async sendCase( - caseId: string, - dto: SendTreatmentCaseDto, + async saveLabCasesForAppointment( + appointmentId: string, + dto: SaveTreatmentLabCasesDto, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + const appointment = await this.ensureAppointmentProvider( + appointmentId, + organizationId, + actorUserId, + true, + ); + + const treatment = await this.prisma.treatment.findFirst({ + where: { appointmentId: appointment.id, organizationId, status: TreatmentStatus.DRAFT }, + select: { id: true }, + }); + + if (!treatment) { + throw new NotFoundException('Save treatment details before creating lab cases'); + } + + const detailIds = dto.labCases.flatMap((lc) => lc.treatmentDetailIds); + const uniqueDetailIds = new Set(detailIds); + if (uniqueDetailIds.size !== detailIds.length) { + throw new BadRequestException('Each treatment detail can belong to only one lab case'); + } + + const details = await this.prisma.treatmentDetail.findMany({ + where: { treatmentId: treatment.id, id: { in: detailIds } }, + select: { id: true }, + }); + if (details.length !== uniqueDetailIds.size) { + throw new BadRequestException('One or more treatment details were not found'); + } + + const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); + + for (const lc of dto.labCases) { + if (lc.destinationOrganizationId && !linkedOrgIds.has(lc.destinationOrganizationId)) { + throw new BadRequestException('Destination organization is not an active linked counterpart'); + } + } + + const saved = await this.prisma.$transaction(async (tx) => { + const existingLabCases = await tx.labCase.findMany({ + where: { treatmentId: treatment.id }, + select: { id: true, sentAt: true }, + }); + + const sentLabCaseIds = new Set(existingLabCases.filter((lc) => lc.sentAt).map((lc) => lc.id)); + const keepLabCaseIds = dto.labCases.map((lc) => lc.id).filter(Boolean) as string[]; + + const removableLabCaseIds = existingLabCases + .filter((lc) => !keepLabCaseIds.includes(lc.id) && !lc.sentAt) + .map((lc) => lc.id); + + if (removableLabCaseIds.length > 0) { + await tx.labCase.deleteMany({ + where: { id: { in: removableLabCaseIds }, treatmentId: treatment.id }, + }); + } + + for (const [index, lc] of dto.labCases.entries()) { + if (lc.id && sentLabCaseIds.has(lc.id)) { + continue; + } + + const row = lc.id + ? await tx.labCase.update({ + where: { id: lc.id }, + data: { + clientKey: lc.clientId, + sortOrder: index, + destinationOrganizationId: lc.destinationOrganizationId ?? null, + labComment: lc.labComment?.trim() || null, + }, + }) + : await tx.labCase.create({ + data: { + treatmentId: treatment.id, + clientKey: lc.clientId, + sortOrder: index, + destinationOrganizationId: lc.destinationOrganizationId ?? null, + labComment: lc.labComment?.trim() || null, + }, + }); + + await tx.labCaseDetail.deleteMany({ where: { labCaseId: row.id } }); + await tx.labCaseDetail.createMany({ + data: lc.treatmentDetailIds.map((treatmentDetailId) => ({ + labCaseId: row.id, + treatmentDetailId, + })), + }); + } + + return tx.treatment.findUniqueOrThrow({ + where: { id: treatment.id }, + include: treatmentInclude, + }); + }); + + return { success: true, data: this.mapTreatment(saved) }; + } + + async sendLabCase( + labCaseId: string, organizationId: string, actorUserId: string, ) { await this.assertCanEditTreatment(actorUserId, organizationId); - const treatmentCase = await this.prisma.treatmentCase.findFirst({ + const labCase = await this.prisma.labCase.findFirst({ where: { - id: caseId, + id: labCaseId, treatment: { organizationId }, }, include: { - treatment: { select: { providerUserId: true, appointmentId: true } }, + treatment: { select: { providerUserId: true } }, sends: { select: { organizationId: true } }, + details: { select: { treatmentDetailId: true } }, }, }); - if (!treatmentCase) { - throw new NotFoundException('Treatment case not found'); + if (!labCase) { + throw new NotFoundException('Lab case not found'); } - if (treatmentCase.treatment.providerUserId !== actorUserId) { + if (!labCase.destinationOrganizationId) { + throw new BadRequestException('Lab case has no destination organization'); + } + + if (labCase.details.length === 0) { + throw new BadRequestException('Lab case must include at least one treatment detail'); + } + + if (labCase.treatment.providerUserId !== actorUserId) { const membership = await this.getMembership(actorUserId, organizationId); if (!membership?.isOwner) { - throw new ForbiddenException('Only the appointment provider can send this case'); + throw new ForbiddenException('Only the appointment provider can send this lab case'); } } const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); - const uniqueTargets = [...new Set(dto.organizationIds)]; - - for (const orgId of uniqueTargets) { - if (!linkedOrgIds.has(orgId)) { - throw new BadRequestException('One or more organizations are not active linked counterparts'); - } + if (!linkedOrgIds.has(labCase.destinationOrganizationId)) { + throw new BadRequestException('Destination organization is not an active linked counterpart'); } - const alreadySent = new Set(treatmentCase.sends.map((s) => s.organizationId)); - const newTargets = uniqueTargets.filter((id) => !alreadySent.has(id)); - - if (newTargets.length === 0) { - throw new BadRequestException('Case was already sent to all selected organizations'); + const alreadySent = labCase.sends.some( + (s) => s.organizationId === labCase.destinationOrganizationId, + ); + if (alreadySent) { + throw new BadRequestException('Lab case was already sent to the destination organization'); } const now = new Date(); await this.prisma.$transaction(async (tx) => { - await tx.treatmentCaseSend.createMany({ - data: newTargets.map((organizationId) => ({ - caseId, - organizationId, - })), + await tx.labCaseSend.create({ + data: { + labCaseId, + organizationId: labCase.destinationOrganizationId!, + }, }); - if (!treatmentCase.sentAt) { - await tx.treatmentCase.update({ - where: { id: caseId }, + if (!labCase.sentAt) { + await tx.labCase.update({ + where: { id: labCaseId }, data: { sentAt: now }, }); } }); - const refreshed = await this.prisma.treatmentCase.findUniqueOrThrow({ - where: { id: caseId }, + const refreshed = await this.prisma.labCase.findUniqueOrThrow({ + where: { id: labCaseId }, include: { - attachments: { orderBy: [{ createdAt: 'asc' }] }, + details: { + include: { + detail: { + select: { id: true, clientKey: true, treatmentType: true, teeth: true }, + }, + }, + }, sends: { orderBy: [{ sentAt: 'asc' }], include: { organization: { select: { id: true, name: true } } }, @@ -342,12 +489,12 @@ export class TreatmentsService { }, }); - return { success: true, data: this.mapCase(refreshed) }; + return { success: true, data: this.mapLabCase(refreshed) }; } - async uploadCaseAttachments( + async uploadDetailAttachments( appointmentId: string, - caseClientKey: string, + detailClientKey: string, files: Express.Multer.File[], organizationId: string, actorUserId: string, @@ -355,8 +502,8 @@ export class TreatmentsService { await this.assertCanEditTreatment(actorUserId, organizationId); await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId, true); - if (!caseClientKey?.trim()) { - throw new BadRequestException('caseClientKey is required'); + if (!detailClientKey?.trim()) { + throw new BadRequestException('detailClientKey is required'); } if (!files?.length) { @@ -379,10 +526,10 @@ export class TreatmentsService { const { writeFileSync } = await import('fs'); writeFileSync(storagePath, file.buffer); - const attachment = await this.prisma.treatmentCaseAttachment.create({ + const attachment = await this.prisma.treatmentDetailAttachment.create({ data: { appointmentId, - caseClientKey, + detailClientKey, fileName: file.originalname, mimeType: file.mimetype || 'application/octet-stream', sizeBytes: file.size, @@ -403,16 +550,16 @@ export class TreatmentsService { ) { await this.assertCanReadTreatment(actorUserId, organizationId); - const attachment = await this.prisma.treatmentCaseAttachment.findFirst({ + const attachment = await this.prisma.treatmentDetailAttachment.findFirst({ where: { id: attachmentId, OR: [ - { case: { treatment: { organizationId } } }, + { detail: { treatment: { organizationId } } }, { appointmentId: { not: null } }, ], }, include: { - case: { select: { treatment: { select: { organizationId: true } } } }, + detail: { select: { treatment: { select: { organizationId: true } } } }, }, }); @@ -420,11 +567,11 @@ export class TreatmentsService { throw new NotFoundException('Attachment not found'); } - if (attachment.case && attachment.case.treatment.organizationId !== organizationId) { + if (attachment.detail && attachment.detail.treatment.organizationId !== organizationId) { throw new NotFoundException('Attachment not found'); } - if (!attachment.case && attachment.appointmentId) { + if (!attachment.detail && attachment.appointmentId) { const appointment = await this.prisma.appointment.findFirst({ where: { id: attachment.appointmentId, organizationId }, select: { id: true }, @@ -452,24 +599,51 @@ export class TreatmentsService { title: string; status: TreatmentStatus; treatmentAt: Date; - cases: Array<{ + details: Array<{ id: string; clientKey: string | null; treatmentType: string; teeth: unknown; comment: string | null; - sentAt: Date | null; attachments: Array<{ id: string; fileName: string; mimeType: string; sizeBytes: number; }>; - sends: Array<{ organizationId: string; sentAt: Date; organization: { id: string; name: string } }>; + labCaseLink?: { + labCase: { + id: string; + sentAt: Date | null; + destinationOrganizationId: string | null; + sends: Array<{ + organizationId: string; + sentAt: Date; + organization: { id: string; name: string }; + }>; + }; + } | null; + }>; + labCases: Array<{ + id: string; + clientKey: string | null; + sortOrder: number; + destinationOrganizationId: string | null; + labComment: string | null; + sentAt: Date | null; + details: Array<{ + treatmentDetailId: string; + detail: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown }; + }>; + sends: Array<{ + organizationId: string; + sentAt: Date; + organization: { id: string; name: string }; + }>; }>; }) { - const documents = treatment.cases.flatMap((c) => - c.attachments.map((a) => this.mapAttachment(a)), + const documents = treatment.details.flatMap((d) => + d.attachments.map((a) => this.mapAttachment(a)), ); return { @@ -479,41 +653,93 @@ export class TreatmentsService { title: treatment.title, treatmentAt: treatment.treatmentAt.toISOString(), status: mapTreatmentStatusForApi(treatment.status), - cases: treatment.cases.map((c) => this.mapCase(c)), + details: treatment.details.map((d) => this.mapDetail(d)), + labCases: treatment.labCases.map((lc) => this.mapLabCase(lc)), documents, }; } - private mapCase(c: { + private mapDetail(d: { id: string; clientKey?: string | null; treatmentType: string; teeth: unknown; comment?: string | null; - sentAt?: Date | null; attachments?: Array<{ id: string; fileName: string; mimeType: string; sizeBytes: number; }>; - sends?: Array<{ organizationId: string; sentAt: Date; organization?: { id: string; name: string } }>; + labCaseLink?: { + labCase: { + id: string; + sentAt: Date | null; + destinationOrganizationId: string | null; + sends: Array<{ + organizationId: string; + sentAt: Date; + organization?: { id: string; name: string }; + }>; + }; + } | null; }) { + const labCase = d.labCaseLink?.labCase; return { - id: c.id, - clientId: c.clientKey ?? c.id, - treatmentType: c.treatmentType, - teeth: normalizeTeeth(c.teeth), - notes: c.comment ?? null, - sentAt: c.sentAt?.toISOString() ?? null, - sendToOrganizationIds: c.sends?.map((s) => s.organizationId) ?? [], + id: d.id, + clientId: d.clientKey ?? d.id, + treatmentType: d.treatmentType, + teeth: normalizeTeeth(d.teeth), + notes: d.comment ?? null, + attachmentMetas: (d.attachments ?? []).map((a) => this.mapAttachment(a)), + labCaseId: labCase?.id ?? null, + sentAt: labCase?.sentAt?.toISOString() ?? null, + destinationOrganizationId: labCase?.destinationOrganizationId ?? null, sends: - c.sends?.map((s) => ({ + labCase?.sends.map((s) => ({ + organizationId: s.organizationId, + organizationName: s.organization?.name ?? 'Unknown organization', + sentAt: s.sentAt.toISOString(), + })) ?? [], + }; + } + + private mapLabCase(lc: { + id: string; + clientKey?: string | null; + sortOrder?: number; + destinationOrganizationId?: string | null; + labComment?: string | null; + sentAt?: Date | null; + details?: Array<{ + treatmentDetailId: string; + detail?: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown }; + }>; + sends?: Array<{ + organizationId: string; + sentAt: Date; + organization?: { id: string; name: string }; + }>; + }) { + return { + id: lc.id, + clientId: lc.clientKey ?? lc.id, + destinationOrganizationId: lc.destinationOrganizationId ?? null, + labComment: lc.labComment ?? null, + sentAt: lc.sentAt?.toISOString() ?? null, + treatmentDetailIds: lc.details?.map((d) => d.treatmentDetailId) ?? [], + details: (lc.details ?? []).map((d) => ({ + id: d.detail?.id ?? d.treatmentDetailId, + clientId: d.detail?.clientKey ?? d.treatmentDetailId, + treatmentType: d.detail?.treatmentType ?? '', + teeth: d.detail ? normalizeTeeth(d.detail.teeth) : [], + })), + sends: + lc.sends?.map((s) => ({ organizationId: s.organizationId, organizationName: s.organization?.name ?? 'Unknown organization', sentAt: s.sentAt.toISOString(), })) ?? [], - attachmentMetas: (c.attachments ?? []).map((a) => this.mapAttachment(a)), }; } @@ -549,7 +775,7 @@ export class TreatmentsService { ]); } - private async ensurePatientInOrg(patientId: string, _organizationId: string) { + private async ensurePatientExists(patientId: string) { const patient = await this.prisma.patient.findUnique({ where: { id: patientId }, select: { id: true }, diff --git a/frontend/src/components/ui/treatment/CaseSentLabel.tsx b/frontend/src/components/ui/treatment/CaseSentLabel.tsx index 281cbe6..f36df8a 100644 --- a/frontend/src/components/ui/treatment/CaseSentLabel.tsx +++ b/frontend/src/components/ui/treatment/CaseSentLabel.tsx @@ -2,21 +2,26 @@ import { useTranslations } from 'next-intl'; import { formatCaseSentLines } from '@/components/treatment/caseSendLabel'; -import type { LinkedOrganizationOption, PastTreatmentCase, TreatmentCaseDraft } from '@/types/treatment'; +import type { LabCaseSendInfo, LinkedOrganizationOption } from '@/types/treatment'; interface CaseSentLabelProps { - treatmentCase: Pick< - PastTreatmentCase | TreatmentCaseDraft, - 'sends' | 'sendToOrganizationIds' | 'sentAt' - >; + treatmentCase: { + sends?: LabCaseSendInfo[]; + sendToOrganizationIds?: string[]; + destinationOrganizationId?: string | null; + sentAt?: string | null; + }; orgs?: LinkedOrganizationOption[]; className?: string; } export function CaseSentLabel({ treatmentCase, orgs, className = 'text-xs text-text-muted' }: CaseSentLabelProps) { const t = useTranslations('treatment'); + const organizationIds = + treatmentCase.sendToOrganizationIds ?? + (treatmentCase.destinationOrganizationId ? [treatmentCase.destinationOrganizationId] : []); const lines = formatCaseSentLines(treatmentCase.sends, { - organizationIds: treatmentCase.sendToOrganizationIds ?? [], + organizationIds, sentAt: treatmentCase.sentAt ?? null, orgs, }, t); diff --git a/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx index 68031bf..e46997d 100644 --- a/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx +++ b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx @@ -64,7 +64,7 @@ export function PastTreatmentsPanel({
- {treatment.cases.map((c, idx) => { + {treatment.details.map((c, idx) => { const attachments = c.attachmentMetas ?? []; const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS]; const typeLabel = typeKey ? t(typeKey) : c.treatmentType; diff --git a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx index 27c51a9..9f8f425 100644 --- a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx +++ b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx @@ -22,7 +22,7 @@ export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPr const t = useTranslations('treatment'); const attachmentCount = draft - ? draft.cases.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0) + ? draft.details.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0) : 0; return ( @@ -42,11 +42,11 @@ export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPr {draft.status}

- {t('caseCount', { n: draft.cases.length })} ·{' '} + {t('caseCount', { n: draft.details.length })} ·{' '} {t('attachmentCount', { n: attachmentCount })}

- {draft.cases.slice(0, 2).map((c, idx) => { + {draft.details.slice(0, 2).map((c, idx) => { const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS]; const typeLabel = typeKey ? t(typeKey) : c.treatmentType; return ( @@ -65,8 +65,8 @@ export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPr
); })} - {draft.cases.length > 2 && ( -

{t('moreCases', { n: draft.cases.length - 2 })}

+ {draft.details.length > 2 && ( +

{t('moreCases', { n: draft.details.length - 2 })}

)} diff --git a/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx b/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx index 038b569..568a590 100644 --- a/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx +++ b/frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx @@ -94,18 +94,20 @@ export function TreatmentPreviewDialog({ {t('statusLabel')} {treatment.status}

- {treatment.cases.length === 0 ? ( + {treatment.details.length === 0 ? (

{t('noCases')}

) : (
- {treatment.cases.map((c, idx) => { + {treatment.details.map((c, idx) => { const key = caseKey(c); const attachments = c.attachmentMetas ?? []; const latestAttachment = attachments.length > 0 ? attachments[attachments.length - 1] : null; const sent = Boolean(c.sentAt); const actionsEnabled = editable && !sent; - const selectedOrgIds = getCaseOrgIds?.(key) ?? c.sendToOrganizationIds ?? []; + const selectedOrgIds = + getCaseOrgIds?.(key) ?? + (c.destinationOrganizationId ? [c.destinationOrganizationId] : []); const sendExpanded = expandedSendCaseId === key; const comment = c.notes?.trim() ?? ''; const attachBusy = uploadBusyCaseId === key; diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 1fc9e66..265a427 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -71,17 +71,18 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment { }; } -function mapCaseFromApi(c: PastTreatmentCase): TreatmentCaseDraft { +function mapDetailFromApi(d: PastTreatmentCase): TreatmentCaseDraft { return { - clientId: c.clientId, - id: c.id, - treatmentType: c.treatmentType, - teeth: c.teeth, - comment: c.notes ?? '', - attachmentMetas: c.attachmentMetas ?? [], - sendToOrganizationIds: c.sendToOrganizationIds ?? [], - sends: c.sends ?? [], - sentAt: c.sentAt ?? null, + clientId: d.clientId, + id: d.id, + treatmentType: d.treatmentType, + teeth: d.teeth, + comment: d.notes ?? '', + attachmentMetas: d.attachmentMetas ?? [], + labCaseId: d.labCaseId ?? null, + sendToOrganizationIds: d.destinationOrganizationId ? [d.destinationOrganizationId] : [], + sends: d.sends ?? [], + sentAt: d.sentAt ?? null, }; } @@ -110,16 +111,19 @@ function casesToPreviewTreatment( title: meta.title, treatmentAt: meta.treatmentAt, status: meta.status, - cases: cases.map((c, idx) => ({ + details: cases.map((c, idx) => ({ id: c.id ?? c.clientId ?? `draft-${idx + 1}`, clientId: c.clientId, treatmentType: c.treatmentType, teeth: c.teeth, notes: c.comment || null, attachmentMetas: c.attachmentMetas, - sendToOrganizationIds: c.sendToOrganizationIds, + labCaseId: c.labCaseId ?? null, + destinationOrganizationId: c.sendToOrganizationIds[0] ?? null, + sends: c.sends ?? [], sentAt: c.sentAt ?? null, })), + labCases: [], documents: [], }; } @@ -305,8 +309,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor const response = await treatmentsApi.getDraft(appointmentId); if (cancelled) return; - if (response.data?.cases?.length) { - const mapped = response.data.cases.map(mapCaseFromApi); + if (response.data?.details?.length) { + const mapped = response.data.details.map(mapDetailFromApi); setCases(mapped); setActiveCaseId((prev) => { const stillExists = mapped.some((c) => c.clientId === prev); @@ -387,7 +391,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor if (!selectedAppointment) throw new Error('No appointment selected'); const response = await treatmentsApi.saveDraft(selectedAppointment.id, { - cases: cases.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({ + details: cases.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({ clientId, id, treatmentType, @@ -396,7 +400,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor attachmentIds: attachmentMetas.map((a) => a.id), })), }); - const mapped = response.data.cases.map(mapCaseFromApi); + const mapped = response.data.details.map(mapDetailFromApi); setCases(mapped); setActiveCaseId((prev) => { const stillExists = mapped.some((c) => c.clientId === prev); @@ -422,28 +426,57 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor const handleSendCase = useCallback( async (treatmentCase: TreatmentCaseDraft) => { if (!canEditTreatmentForDay || !selectedAppointment) return; - const targets = treatmentCase.sendToOrganizationIds.filter((id) => + const destinationOrgId = treatmentCase.sendToOrganizationIds.find((id) => orgs.some((o) => o.id === id && o.active), ); - if (targets.length === 0) { + if (!destinationOrgId) { showError(t('errorChooseOrg')); return; } setSendBusyId(treatmentCase.clientId); try { const saved = await persistDraft(); - const serverCase = saved.cases.find((c) => c.clientId === treatmentCase.clientId); - if (!serverCase?.id) throw new Error(t('errorCaseMustSave')); + const serverDetail = saved.details.find((c) => c.clientId === treatmentCase.clientId); + if (!serverDetail?.id) throw new Error(t('errorCaseMustSave')); + + const labCaseClientId = treatmentCase.labCaseId + ? saved.labCases.find((lc) => lc.id === treatmentCase.labCaseId)?.clientId + : `lab-${treatmentCase.clientId}`; + + const existingLabCase = saved.labCases.find( + (lc) => + lc.treatmentDetailIds.includes(serverDetail.id) && + !lc.sentAt, + ); + + const withLabCases = await treatmentsApi.saveLabCases(selectedAppointment.id, { + labCases: [ + { + clientId: existingLabCase?.clientId ?? labCaseClientId ?? `lab-${treatmentCase.clientId}`, + id: existingLabCase?.id ?? treatmentCase.labCaseId ?? undefined, + destinationOrganizationId: destinationOrgId, + treatmentDetailIds: [serverDetail.id], + }, + ], + }); + + const labCase = withLabCases.data.labCases.find((lc) => + lc.treatmentDetailIds.includes(serverDetail.id), + ); + if (!labCase?.id) throw new Error(t('errorSendCase')); + + const response = await treatmentsApi.sendLabCase(labCase.id); - const response = await treatmentsApi.sendCase(serverCase.id, { organizationIds: targets }); setCases((prev) => { const next = prev.map((c) => c.clientId === treatmentCase.clientId ? { ...c, - id: response.data.id, + labCaseId: response.data.id, sentAt: response.data.sentAt, - sendToOrganizationIds: response.data.sendToOrganizationIds, + sendToOrganizationIds: response.data.destinationOrganizationId + ? [response.data.destinationOrganizationId] + : [], sends: response.data.sends, } : c, @@ -452,7 +485,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor return next; }); setRecentOrganizationIds((prev) => { - const next = [...targets.filter((id) => !prev.includes(id)), ...prev]; + const next = [destinationOrgId, ...prev.filter((id) => id !== destinationOrgId)]; return next.slice(0, 10); }); showSuccess(t('successCaseSent')); diff --git a/frontend/src/lib/api/treatments.ts b/frontend/src/lib/api/treatments.ts index eaea2b5..6dbb17d 100644 --- a/frontend/src/lib/api/treatments.ts +++ b/frontend/src/lib/api/treatments.ts @@ -1,11 +1,10 @@ import { apiClient } from './client'; import type { + LabCaseResponse, LinkedOrganizationOption, PastTreatment, - SaveTreatmentPayload, - SendTreatmentCasePayload, - TreatmentAttachmentMeta, - TreatmentCaseSendInfo, + SaveLabCasePayload, + SavedTreatmentDetailPayload, } from '@/types/treatment'; export const treatmentsApi = { @@ -33,34 +32,53 @@ export const treatmentsApi = { saveDraft: async ( appointmentId: string, - payload: Pick, + payload: { details: SavedTreatmentDetailPayload[] }, ): Promise<{ success: boolean; data: PastTreatment }> => { const response = await apiClient.put(`/treatments/appointments/${appointmentId}/draft`, payload); return response.data; }, - uploadCaseAttachments: async ( + saveLabCases: async ( appointmentId: string, - caseClientId: string, + payload: { labCases: SaveLabCasePayload[] }, + ): Promise<{ success: boolean; data: PastTreatment }> => { + const response = await apiClient.put( + `/treatments/appointments/${appointmentId}/lab-cases`, + payload, + ); + return response.data; + }, + + uploadDetailAttachments: async ( + appointmentId: string, + detailClientId: string, files: File[], - ): Promise<{ success: boolean; data: TreatmentAttachmentMeta[] }> => { + ): Promise<{ success: boolean; data: import('@/types/treatment').TreatmentAttachmentMeta[] }> => { const form = new FormData(); for (const file of files) { form.append('files', file); } const response = await apiClient.post( - `/treatments/appointments/${appointmentId}/cases/${encodeURIComponent(caseClientId)}/attachments`, + `/treatments/appointments/${appointmentId}/details/${encodeURIComponent(detailClientId)}/attachments`, form, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120_000 }, ); return response.data; }, - sendCase: async ( - caseId: string, - payload: SendTreatmentCasePayload, - ): Promise<{ success: boolean; data: PastTreatmentCaseResponse }> => { - const response = await apiClient.post(`/treatments/cases/${caseId}/send`, payload); + /** @deprecated Use uploadDetailAttachments */ + uploadCaseAttachments: async ( + appointmentId: string, + detailClientId: string, + files: File[], + ) => { + return treatmentsApi.uploadDetailAttachments(appointmentId, detailClientId, files); + }, + + sendLabCase: async ( + labCaseId: string, + ): Promise<{ success: boolean; data: LabCaseResponse }> => { + const response = await apiClient.post(`/treatments/lab-cases/${labCaseId}/send`); return response.data; }, @@ -72,15 +90,3 @@ export const treatmentsApi = { return response.data; }, }; - -export interface PastTreatmentCaseResponse { - id: string; - clientId: string; - treatmentType: string; - teeth: string[]; - notes: string | null; - sentAt: string | null; - sendToOrganizationIds: string[]; - sends: TreatmentCaseSendInfo[]; - attachmentMetas: TreatmentAttachmentMeta[]; -} diff --git a/frontend/src/types/treatment.ts b/frontend/src/types/treatment.ts index 275f71f..ca00506 100644 --- a/frontend/src/types/treatment.ts +++ b/frontend/src/types/treatment.ts @@ -61,24 +61,47 @@ export const TREATMENT_TYPES = [ export type TreatmentType = (typeof TREATMENT_TYPES)[number]; -export interface TreatmentCaseSendInfo { +export interface LabCaseSendInfo { organizationId: string; organizationName: string; sentAt: string; } -export interface PastTreatmentCase { +/** @deprecated Use LabCaseSendInfo */ +export type TreatmentCaseSendInfo = LabCaseSendInfo; + +export interface PastTreatmentDetail { id: string; clientId: string; treatmentType: TreatmentType; teeth: FdiToothId[]; notes?: string | null; attachmentMetas?: TreatmentAttachmentMeta[]; - sendToOrganizationIds?: string[]; - sends?: TreatmentCaseSendInfo[]; + labCaseId?: string | null; + destinationOrganizationId?: string | null; + sends?: LabCaseSendInfo[]; sentAt?: string | null; } +/** @deprecated Use PastTreatmentDetail */ +export type PastTreatmentCase = PastTreatmentDetail; + +export interface PastLabCase { + id: string; + clientId: string; + destinationOrganizationId: string | null; + labComment?: string | null; + sentAt?: string | null; + treatmentDetailIds: string[]; + details: Array<{ + id: string; + clientId: string; + treatmentType: string; + teeth: FdiToothId[]; + }>; + sends?: LabCaseSendInfo[]; +} + export interface PastTreatment { id: string; patientId: string; @@ -86,7 +109,8 @@ export interface PastTreatment { title: string; treatmentAt: string; status: string; - cases: PastTreatmentCase[]; + details: PastTreatmentDetail[]; + labCases: PastLabCase[]; documents: TreatmentAttachmentMeta[]; } @@ -96,19 +120,23 @@ export interface LinkedOrganizationOption { active: boolean; } -export interface TreatmentCaseDraft { +export interface TreatmentDetailDraft { clientId: string; id?: string; treatmentType: TreatmentType; teeth: FdiToothId[]; comment: string; attachmentMetas: TreatmentAttachmentMeta[]; + labCaseId?: string | null; sendToOrganizationIds: string[]; - sends?: TreatmentCaseSendInfo[]; + sends?: LabCaseSendInfo[]; sentAt?: string | null; } -export type SavedTreatmentCasePayload = { +/** @deprecated Use TreatmentDetailDraft — kept for editor components until Phase 4 rename */ +export type TreatmentCaseDraft = TreatmentDetailDraft; + +export type SavedTreatmentDetailPayload = { clientId: string; id?: string; treatmentType: TreatmentType; @@ -117,12 +145,35 @@ export type SavedTreatmentCasePayload = { attachmentIds: string[]; }; +/** @deprecated Use SavedTreatmentDetailPayload */ +export type SavedTreatmentCasePayload = SavedTreatmentDetailPayload; + +export interface SaveLabCasePayload { + clientId: string; + id?: string; + destinationOrganizationId?: string; + labComment?: string; + treatmentDetailIds: string[]; +} + export interface SaveTreatmentPayload { appointmentId: string; patientId: string; - cases: SavedTreatmentCasePayload[]; + details: SavedTreatmentDetailPayload[]; } -export interface SendTreatmentCasePayload { - organizationIds: string[]; +export interface LabCaseResponse { + id: string; + clientId: string; + destinationOrganizationId: string | null; + labComment: string | null; + sentAt: string | null; + treatmentDetailIds: string[]; + details: Array<{ + id: string; + clientId: string; + treatmentType: string; + teeth: string[]; + }>; + sends: LabCaseSendInfo[]; }