feature: Phase 2- splitting the treatment schema into TreatmentDetail and LabCase.

This commit is contained in:
2026-06-28 15:34:56 +03:30
parent dc965b2528
commit 8b4ef6195d
12 changed files with 749 additions and 232 deletions

View File

@@ -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;

View File

@@ -63,7 +63,7 @@ model Organization {
createdPatients Patient[] @relation("PatientCreatedBy") createdPatients Patient[] @relation("PatientCreatedBy")
appointments Appointment[] appointments Appointment[]
treatments Treatment[] treatments Treatment[]
caseSends TreatmentCaseSend[] labCaseSends LabCaseSend[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -131,7 +131,8 @@ model Treatment {
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull) appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull)
cases TreatmentCase[] details TreatmentDetail[]
labCases LabCase[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -141,7 +142,7 @@ model Treatment {
@@map("treatments") @@map("treatments")
} }
model TreatmentCase { model TreatmentDetail {
id String @id @default(uuid()) id String @id @default(uuid())
treatmentId String treatmentId String
clientKey String? clientKey String?
@@ -149,46 +150,73 @@ model TreatmentCase {
treatmentType String treatmentType String
teeth Json teeth Json
comment String? comment String?
sentAt DateTime?
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
attachments TreatmentCaseAttachment[] attachments TreatmentDetailAttachment[]
sends TreatmentCaseSend[] labCaseLink LabCaseDetail?
@@index([treatmentId, sortOrder]) @@index([treatmentId, sortOrder])
@@map("treatment_cases") @@map("treatment_details")
} }
model TreatmentCaseAttachment { model TreatmentDetailAttachment {
id String @id @default(uuid()) id String @id @default(uuid())
caseId String? detailId String?
appointmentId String? appointmentId String?
caseClientKey String? detailClientKey String?
fileName String fileName String
mimeType String mimeType String
sizeBytes Int sizeBytes Int
storagePath String storagePath String
case TreatmentCase? @relation(fields: [caseId], references: [id], onDelete: Cascade) detail TreatmentDetail? @relation(fields: [detailId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([appointmentId, caseClientKey]) @@index([appointmentId, detailClientKey])
@@index([caseId]) @@index([detailId])
@@map("treatment_case_attachments") @@map("treatment_detail_attachments")
} }
model TreatmentCaseSend { model LabCase {
id String @id @default(uuid()) id String @id @default(uuid())
caseId String 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())
labCaseId String
organizationId String organizationId String
sentAt DateTime @default(now()) sentAt DateTime @default(now())
case TreatmentCase @relation(fields: [caseId], references: [id], onDelete: Cascade) labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@unique([caseId, organizationId]) @@unique([labCaseId, organizationId])
@@map("treatment_case_sends") @@map("lab_case_sends")
} }
model Plan { model Plan {

View File

@@ -12,7 +12,7 @@ import { Type } from 'class-transformer';
const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const; const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const;
export class SaveTreatmentCaseDto { export class SaveTreatmentDetailDto {
@IsString() @IsString()
@MaxLength(64) @MaxLength(64)
clientId: string; clientId: string;
@@ -43,15 +43,39 @@ export class SaveTreatmentDraftDto {
@IsArray() @IsArray()
@ArrayMinSize(1) @ArrayMinSize(1)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => SaveTreatmentCaseDto) @Type(() => SaveTreatmentDetailDto)
cases: SaveTreatmentCaseDto[]; 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() @IsArray()
@ArrayMinSize(1) @ArrayMinSize(1)
@IsUUID(undefined, { each: true }) @IsUUID(undefined, { each: true })
organizationIds: string[]; treatmentDetailIds: string[];
}
export class SaveTreatmentLabCasesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => SaveLabCaseDto)
labCases: SaveLabCaseDto[];
} }
export class ListPatientTreatmentHistoryDto { export class ListPatientTreatmentHistoryDto {

View File

@@ -19,7 +19,10 @@ import { memoryStorage } from 'multer';
import type { Response } from 'express'; import type { Response } from 'express';
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard'; import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.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'; import { TreatmentsService } from './treatments.service';
@ApiTags('treatments') @ApiTags('treatments')
@@ -67,7 +70,7 @@ export class TreatmentsController {
} }
@Put('appointments/:appointmentId/draft') @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( saveDraft(
@Param('appointmentId') appointmentId: string, @Param('appointmentId') appointmentId: string,
@Body() dto: SaveTreatmentDraftDto, @Body() dto: SaveTreatmentDraftDto,
@@ -82,8 +85,24 @@ export class TreatmentsController {
); );
} }
@Post('appointments/:appointmentId/cases/:caseClientKey/attachments') @Put('appointments/:appointmentId/lab-cases')
@ApiOperation({ summary: 'Upload attachments for a draft case (TAB_TREATMENT_EDIT)' }) @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') @ApiConsumes('multipart/form-data')
@ApiBody({ @ApiBody({
schema: { schema: {
@@ -101,14 +120,39 @@ export class TreatmentsController {
storage: memoryStorage(), 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('appointmentId') appointmentId: string,
@Param('caseClientKey') caseClientKey: string, @Param('caseClientKey') caseClientKey: string,
@UploadedFiles() files: Express.Multer.File[], @UploadedFiles() files: Express.Multer.File[],
@Req() req: { user: { id: string; organizationId?: string } }, @Req() req: { user: { id: string; organizationId?: string } },
) { ) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.uploadCaseAttachments( return this.treatmentsService.uploadDetailAttachments(
appointmentId, appointmentId,
caseClientKey, caseClientKey,
files, files,
@@ -136,14 +180,13 @@ export class TreatmentsController {
file.stream.pipe(res); file.stream.pipe(res);
} }
@Post('cases/:caseId/send') @Post('lab-cases/:labCaseId/send')
@ApiOperation({ summary: 'Send a treatment case to linked organizations (TAB_TREATMENT_EDIT)' }) @ApiOperation({ summary: 'Send a lab case to its destination organization (TAB_TREATMENT_EDIT)' })
sendCase( sendLabCase(
@Param('caseId') caseId: string, @Param('labCaseId') labCaseId: string,
@Body() dto: SendTreatmentCaseDto,
@Req() req: { user: { id: string; organizationId?: string } }, @Req() req: { user: { id: string; organizationId?: string } },
) { ) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); 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);
} }
} }

View File

@@ -9,7 +9,10 @@ import { createReadStream, existsSync, mkdirSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto'; import {
SaveTreatmentDraftDto,
SaveTreatmentLabCasesDto,
} from './dto/treatment.dto';
import { import {
generateTreatmentTitle, generateTreatmentTitle,
isTreatmentType, isTreatmentType,
@@ -18,10 +21,34 @@ import {
} from './treatment.utils'; } from './treatment.utils';
const treatmentInclude = { const treatmentInclude = {
cases: { details: {
orderBy: [{ sortOrder: 'asc' as const }], orderBy: [{ sortOrder: 'asc' as const }],
include: { include: {
attachments: { orderBy: [{ createdAt: 'asc' as const }] }, 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: { sends: {
orderBy: [{ sentAt: 'asc' as const }], orderBy: [{ sentAt: 'asc' as const }],
include: { organization: { select: { id: true, name: true } } }, include: { organization: { select: { id: true, name: true } } },
@@ -80,7 +107,7 @@ export class TreatmentsService {
limit = 20, limit = 20,
) { ) {
await this.assertCanReadTreatment(actorUserId, organizationId); await this.assertCanReadTreatment(actorUserId, organizationId);
await this.ensurePatientInOrg(patientId, organizationId); await this.ensurePatientExists(patientId);
const items = await this.prisma.treatment.findMany({ const items = await this.prisma.treatment.findMany({
where: { where: {
@@ -135,22 +162,22 @@ export class TreatmentsService {
true, true,
); );
for (const c of dto.cases) { for (const d of dto.details) {
if (!isTreatmentType(c.treatmentType)) { if (!isTreatmentType(d.treatmentType)) {
throw new BadRequestException(`Invalid treatment type: ${c.treatmentType}`); throw new BadRequestException(`Invalid treatment type: ${d.treatmentType}`);
} }
} }
const normalizedCases = dto.cases.map((c, index) => ({ const normalizedDetails = dto.details.map((d, index) => ({
...c, ...d,
sortOrder: index, sortOrder: index,
teeth: normalizeTeeth(c.teeth), teeth: normalizeTeeth(d.teeth),
comment: c.comment?.trim() || null, comment: d.comment?.trim() || null,
attachmentIds: c.attachmentIds ?? [], attachmentIds: d.attachmentIds ?? [],
})); }));
const title = generateTreatmentTitle( 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) => { 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 keepDetailIds = normalizedDetails.map((d) => d.id).filter(Boolean) as string[];
const existingCases = existing
? await tx.treatmentCase.findMany({ const existingDetails = existing
? await tx.treatmentDetail.findMany({
where: { treatmentId: saved.id }, where: { treatmentId: saved.id },
select: { id: true, sentAt: true }, select: { id: true, labCaseLink: { select: { labCase: { select: { sentAt: true } } } } },
}) })
: []; : [];
const sentCaseIds = new Set( const lockedDetailIds = new Set(
existingCases.filter((c) => c.sentAt).map((c) => c.id), existingDetails
.filter((d) => d.labCaseLink?.labCase.sentAt)
.map((d) => d.id),
); );
const removableCaseIds = existingCases const removableDetailIds = existingDetails
.filter((c) => !keepCaseIds.includes(c.id) && !c.sentAt) .filter((d) => !keepDetailIds.includes(d.id) && !lockedDetailIds.has(d.id))
.map((c) => c.id); .map((d) => d.id);
if (removableCaseIds.length > 0) { if (removableDetailIds.length > 0) {
await tx.treatmentCase.deleteMany({ await tx.treatmentDetail.deleteMany({
where: { id: { in: removableCaseIds }, treatmentId: saved.id }, where: { id: { in: removableDetailIds }, treatmentId: saved.id },
}); });
} }
for (const c of normalizedCases) { for (const d of normalizedDetails) {
if (c.id && sentCaseIds.has(c.id)) { if (d.id && lockedDetailIds.has(d.id)) {
continue; continue;
} }
const row = c.id const row = d.id
? await tx.treatmentCase.update({ ? await tx.treatmentDetail.update({
where: { id: c.id }, where: { id: d.id },
data: { data: {
clientKey: c.clientId, clientKey: d.clientId,
sortOrder: c.sortOrder, sortOrder: d.sortOrder,
treatmentType: c.treatmentType, treatmentType: d.treatmentType,
teeth: c.teeth, teeth: d.teeth,
comment: c.comment, comment: d.comment,
}, },
}) })
: await tx.treatmentCase.create({ : await tx.treatmentDetail.create({
data: { data: {
treatmentId: saved.id, treatmentId: saved.id,
clientKey: c.clientId, clientKey: d.clientId,
sortOrder: c.sortOrder, sortOrder: d.sortOrder,
treatmentType: c.treatmentType, treatmentType: d.treatmentType,
teeth: c.teeth, teeth: d.teeth,
comment: c.comment, comment: d.comment,
}, },
}); });
const allowedAttachmentIds = new Set(c.attachmentIds); const allowedAttachmentIds = new Set(d.attachmentIds);
const pendingAttachments = await tx.treatmentCaseAttachment.findMany({ const pendingAttachments = await tx.treatmentDetailAttachment.findMany({
where: { where: {
appointmentId: appointment.id, appointmentId: appointment.id,
caseClientKey: c.clientId, detailClientKey: d.clientId,
}, },
}); });
for (const attachment of pendingAttachments) { for (const attachment of pendingAttachments) {
if (!allowedAttachmentIds.has(attachment.id)) { if (!allowedAttachmentIds.has(attachment.id)) {
await tx.treatmentCaseAttachment.delete({ where: { id: attachment.id } }); await tx.treatmentDetailAttachment.delete({ where: { id: attachment.id } });
} else { } else {
await tx.treatmentCaseAttachment.update({ await tx.treatmentDetailAttachment.update({
where: { id: attachment.id }, 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: { where: {
caseId: row.id, detailId: row.id,
id: { notIn: [...allowedAttachmentIds] }, id: { notIn: [...allowedAttachmentIds] },
}, },
}); });
@@ -267,74 +297,191 @@ export class TreatmentsService {
return { success: true, data: this.mapTreatment(treatment) }; return { success: true, data: this.mapTreatment(treatment) };
} }
async sendCase( async saveLabCasesForAppointment(
caseId: string, appointmentId: string,
dto: SendTreatmentCaseDto, 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, organizationId: string,
actorUserId: string, actorUserId: string,
) { ) {
await this.assertCanEditTreatment(actorUserId, organizationId); await this.assertCanEditTreatment(actorUserId, organizationId);
const treatmentCase = await this.prisma.treatmentCase.findFirst({ const labCase = await this.prisma.labCase.findFirst({
where: { where: {
id: caseId, id: labCaseId,
treatment: { organizationId }, treatment: { organizationId },
}, },
include: { include: {
treatment: { select: { providerUserId: true, appointmentId: true } }, treatment: { select: { providerUserId: true } },
sends: { select: { organizationId: true } }, sends: { select: { organizationId: true } },
details: { select: { treatmentDetailId: true } },
}, },
}); });
if (!treatmentCase) { if (!labCase) {
throw new NotFoundException('Treatment case not found'); 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); const membership = await this.getMembership(actorUserId, organizationId);
if (!membership?.isOwner) { 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 linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
const uniqueTargets = [...new Set(dto.organizationIds)]; if (!linkedOrgIds.has(labCase.destinationOrganizationId)) {
throw new BadRequestException('Destination organization is not an active linked counterpart');
for (const orgId of uniqueTargets) {
if (!linkedOrgIds.has(orgId)) {
throw new BadRequestException('One or more organizations are not active linked counterparts');
}
} }
const alreadySent = new Set(treatmentCase.sends.map((s) => s.organizationId)); const alreadySent = labCase.sends.some(
const newTargets = uniqueTargets.filter((id) => !alreadySent.has(id)); (s) => s.organizationId === labCase.destinationOrganizationId,
);
if (newTargets.length === 0) { if (alreadySent) {
throw new BadRequestException('Case was already sent to all selected organizations'); throw new BadRequestException('Lab case was already sent to the destination organization');
} }
const now = new Date(); const now = new Date();
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await tx.treatmentCaseSend.createMany({ await tx.labCaseSend.create({
data: newTargets.map((organizationId) => ({ data: {
caseId, labCaseId,
organizationId, organizationId: labCase.destinationOrganizationId!,
})), },
}); });
if (!treatmentCase.sentAt) { if (!labCase.sentAt) {
await tx.treatmentCase.update({ await tx.labCase.update({
where: { id: caseId }, where: { id: labCaseId },
data: { sentAt: now }, data: { sentAt: now },
}); });
} }
}); });
const refreshed = await this.prisma.treatmentCase.findUniqueOrThrow({ const refreshed = await this.prisma.labCase.findUniqueOrThrow({
where: { id: caseId }, where: { id: labCaseId },
include: { include: {
attachments: { orderBy: [{ createdAt: 'asc' }] }, details: {
include: {
detail: {
select: { id: true, clientKey: true, treatmentType: true, teeth: true },
},
},
},
sends: { sends: {
orderBy: [{ sentAt: 'asc' }], orderBy: [{ sentAt: 'asc' }],
include: { organization: { select: { id: true, name: true } } }, 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, appointmentId: string,
caseClientKey: string, detailClientKey: string,
files: Express.Multer.File[], files: Express.Multer.File[],
organizationId: string, organizationId: string,
actorUserId: string, actorUserId: string,
@@ -355,8 +502,8 @@ export class TreatmentsService {
await this.assertCanEditTreatment(actorUserId, organizationId); await this.assertCanEditTreatment(actorUserId, organizationId);
await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId, true); await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId, true);
if (!caseClientKey?.trim()) { if (!detailClientKey?.trim()) {
throw new BadRequestException('caseClientKey is required'); throw new BadRequestException('detailClientKey is required');
} }
if (!files?.length) { if (!files?.length) {
@@ -379,10 +526,10 @@ export class TreatmentsService {
const { writeFileSync } = await import('fs'); const { writeFileSync } = await import('fs');
writeFileSync(storagePath, file.buffer); writeFileSync(storagePath, file.buffer);
const attachment = await this.prisma.treatmentCaseAttachment.create({ const attachment = await this.prisma.treatmentDetailAttachment.create({
data: { data: {
appointmentId, appointmentId,
caseClientKey, detailClientKey,
fileName: file.originalname, fileName: file.originalname,
mimeType: file.mimetype || 'application/octet-stream', mimeType: file.mimetype || 'application/octet-stream',
sizeBytes: file.size, sizeBytes: file.size,
@@ -403,16 +550,16 @@ export class TreatmentsService {
) { ) {
await this.assertCanReadTreatment(actorUserId, organizationId); await this.assertCanReadTreatment(actorUserId, organizationId);
const attachment = await this.prisma.treatmentCaseAttachment.findFirst({ const attachment = await this.prisma.treatmentDetailAttachment.findFirst({
where: { where: {
id: attachmentId, id: attachmentId,
OR: [ OR: [
{ case: { treatment: { organizationId } } }, { detail: { treatment: { organizationId } } },
{ appointmentId: { not: null } }, { appointmentId: { not: null } },
], ],
}, },
include: { 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'); 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'); throw new NotFoundException('Attachment not found');
} }
if (!attachment.case && attachment.appointmentId) { if (!attachment.detail && attachment.appointmentId) {
const appointment = await this.prisma.appointment.findFirst({ const appointment = await this.prisma.appointment.findFirst({
where: { id: attachment.appointmentId, organizationId }, where: { id: attachment.appointmentId, organizationId },
select: { id: true }, select: { id: true },
@@ -452,24 +599,51 @@ export class TreatmentsService {
title: string; title: string;
status: TreatmentStatus; status: TreatmentStatus;
treatmentAt: Date; treatmentAt: Date;
cases: Array<{ details: Array<{
id: string; id: string;
clientKey: string | null; clientKey: string | null;
treatmentType: string; treatmentType: string;
teeth: unknown; teeth: unknown;
comment: string | null; comment: string | null;
sentAt: Date | null;
attachments: Array<{ attachments: Array<{
id: string; id: string;
fileName: string; fileName: string;
mimeType: string; mimeType: string;
sizeBytes: number; 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) => const documents = treatment.details.flatMap((d) =>
c.attachments.map((a) => this.mapAttachment(a)), d.attachments.map((a) => this.mapAttachment(a)),
); );
return { return {
@@ -479,41 +653,93 @@ export class TreatmentsService {
title: treatment.title, title: treatment.title,
treatmentAt: treatment.treatmentAt.toISOString(), treatmentAt: treatment.treatmentAt.toISOString(),
status: mapTreatmentStatusForApi(treatment.status), 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, documents,
}; };
} }
private mapCase(c: { private mapDetail(d: {
id: string; id: string;
clientKey?: string | null; clientKey?: string | null;
treatmentType: string; treatmentType: string;
teeth: unknown; teeth: unknown;
comment?: string | null; comment?: string | null;
sentAt?: Date | null;
attachments?: Array<{ attachments?: Array<{
id: string; id: string;
fileName: string; fileName: string;
mimeType: string; mimeType: string;
sizeBytes: number; 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 { return {
id: c.id, id: d.id,
clientId: c.clientKey ?? c.id, clientId: d.clientKey ?? d.id,
treatmentType: c.treatmentType, treatmentType: d.treatmentType,
teeth: normalizeTeeth(c.teeth), teeth: normalizeTeeth(d.teeth),
notes: c.comment ?? null, notes: d.comment ?? null,
sentAt: c.sentAt?.toISOString() ?? null, attachmentMetas: (d.attachments ?? []).map((a) => this.mapAttachment(a)),
sendToOrganizationIds: c.sends?.map((s) => s.organizationId) ?? [], labCaseId: labCase?.id ?? null,
sentAt: labCase?.sentAt?.toISOString() ?? null,
destinationOrganizationId: labCase?.destinationOrganizationId ?? null,
sends: 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, organizationId: s.organizationId,
organizationName: s.organization?.name ?? 'Unknown organization', organizationName: s.organization?.name ?? 'Unknown organization',
sentAt: s.sentAt.toISOString(), 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({ const patient = await this.prisma.patient.findUnique({
where: { id: patientId }, where: { id: patientId },
select: { id: true }, select: { id: true },

View File

@@ -2,21 +2,26 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { formatCaseSentLines } from '@/components/treatment/caseSendLabel'; import { formatCaseSentLines } from '@/components/treatment/caseSendLabel';
import type { LinkedOrganizationOption, PastTreatmentCase, TreatmentCaseDraft } from '@/types/treatment'; import type { LabCaseSendInfo, LinkedOrganizationOption } from '@/types/treatment';
interface CaseSentLabelProps { interface CaseSentLabelProps {
treatmentCase: Pick< treatmentCase: {
PastTreatmentCase | TreatmentCaseDraft, sends?: LabCaseSendInfo[];
'sends' | 'sendToOrganizationIds' | 'sentAt' sendToOrganizationIds?: string[];
>; destinationOrganizationId?: string | null;
sentAt?: string | null;
};
orgs?: LinkedOrganizationOption[]; orgs?: LinkedOrganizationOption[];
className?: string; className?: string;
} }
export function CaseSentLabel({ treatmentCase, orgs, className = 'text-xs text-text-muted' }: CaseSentLabelProps) { export function CaseSentLabel({ treatmentCase, orgs, className = 'text-xs text-text-muted' }: CaseSentLabelProps) {
const t = useTranslations('treatment'); const t = useTranslations('treatment');
const organizationIds =
treatmentCase.sendToOrganizationIds ??
(treatmentCase.destinationOrganizationId ? [treatmentCase.destinationOrganizationId] : []);
const lines = formatCaseSentLines(treatmentCase.sends, { const lines = formatCaseSentLines(treatmentCase.sends, {
organizationIds: treatmentCase.sendToOrganizationIds ?? [], organizationIds,
sentAt: treatmentCase.sentAt ?? null, sentAt: treatmentCase.sentAt ?? null,
orgs, orgs,
}, t); }, t);

View File

@@ -64,7 +64,7 @@ export function PastTreatmentsPanel({
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
{treatment.cases.map((c, idx) => { {treatment.details.map((c, idx) => {
const attachments = c.attachmentMetas ?? []; const attachments = c.attachmentMetas ?? [];
const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS]; const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS];
const typeLabel = typeKey ? t(typeKey) : c.treatmentType; const typeLabel = typeKey ? t(typeKey) : c.treatmentType;

View File

@@ -22,7 +22,7 @@ export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPr
const t = useTranslations('treatment'); const t = useTranslations('treatment');
const attachmentCount = draft 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; : 0;
return ( return (
@@ -42,11 +42,11 @@ export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPr
<span className="text-xs text-text-muted tabular-nums shrink-0 capitalize">{draft.status}</span> <span className="text-xs text-text-muted tabular-nums shrink-0 capitalize">{draft.status}</span>
</div> </div>
<p className="text-xs text-text-secondary"> <p className="text-xs text-text-secondary">
{t('caseCount', { n: draft.cases.length })} ·{' '} {t('caseCount', { n: draft.details.length })} ·{' '}
{t('attachmentCount', { n: attachmentCount })} {t('attachmentCount', { n: attachmentCount })}
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{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 typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS];
const typeLabel = typeKey ? t(typeKey) : c.treatmentType; const typeLabel = typeKey ? t(typeKey) : c.treatmentType;
return ( return (
@@ -65,8 +65,8 @@ export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPr
</div> </div>
); );
})} })}
{draft.cases.length > 2 && ( {draft.details.length > 2 && (
<p className="text-xs text-text-muted">{t('moreCases', { n: draft.cases.length - 2 })}</p> <p className="text-xs text-text-muted">{t('moreCases', { n: draft.details.length - 2 })}</p>
)} )}
</div> </div>
</div> </div>

View File

@@ -94,18 +94,20 @@ export function TreatmentPreviewDialog({
{t('statusLabel')} {treatment.status} {t('statusLabel')} {treatment.status}
</p> </p>
{treatment.cases.length === 0 ? ( {treatment.details.length === 0 ? (
<p className="text-sm text-text-muted">{t('noCases')}</p> <p className="text-sm text-text-muted">{t('noCases')}</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{treatment.cases.map((c, idx) => { {treatment.details.map((c, idx) => {
const key = caseKey(c); const key = caseKey(c);
const attachments = c.attachmentMetas ?? []; const attachments = c.attachmentMetas ?? [];
const latestAttachment = const latestAttachment =
attachments.length > 0 ? attachments[attachments.length - 1] : null; attachments.length > 0 ? attachments[attachments.length - 1] : null;
const sent = Boolean(c.sentAt); const sent = Boolean(c.sentAt);
const actionsEnabled = editable && !sent; const actionsEnabled = editable && !sent;
const selectedOrgIds = getCaseOrgIds?.(key) ?? c.sendToOrganizationIds ?? []; const selectedOrgIds =
getCaseOrgIds?.(key) ??
(c.destinationOrganizationId ? [c.destinationOrganizationId] : []);
const sendExpanded = expandedSendCaseId === key; const sendExpanded = expandedSendCaseId === key;
const comment = c.notes?.trim() ?? ''; const comment = c.notes?.trim() ?? '';
const attachBusy = uploadBusyCaseId === key; const attachBusy = uploadBusyCaseId === key;

View File

@@ -71,17 +71,18 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
}; };
} }
function mapCaseFromApi(c: PastTreatmentCase): TreatmentCaseDraft { function mapDetailFromApi(d: PastTreatmentCase): TreatmentCaseDraft {
return { return {
clientId: c.clientId, clientId: d.clientId,
id: c.id, id: d.id,
treatmentType: c.treatmentType, treatmentType: d.treatmentType,
teeth: c.teeth, teeth: d.teeth,
comment: c.notes ?? '', comment: d.notes ?? '',
attachmentMetas: c.attachmentMetas ?? [], attachmentMetas: d.attachmentMetas ?? [],
sendToOrganizationIds: c.sendToOrganizationIds ?? [], labCaseId: d.labCaseId ?? null,
sends: c.sends ?? [], sendToOrganizationIds: d.destinationOrganizationId ? [d.destinationOrganizationId] : [],
sentAt: c.sentAt ?? null, sends: d.sends ?? [],
sentAt: d.sentAt ?? null,
}; };
} }
@@ -110,16 +111,19 @@ function casesToPreviewTreatment(
title: meta.title, title: meta.title,
treatmentAt: meta.treatmentAt, treatmentAt: meta.treatmentAt,
status: meta.status, status: meta.status,
cases: cases.map((c, idx) => ({ details: cases.map((c, idx) => ({
id: c.id ?? c.clientId ?? `draft-${idx + 1}`, id: c.id ?? c.clientId ?? `draft-${idx + 1}`,
clientId: c.clientId, clientId: c.clientId,
treatmentType: c.treatmentType, treatmentType: c.treatmentType,
teeth: c.teeth, teeth: c.teeth,
notes: c.comment || null, notes: c.comment || null,
attachmentMetas: c.attachmentMetas, attachmentMetas: c.attachmentMetas,
sendToOrganizationIds: c.sendToOrganizationIds, labCaseId: c.labCaseId ?? null,
destinationOrganizationId: c.sendToOrganizationIds[0] ?? null,
sends: c.sends ?? [],
sentAt: c.sentAt ?? null, sentAt: c.sentAt ?? null,
})), })),
labCases: [],
documents: [], documents: [],
}; };
} }
@@ -305,8 +309,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const response = await treatmentsApi.getDraft(appointmentId); const response = await treatmentsApi.getDraft(appointmentId);
if (cancelled) return; if (cancelled) return;
if (response.data?.cases?.length) { if (response.data?.details?.length) {
const mapped = response.data.cases.map(mapCaseFromApi); const mapped = response.data.details.map(mapDetailFromApi);
setCases(mapped); setCases(mapped);
setActiveCaseId((prev) => { setActiveCaseId((prev) => {
const stillExists = mapped.some((c) => c.clientId === 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'); if (!selectedAppointment) throw new Error('No appointment selected');
const response = await treatmentsApi.saveDraft(selectedAppointment.id, { 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, clientId,
id, id,
treatmentType, treatmentType,
@@ -396,7 +400,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
attachmentIds: attachmentMetas.map((a) => a.id), attachmentIds: attachmentMetas.map((a) => a.id),
})), })),
}); });
const mapped = response.data.cases.map(mapCaseFromApi); const mapped = response.data.details.map(mapDetailFromApi);
setCases(mapped); setCases(mapped);
setActiveCaseId((prev) => { setActiveCaseId((prev) => {
const stillExists = mapped.some((c) => c.clientId === prev); const stillExists = mapped.some((c) => c.clientId === prev);
@@ -422,28 +426,57 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const handleSendCase = useCallback( const handleSendCase = useCallback(
async (treatmentCase: TreatmentCaseDraft) => { async (treatmentCase: TreatmentCaseDraft) => {
if (!canEditTreatmentForDay || !selectedAppointment) return; if (!canEditTreatmentForDay || !selectedAppointment) return;
const targets = treatmentCase.sendToOrganizationIds.filter((id) => const destinationOrgId = treatmentCase.sendToOrganizationIds.find((id) =>
orgs.some((o) => o.id === id && o.active), orgs.some((o) => o.id === id && o.active),
); );
if (targets.length === 0) { if (!destinationOrgId) {
showError(t('errorChooseOrg')); showError(t('errorChooseOrg'));
return; return;
} }
setSendBusyId(treatmentCase.clientId); setSendBusyId(treatmentCase.clientId);
try { try {
const saved = await persistDraft(); const saved = await persistDraft();
const serverCase = saved.cases.find((c) => c.clientId === treatmentCase.clientId); const serverDetail = saved.details.find((c) => c.clientId === treatmentCase.clientId);
if (!serverCase?.id) throw new Error(t('errorCaseMustSave')); 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) => { setCases((prev) => {
const next = prev.map((c) => const next = prev.map((c) =>
c.clientId === treatmentCase.clientId c.clientId === treatmentCase.clientId
? { ? {
...c, ...c,
id: response.data.id, labCaseId: response.data.id,
sentAt: response.data.sentAt, sentAt: response.data.sentAt,
sendToOrganizationIds: response.data.sendToOrganizationIds, sendToOrganizationIds: response.data.destinationOrganizationId
? [response.data.destinationOrganizationId]
: [],
sends: response.data.sends, sends: response.data.sends,
} }
: c, : c,
@@ -452,7 +485,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
return next; return next;
}); });
setRecentOrganizationIds((prev) => { setRecentOrganizationIds((prev) => {
const next = [...targets.filter((id) => !prev.includes(id)), ...prev]; const next = [destinationOrgId, ...prev.filter((id) => id !== destinationOrgId)];
return next.slice(0, 10); return next.slice(0, 10);
}); });
showSuccess(t('successCaseSent')); showSuccess(t('successCaseSent'));

View File

@@ -1,11 +1,10 @@
import { apiClient } from './client'; import { apiClient } from './client';
import type { import type {
LabCaseResponse,
LinkedOrganizationOption, LinkedOrganizationOption,
PastTreatment, PastTreatment,
SaveTreatmentPayload, SaveLabCasePayload,
SendTreatmentCasePayload, SavedTreatmentDetailPayload,
TreatmentAttachmentMeta,
TreatmentCaseSendInfo,
} from '@/types/treatment'; } from '@/types/treatment';
export const treatmentsApi = { export const treatmentsApi = {
@@ -33,34 +32,53 @@ export const treatmentsApi = {
saveDraft: async ( saveDraft: async (
appointmentId: string, appointmentId: string,
payload: Pick<SaveTreatmentPayload, 'cases'>, payload: { details: SavedTreatmentDetailPayload[] },
): Promise<{ success: boolean; data: PastTreatment }> => { ): Promise<{ success: boolean; data: PastTreatment }> => {
const response = await apiClient.put(`/treatments/appointments/${appointmentId}/draft`, payload); const response = await apiClient.put(`/treatments/appointments/${appointmentId}/draft`, payload);
return response.data; return response.data;
}, },
uploadCaseAttachments: async ( saveLabCases: async (
appointmentId: string, 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[], files: File[],
): Promise<{ success: boolean; data: TreatmentAttachmentMeta[] }> => { ): Promise<{ success: boolean; data: import('@/types/treatment').TreatmentAttachmentMeta[] }> => {
const form = new FormData(); const form = new FormData();
for (const file of files) { for (const file of files) {
form.append('files', file); form.append('files', file);
} }
const response = await apiClient.post( const response = await apiClient.post(
`/treatments/appointments/${appointmentId}/cases/${encodeURIComponent(caseClientId)}/attachments`, `/treatments/appointments/${appointmentId}/details/${encodeURIComponent(detailClientId)}/attachments`,
form, form,
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120_000 }, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120_000 },
); );
return response.data; return response.data;
}, },
sendCase: async ( /** @deprecated Use uploadDetailAttachments */
caseId: string, uploadCaseAttachments: async (
payload: SendTreatmentCasePayload, appointmentId: string,
): Promise<{ success: boolean; data: PastTreatmentCaseResponse }> => { detailClientId: string,
const response = await apiClient.post(`/treatments/cases/${caseId}/send`, payload); 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; return response.data;
}, },
@@ -72,15 +90,3 @@ export const treatmentsApi = {
return response.data; 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[];
}

View File

@@ -61,24 +61,47 @@ export const TREATMENT_TYPES = [
export type TreatmentType = (typeof TREATMENT_TYPES)[number]; export type TreatmentType = (typeof TREATMENT_TYPES)[number];
export interface TreatmentCaseSendInfo { export interface LabCaseSendInfo {
organizationId: string; organizationId: string;
organizationName: string; organizationName: string;
sentAt: string; sentAt: string;
} }
export interface PastTreatmentCase { /** @deprecated Use LabCaseSendInfo */
export type TreatmentCaseSendInfo = LabCaseSendInfo;
export interface PastTreatmentDetail {
id: string; id: string;
clientId: string; clientId: string;
treatmentType: TreatmentType; treatmentType: TreatmentType;
teeth: FdiToothId[]; teeth: FdiToothId[];
notes?: string | null; notes?: string | null;
attachmentMetas?: TreatmentAttachmentMeta[]; attachmentMetas?: TreatmentAttachmentMeta[];
sendToOrganizationIds?: string[]; labCaseId?: string | null;
sends?: TreatmentCaseSendInfo[]; destinationOrganizationId?: string | null;
sends?: LabCaseSendInfo[];
sentAt?: string | null; 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 { export interface PastTreatment {
id: string; id: string;
patientId: string; patientId: string;
@@ -86,7 +109,8 @@ export interface PastTreatment {
title: string; title: string;
treatmentAt: string; treatmentAt: string;
status: string; status: string;
cases: PastTreatmentCase[]; details: PastTreatmentDetail[];
labCases: PastLabCase[];
documents: TreatmentAttachmentMeta[]; documents: TreatmentAttachmentMeta[];
} }
@@ -96,19 +120,23 @@ export interface LinkedOrganizationOption {
active: boolean; active: boolean;
} }
export interface TreatmentCaseDraft { export interface TreatmentDetailDraft {
clientId: string; clientId: string;
id?: string; id?: string;
treatmentType: TreatmentType; treatmentType: TreatmentType;
teeth: FdiToothId[]; teeth: FdiToothId[];
comment: string; comment: string;
attachmentMetas: TreatmentAttachmentMeta[]; attachmentMetas: TreatmentAttachmentMeta[];
labCaseId?: string | null;
sendToOrganizationIds: string[]; sendToOrganizationIds: string[];
sends?: TreatmentCaseSendInfo[]; sends?: LabCaseSendInfo[];
sentAt?: string | null; 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; clientId: string;
id?: string; id?: string;
treatmentType: TreatmentType; treatmentType: TreatmentType;
@@ -117,12 +145,35 @@ export type SavedTreatmentCasePayload = {
attachmentIds: string[]; 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 { export interface SaveTreatmentPayload {
appointmentId: string; appointmentId: string;
patientId: string; patientId: string;
cases: SavedTreatmentCasePayload[]; details: SavedTreatmentDetailPayload[];
} }
export interface SendTreatmentCasePayload { export interface LabCaseResponse {
organizationIds: string[]; 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[];
} }