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[];
}