feature: Phase 2- splitting the treatment schema into TreatmentDetail and LabCase.
This commit is contained in:
@@ -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;
|
||||||
@@ -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,54 +142,81 @@ 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?
|
||||||
sortOrder Int
|
sortOrder Int
|
||||||
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())
|
||||||
|
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())
|
id String @id @default(uuid())
|
||||||
caseId String
|
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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user