diff --git a/backend/.gitignore b/backend/.gitignore index 4b56acf..6233af0 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -45,6 +45,7 @@ lerna-debug.log* # temp directory .temp .tmp +/uploads # Runtime data pids diff --git a/backend/package-lock.json b/backend/package-lock.json index 7ce12a9..38d9f70 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -35,6 +35,7 @@ "express-formidable": "^1.2.0", "express-session": "^1.19.0", "helmet": "^8.1.0", + "multer": "^2.1.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", @@ -55,6 +56,7 @@ "@types/express": "^5.0.0", "@types/express-session": "^1.18.2", "@types/jest": "^30.0.0", + "@types/multer": "^2.1.0", "@types/node": "^22.10.7", "@types/pg": "^8.16.0", "@types/react": "^19.2.14", @@ -6251,6 +6253,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", @@ -12374,7 +12386,7 @@ }, "node_modules/multer": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "resolved": "https://registry.npmmirror.com/multer/-/multer-2.1.1.tgz", "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", "license": "MIT", "dependencies": { diff --git a/backend/package.json b/backend/package.json index 9ecc4ad..fa41833 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,7 +41,6 @@ "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.5.0", "@prisma/client": "^6.19.2", - "prisma": "^6.19.2", "adminjs": "^7.8.17", "axios": "^1.13.5", "bcrypt": "^6.0.0", @@ -54,10 +53,12 @@ "express-formidable": "^1.2.0", "express-session": "^1.19.0", "helmet": "^8.1.0", + "multer": "^2.1.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pg": "^8.18.0", + "prisma": "^6.19.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "styled-components": "^6.3.11", @@ -73,6 +74,7 @@ "@types/express": "^5.0.0", "@types/express-session": "^1.18.2", "@types/jest": "^30.0.0", + "@types/multer": "^2.1.0", "@types/node": "^22.10.7", "@types/pg": "^8.16.0", "@types/react": "^19.2.14", diff --git a/backend/prisma/migrations/20260519120000_add_treatments_feature/migration.sql b/backend/prisma/migrations/20260519120000_add_treatments_feature/migration.sql new file mode 100644 index 0000000..b3dd2e3 --- /dev/null +++ b/backend/prisma/migrations/20260519120000_add_treatments_feature/migration.sql @@ -0,0 +1,154 @@ +-- CreateEnum +CREATE TYPE "TreatmentStatus" AS ENUM ('DRAFT', 'COMPLETED'); + +-- CreateTable +CREATE TABLE "treatments" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "patientId" TEXT NOT NULL, + "appointmentId" TEXT, + "providerUserId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "status" "TreatmentStatus" NOT NULL DEFAULT 'DRAFT', + "treatmentAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "treatments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "treatment_cases" ( + "id" TEXT NOT NULL, + "treatmentId" TEXT NOT NULL, + "clientKey" TEXT, + "sortOrder" INTEGER NOT NULL, + "treatmentType" TEXT NOT NULL, + "teeth" JSONB NOT NULL, + "comment" TEXT, + "sentAt" TIMESTAMP(3), + + CONSTRAINT "treatment_cases_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "treatment_case_attachments" ( + "id" TEXT NOT NULL, + "caseId" TEXT, + "appointmentId" TEXT, + "caseClientKey" 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_case_attachments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "treatment_case_sends" ( + "id" TEXT NOT NULL, + "caseId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "treatment_case_sends_pkey" PRIMARY KEY ("id") +); + +-- Migrate legacy patient_treatment_histories into treatments + single case per row +INSERT INTO "treatments" ( + "id", + "organizationId", + "patientId", + "appointmentId", + "providerUserId", + "title", + "status", + "treatmentAt", + "createdAt", + "updatedAt" +) +SELECT + h."id", + p."organizationId", + h."patientId", + NULL, + o."ownerId", + h."title", + 'COMPLETED'::"TreatmentStatus", + h."treatmentAt", + h."createdAt", + h."updatedAt" +FROM "patient_treatment_histories" h +JOIN "patients" p ON p."id" = h."patientId" +JOIN "organizations" o ON o."id" = p."organizationId"; + +INSERT INTO "treatment_cases" ( + "id", + "treatmentId", + "clientKey", + "sortOrder", + "treatmentType", + "teeth", + "comment", + "sentAt" +) +SELECT + h."id" || '-case', + h."id", + NULL, + 0, + 'visit', + CASE + WHEN h."tooth" IS NOT NULL AND btrim(h."tooth") <> '' THEN jsonb_build_array(h."tooth") + ELSE '[]'::jsonb + END, + h."notes", + NULL +FROM "patient_treatment_histories" h; + +-- Drop legacy table +DROP TABLE "patient_treatment_histories"; + +-- CreateIndex +CREATE UNIQUE INDEX "treatments_appointmentId_key" ON "treatments"("appointmentId"); + +-- CreateIndex +CREATE INDEX "treatments_patientId_treatmentAt_idx" ON "treatments"("patientId", "treatmentAt"); + +-- CreateIndex +CREATE INDEX "treatments_organizationId_status_idx" ON "treatments"("organizationId", "status"); + +-- CreateIndex +CREATE INDEX "treatment_cases_treatmentId_sortOrder_idx" ON "treatment_cases"("treatmentId", "sortOrder"); + +-- CreateIndex +CREATE INDEX "treatment_case_attachments_appointmentId_caseClientKey_idx" ON "treatment_case_attachments"("appointmentId", "caseClientKey"); + +-- CreateIndex +CREATE INDEX "treatment_case_attachments_caseId_idx" ON "treatment_case_attachments"("caseId"); + +-- CreateIndex +CREATE UNIQUE INDEX "treatment_case_sends_caseId_organizationId_key" ON "treatment_case_sends"("caseId", "organizationId"); + +-- AddForeignKey +ALTER TABLE "treatments" ADD CONSTRAINT "treatments_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "treatments" ADD CONSTRAINT "treatments_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "patients"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "treatments" ADD CONSTRAINT "treatments_appointmentId_fkey" FOREIGN KEY ("appointmentId") REFERENCES "appointments"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "treatment_cases" ADD CONSTRAINT "treatment_cases_treatmentId_fkey" FOREIGN KEY ("treatmentId") REFERENCES "treatments"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "treatment_case_attachments" ADD CONSTRAINT "treatment_case_attachments_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "treatment_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "treatment_case_sends" ADD CONSTRAINT "treatment_case_sends_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "treatment_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "treatment_case_sends" ADD CONSTRAINT "treatment_case_sends_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7882769..8d3836b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -61,6 +61,8 @@ model Organization { sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter") patients Patient[] appointments Appointment[] + treatments Treatment[] + caseSends TreatmentCaseSend[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -81,8 +83,8 @@ model Patient { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - organization Organization @relation(fields: [organizationId], references: [id]) - treatments PatientTreatmentHistory[] + organization Organization @relation(fields: [organizationId], references: [id]) + treatments Treatment[] appointments Appointment[] @@index([organizationId, createdAt]) @@ -90,24 +92,6 @@ model Patient { @@map("patients") } -model PatientTreatmentHistory { - id String @id @default(uuid()) - patientId String - title String - status String - treatmentAt DateTime - tooth String? - notes String? - totalCost Float? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) - - @@index([patientId, treatmentAt]) - @@map("patient_treatment_histories") -} - model Appointment { id String @id @default(uuid()) organizationId String @@ -119,6 +103,7 @@ model Appointment { organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + treatment Treatment? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -128,6 +113,84 @@ model Appointment { @@map("appointments") } +enum TreatmentStatus { + DRAFT + COMPLETED +} + +model Treatment { + id String @id @default(uuid()) + organizationId String + patientId String + appointmentId String? @unique + providerUserId String + title String + status TreatmentStatus @default(DRAFT) + treatmentAt DateTime + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) + appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull) + cases TreatmentCase[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([patientId, treatmentAt]) + @@index([organizationId, status]) + @@map("treatments") +} + +model TreatmentCase { + id String @id @default(uuid()) + treatmentId String + clientKey String? + sortOrder Int + treatmentType String + teeth Json + comment String? + sentAt DateTime? + + treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) + attachments TreatmentCaseAttachment[] + sends TreatmentCaseSend[] + + @@index([treatmentId, sortOrder]) + @@map("treatment_cases") +} + +model TreatmentCaseAttachment { + id String @id @default(uuid()) + caseId String? + appointmentId String? + caseClientKey String? + fileName String + mimeType String + sizeBytes Int + storagePath String + + case TreatmentCase? @relation(fields: [caseId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@index([appointmentId, caseClientKey]) + @@index([caseId]) + @@map("treatment_case_attachments") +} + +model TreatmentCaseSend { + id String @id @default(uuid()) + caseId String + organizationId String + sentAt DateTime @default(now()) + + case TreatmentCase @relation(fields: [caseId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@unique([caseId, organizationId]) + @@map("treatment_case_sends") +} + model Plan { id String @id @default(uuid()) name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise" diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 270c45c..70c3e1c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -10,6 +10,7 @@ import { PatientsModule } from './modules/patients/patients.module'; import { StaffModule } from './modules/staff/staff.module'; import { OrganizationModule } from './modules/organization/organization.module'; import { AppointmentsModule } from './modules/appointments/appointments.module'; +import { TreatmentsModule } from './modules/treatments/treatments.module'; @Module({ imports: [ @@ -21,6 +22,7 @@ import { AppointmentsModule } from './modules/appointments/appointments.module'; AuthModule, PatientsModule, AppointmentsModule, + TreatmentsModule, StaffModule, OrganizationModule, AdminModule.forRoot(), diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 2f9e48d..d5baa9f 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -220,6 +220,9 @@ export class AppointmentsService { if (names.includes('TAB_TREATMENT_EDIT')) { return; } + if (names.includes('TAB_TREATMENT_READ')) { + return; + } throw new ForbiddenException('You do not have access to appointments'); } diff --git a/backend/src/modules/patients/dto/create-treatment-history.dto.ts b/backend/src/modules/patients/dto/create-treatment-history.dto.ts deleted file mode 100644 index e0620d4..0000000 --- a/backend/src/modules/patients/dto/create-treatment-history.dto.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { IsDateString, IsNumber, IsOptional, IsString, MaxLength } from 'class-validator'; - -export class CreateTreatmentHistoryDto { - @IsString() - @MaxLength(120) - title: string; - - @IsString() - @MaxLength(40) - status: string; - - @IsDateString() - treatmentAt: string; - - @IsOptional() - @IsString() - @MaxLength(20) - tooth?: string; - - @IsOptional() - @IsString() - @MaxLength(1000) - notes?: string; - - @IsOptional() - @IsNumber() - totalCost?: number; -} diff --git a/backend/src/modules/patients/patients.controller.ts b/backend/src/modules/patients/patients.controller.ts index df77e81..0e97c6e 100644 --- a/backend/src/modules/patients/patients.controller.ts +++ b/backend/src/modules/patients/patients.controller.ts @@ -16,7 +16,6 @@ import { CreatePatientDto } from './dto/create-patient.dto'; import { ListPatientsDto } from './dto/list-patients.dto'; import { UpdatePatientDto } from './dto/update-patient.dto'; import { PatientsService } from './patients.service'; -import { CreateTreatmentHistoryDto } from './dto/create-treatment-history.dto'; @ApiTags('patients') @ApiBearerAuth('JWT-auth') @@ -52,26 +51,4 @@ export class PatientsController { const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); return this.patientsService.update(id, updatePatientDto, organizationId); } - - @Get(':id/treatments') - @ApiOperation({ summary: 'Get patient treatment history' }) - findTreatments( - @Param('id') id: string, - @Query('limit', new ParseIntPipe({ optional: true })) limit = 20, - @Req() req, - ) { - const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); - return this.patientsService.findTreatments(id, organizationId, limit); - } - - @Post(':id/treatments') - @ApiOperation({ summary: 'Add treatment history item for a patient' }) - addTreatment( - @Param('id') id: string, - @Body() dto: CreateTreatmentHistoryDto, - @Req() req, - ) { - const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); - return this.patientsService.addTreatment(id, dto, organizationId); - } } diff --git a/backend/src/modules/patients/patients.service.ts b/backend/src/modules/patients/patients.service.ts index f3513f8..ac99936 100644 --- a/backend/src/modules/patients/patients.service.ts +++ b/backend/src/modules/patients/patients.service.ts @@ -3,7 +3,6 @@ import { PrismaService } from '../../../prisma/prisma.service'; import { CreatePatientDto } from './dto/create-patient.dto'; import { ListPatientsDto } from './dto/list-patients.dto'; import { UpdatePatientDto } from './dto/update-patient.dto'; -import { CreateTreatmentHistoryDto } from './dto/create-treatment-history.dto'; @Injectable() export class PatientsService { @@ -89,36 +88,6 @@ export class PatientsService { return { success: true, data: patient }; } - async findTreatments(patientId: string, organizationId: string, limit = 20) { - await this.ensurePatient(patientId, organizationId); - - const items = await this.prisma.patientTreatmentHistory.findMany({ - where: { patientId }, - orderBy: [{ treatmentAt: 'desc' }], - take: limit, - }); - - return { success: true, data: items }; - } - - async addTreatment( - patientId: string, - dto: CreateTreatmentHistoryDto, - organizationId: string, - ) { - await this.ensurePatient(patientId, organizationId); - - const treatment = await this.prisma.patientTreatmentHistory.create({ - data: { - ...dto, - treatmentAt: new Date(dto.treatmentAt), - patientId, - }, - }); - - return { success: true, data: treatment }; - } - private async ensurePatient(id: string, organizationId: string) { const patient = await this.prisma.patient.findFirst({ where: { id, organizationId }, diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts new file mode 100644 index 0000000..3d3c462 --- /dev/null +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -0,0 +1,60 @@ +import { + ArrayMinSize, + IsArray, + IsIn, + IsOptional, + IsString, + IsUUID, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const; + +export class SaveTreatmentCaseDto { + @IsString() + @MaxLength(64) + clientId: string; + + @IsOptional() + @IsUUID() + id?: string; + + @IsIn(TREATMENT_TYPES) + treatmentType: string; + + @IsArray() + @IsString({ each: true }) + teeth: string[]; + + @IsOptional() + @IsString() + @MaxLength(5000) + comment?: string; + + @IsOptional() + @IsArray() + @IsUUID(undefined, { each: true }) + attachmentIds?: string[]; +} + +export class SaveTreatmentDraftDto { + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => SaveTreatmentCaseDto) + cases: SaveTreatmentCaseDto[]; +} + +export class SendTreatmentCaseDto { + @IsArray() + @ArrayMinSize(1) + @IsUUID(undefined, { each: true }) + organizationIds: string[]; +} + +export class ListPatientTreatmentHistoryDto { + @IsOptional() + limit?: number; +} diff --git a/backend/src/modules/treatments/treatment.utils.spec.ts b/backend/src/modules/treatments/treatment.utils.spec.ts new file mode 100644 index 0000000..bee7dc8 --- /dev/null +++ b/backend/src/modules/treatments/treatment.utils.spec.ts @@ -0,0 +1,16 @@ +import { generateTreatmentTitle, normalizeTeeth } from './treatment.utils'; + +describe('treatment.utils', () => { + it('normalizes valid FDI teeth', () => { + expect(normalizeTeeth(['45', '14', '14', '99'])).toEqual(['14', '45']); + }); + + it('generates a title from cases', () => { + expect( + generateTreatmentTitle([ + { treatmentType: 'filling', teeth: ['14', '15'] }, + { treatmentType: 'endo', teeth: ['45'] }, + ]), + ).toBe('Filling 14, 15 · Endo 45'); + }); +}); diff --git a/backend/src/modules/treatments/treatment.utils.ts b/backend/src/modules/treatments/treatment.utils.ts new file mode 100644 index 0000000..fc13721 --- /dev/null +++ b/backend/src/modules/treatments/treatment.utils.ts @@ -0,0 +1,53 @@ +import { TreatmentStatus } from '@prisma/client'; + +const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const; + +export type TreatmentTypeValue = (typeof TREATMENT_TYPES)[number]; + +export function isTreatmentType(value: string): value is TreatmentTypeValue { + return (TREATMENT_TYPES as readonly string[]).includes(value); +} + +const FDI_TOOTH_IDS = new Set([ + '11', '12', '13', '14', '15', '16', '17', '18', + '21', '22', '23', '24', '25', '26', '27', '28', + '31', '32', '33', '34', '35', '36', '37', '38', + '41', '42', '43', '44', '45', '46', '47', '48', +]); + +export function normalizeTeeth(teeth: unknown): string[] { + if (!Array.isArray(teeth)) { + return []; + } + const unique = new Set(); + for (const tooth of teeth) { + if (typeof tooth !== 'string') continue; + const trimmed = tooth.trim(); + if (FDI_TOOTH_IDS.has(trimmed)) { + unique.add(trimmed); + } + } + return [...unique].sort(); +} + +export function generateTreatmentTitle( + cases: { treatmentType: string; teeth: string[] }[], +): string { + if (cases.length === 0) { + return 'Treatment'; + } + + const parts = cases.map((c) => { + const label = c.treatmentType.charAt(0).toUpperCase() + c.treatmentType.slice(1); + if (c.teeth.length > 0) { + return `${label} ${c.teeth.join(', ')}`; + } + return label; + }); + + return parts.join(' · '); +} + +export function mapTreatmentStatusForApi(status: TreatmentStatus): string { + return status === TreatmentStatus.DRAFT ? 'draft' : 'completed'; +} diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts new file mode 100644 index 0000000..6f9bdac --- /dev/null +++ b/backend/src/modules/treatments/treatments.controller.ts @@ -0,0 +1,148 @@ +import { + Body, + Controller, + Get, + Param, + ParseIntPipe, + Post, + Put, + Query, + Req, + Res, + UploadedFiles, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { memoryStorage } from 'multer'; +import type { Response } from 'express'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto'; +import { TreatmentsService } from './treatments.service'; + +@ApiTags('treatments') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('treatments') +export class TreatmentsController { + constructor(private readonly treatmentsService: TreatmentsService) {} + + @Get('linked-organizations') + @ApiOperation({ summary: 'List active linked counterpart organizations (TAB_TREATMENT_READ)' }) + listLinkedOrganizations(@Req() req: { user: { id: string; organizationId?: string } }) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.listLinkedOrganizations(req.user.id, organizationId); + } + + @Get('patients/:patientId/history') + @ApiOperation({ summary: 'List completed treatments for a patient (TAB_TREATMENT_READ)' }) + listPatientHistory( + @Param('patientId') patientId: string, + @Query('limit', new ParseIntPipe({ optional: true })) limit = 20, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.listPatientHistory( + patientId, + organizationId, + req.user.id, + limit, + ); + } + + @Get('appointments/:appointmentId/draft') + @ApiOperation({ summary: 'Get draft treatment for an appointment (TAB_TREATMENT_READ)' }) + getDraft( + @Param('appointmentId') appointmentId: string, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.getDraftForAppointment( + appointmentId, + organizationId, + req.user.id, + ); + } + + @Put('appointments/:appointmentId/draft') + @ApiOperation({ summary: 'Save draft treatment for an appointment (TAB_TREATMENT_EDIT)' }) + saveDraft( + @Param('appointmentId') appointmentId: string, + @Body() dto: SaveTreatmentDraftDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.saveDraftForAppointment( + appointmentId, + dto, + organizationId, + req.user.id, + ); + } + + @Post('appointments/:appointmentId/cases/:caseClientKey/attachments') + @ApiOperation({ summary: 'Upload attachments for a draft case (TAB_TREATMENT_EDIT)' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + files: { + type: 'array', + items: { type: 'string', format: 'binary' }, + }, + }, + }, + }) + @UseInterceptors( + FilesInterceptor('files', 20, { + storage: memoryStorage(), + }), + ) + uploadAttachments( + @Param('appointmentId') appointmentId: string, + @Param('caseClientKey') caseClientKey: string, + @UploadedFiles() files: Express.Multer.File[], + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.uploadCaseAttachments( + appointmentId, + caseClientKey, + files, + organizationId, + req.user.id, + ); + } + + @Get('attachments/:attachmentId/file') + @ApiOperation({ summary: 'Download a treatment attachment (TAB_TREATMENT_READ)' }) + async downloadAttachment( + @Param('attachmentId') attachmentId: string, + @Req() req: { user: { id: string; organizationId?: string } }, + @Res() res: Response, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + const file = await this.treatmentsService.streamAttachmentFile( + attachmentId, + organizationId, + req.user.id, + ); + + res.setHeader('Content-Type', file.mimeType); + res.setHeader('Content-Disposition', `inline; filename="${file.fileName}"`); + file.stream.pipe(res); + } + + @Post('cases/:caseId/send') + @ApiOperation({ summary: 'Send a treatment case to linked organizations (TAB_TREATMENT_EDIT)' }) + sendCase( + @Param('caseId') caseId: string, + @Body() dto: SendTreatmentCaseDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.sendCase(caseId, dto, organizationId, req.user.id); + } +} diff --git a/backend/src/modules/treatments/treatments.module.ts b/backend/src/modules/treatments/treatments.module.ts new file mode 100644 index 0000000..52fb3b6 --- /dev/null +++ b/backend/src/modules/treatments/treatments.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { TreatmentsController } from './treatments.controller'; +import { TreatmentsService } from './treatments.service'; + +@Module({ + controllers: [TreatmentsController], + providers: [TreatmentsService, PrismaService], +}) +export class TreatmentsModule {} diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts new file mode 100644 index 0000000..954279b --- /dev/null +++ b/backend/src/modules/treatments/treatments.service.ts @@ -0,0 +1,613 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { LinkStatus, TreatmentStatus } from '@prisma/client'; +import { createReadStream, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto'; +import { + generateTreatmentTitle, + isTreatmentType, + mapTreatmentStatusForApi, + normalizeTeeth, +} from './treatment.utils'; + +const treatmentInclude = { + cases: { + orderBy: [{ sortOrder: 'asc' as const }], + include: { + attachments: { orderBy: [{ createdAt: 'asc' as const }] }, + sends: { orderBy: [{ sentAt: 'asc' as const }] }, + }, + }, +}; + +@Injectable() +export class TreatmentsService { + private readonly uploadRoot = join(process.cwd(), 'uploads', 'treatments'); + + constructor(private readonly prisma: PrismaService) {} + + getOrganizationIdFromUser(user: { organizationId?: string }) { + if (!user?.organizationId) { + throw new BadRequestException('Organization is not selected'); + } + return user.organizationId; + } + + async listLinkedOrganizations(userId: string, organizationId: string) { + await this.assertCanReadTreatment(userId, organizationId); + + const [linksA, linksB] = await Promise.all([ + this.prisma.organizationLink.findMany({ + where: { organizationAId: organizationId, status: LinkStatus.ACTIVE }, + include: { organizationB: { select: { id: true, name: true } } }, + }), + this.prisma.organizationLink.findMany({ + where: { organizationBId: organizationId, status: LinkStatus.ACTIVE }, + include: { organizationA: { select: { id: true, name: true } } }, + }), + ]); + + const data = [ + ...linksA.map((l) => ({ + id: l.organizationB.id, + name: l.organizationB.name, + active: true, + })), + ...linksB.map((l) => ({ + id: l.organizationA.id, + name: l.organizationA.name, + active: true, + })), + ].sort((a, b) => a.name.localeCompare(b.name)); + + return { success: true, data }; + } + + async listPatientHistory( + patientId: string, + organizationId: string, + actorUserId: string, + limit = 20, + ) { + await this.assertCanReadTreatment(actorUserId, organizationId); + await this.ensurePatientInOrg(patientId, organizationId); + + const items = await this.prisma.treatment.findMany({ + where: { + patientId, + organizationId, + status: TreatmentStatus.COMPLETED, + }, + include: treatmentInclude, + orderBy: [{ treatmentAt: 'desc' }], + take: Math.min(Math.max(limit, 1), 100), + }); + + return { success: true, data: items.map((t) => this.mapTreatment(t)) }; + } + + async getDraftForAppointment( + appointmentId: string, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanReadTreatment(actorUserId, organizationId); + const appointment = await this.ensureAppointmentProvider( + appointmentId, + organizationId, + actorUserId, + false, + ); + + const treatment = await this.prisma.treatment.findFirst({ + where: { + appointmentId: appointment.id, + organizationId, + status: TreatmentStatus.DRAFT, + }, + include: treatmentInclude, + }); + + return { success: true, data: treatment ? this.mapTreatment(treatment) : null }; + } + + async saveDraftForAppointment( + appointmentId: string, + dto: SaveTreatmentDraftDto, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + const appointment = await this.ensureAppointmentProvider( + appointmentId, + organizationId, + actorUserId, + true, + ); + + for (const c of dto.cases) { + if (!isTreatmentType(c.treatmentType)) { + throw new BadRequestException(`Invalid treatment type: ${c.treatmentType}`); + } + } + + const normalizedCases = dto.cases.map((c, index) => ({ + ...c, + sortOrder: index, + teeth: normalizeTeeth(c.teeth), + comment: c.comment?.trim() || null, + attachmentIds: c.attachmentIds ?? [], + })); + + const title = generateTreatmentTitle( + normalizedCases.map((c) => ({ treatmentType: c.treatmentType, teeth: c.teeth })), + ); + + const treatment = await this.prisma.$transaction(async (tx) => { + const existing = await tx.treatment.findFirst({ + where: { appointmentId: appointment.id, organizationId }, + select: { id: true }, + }); + + const saved = existing + ? await tx.treatment.update({ + where: { id: existing.id }, + data: { + title, + treatmentAt: appointment.startAt, + patientId: appointment.patientId, + providerUserId: appointment.providerUserId, + status: TreatmentStatus.DRAFT, + }, + }) + : await tx.treatment.create({ + data: { + organizationId, + patientId: appointment.patientId, + appointmentId: appointment.id, + providerUserId: appointment.providerUserId, + title, + status: TreatmentStatus.DRAFT, + treatmentAt: appointment.startAt, + }, + }); + + const keepCaseIds = normalizedCases.map((c) => c.id).filter(Boolean) as string[]; + const existingCases = existing + ? await tx.treatmentCase.findMany({ + where: { treatmentId: saved.id }, + select: { id: true, sentAt: true }, + }) + : []; + + const sentCaseIds = new Set( + existingCases.filter((c) => c.sentAt).map((c) => c.id), + ); + + const removableCaseIds = existingCases + .filter((c) => !keepCaseIds.includes(c.id) && !c.sentAt) + .map((c) => c.id); + + if (removableCaseIds.length > 0) { + await tx.treatmentCase.deleteMany({ + where: { id: { in: removableCaseIds }, treatmentId: saved.id }, + }); + } + + for (const c of normalizedCases) { + if (c.id && sentCaseIds.has(c.id)) { + continue; + } + + const row = c.id + ? await tx.treatmentCase.update({ + where: { id: c.id }, + data: { + clientKey: c.clientId, + sortOrder: c.sortOrder, + treatmentType: c.treatmentType, + teeth: c.teeth, + comment: c.comment, + }, + }) + : await tx.treatmentCase.create({ + data: { + treatmentId: saved.id, + clientKey: c.clientId, + sortOrder: c.sortOrder, + treatmentType: c.treatmentType, + teeth: c.teeth, + comment: c.comment, + }, + }); + + const allowedAttachmentIds = new Set(c.attachmentIds); + const pendingAttachments = await tx.treatmentCaseAttachment.findMany({ + where: { + appointmentId: appointment.id, + caseClientKey: c.clientId, + }, + }); + + for (const attachment of pendingAttachments) { + if (!allowedAttachmentIds.has(attachment.id)) { + await tx.treatmentCaseAttachment.delete({ where: { id: attachment.id } }); + } else { + await tx.treatmentCaseAttachment.update({ + where: { id: attachment.id }, + data: { caseId: row.id, appointmentId: null, caseClientKey: null }, + }); + } + } + + await tx.treatmentCaseAttachment.deleteMany({ + where: { + caseId: row.id, + id: { notIn: [...allowedAttachmentIds] }, + }, + }); + } + + return tx.treatment.findUniqueOrThrow({ + where: { id: saved.id }, + include: treatmentInclude, + }); + }); + + return { success: true, data: this.mapTreatment(treatment) }; + } + + async sendCase( + caseId: string, + dto: SendTreatmentCaseDto, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + + const treatmentCase = await this.prisma.treatmentCase.findFirst({ + where: { + id: caseId, + treatment: { organizationId }, + }, + include: { + treatment: { select: { providerUserId: true, appointmentId: true } }, + sends: { select: { organizationId: true } }, + }, + }); + + if (!treatmentCase) { + throw new NotFoundException('Treatment case not found'); + } + + if (treatmentCase.treatment.providerUserId !== actorUserId) { + const membership = await this.getMembership(actorUserId, organizationId); + if (!membership?.isOwner) { + throw new ForbiddenException('Only the appointment provider can send this case'); + } + } + + const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); + const uniqueTargets = [...new Set(dto.organizationIds)]; + + for (const orgId of uniqueTargets) { + if (!linkedOrgIds.has(orgId)) { + throw new BadRequestException('One or more organizations are not active linked counterparts'); + } + } + + const alreadySent = new Set(treatmentCase.sends.map((s) => s.organizationId)); + const newTargets = uniqueTargets.filter((id) => !alreadySent.has(id)); + + if (newTargets.length === 0) { + throw new BadRequestException('Case was already sent to all selected organizations'); + } + + const now = new Date(); + + await this.prisma.$transaction(async (tx) => { + await tx.treatmentCaseSend.createMany({ + data: newTargets.map((organizationId) => ({ + caseId, + organizationId, + })), + }); + + if (!treatmentCase.sentAt) { + await tx.treatmentCase.update({ + where: { id: caseId }, + data: { sentAt: now }, + }); + } + }); + + const refreshed = await this.prisma.treatmentCase.findUniqueOrThrow({ + where: { id: caseId }, + include: { + attachments: { orderBy: [{ createdAt: 'asc' }] }, + sends: { orderBy: [{ sentAt: 'asc' }] }, + }, + }); + + return { success: true, data: this.mapCase(refreshed) }; + } + + async uploadCaseAttachments( + appointmentId: string, + caseClientKey: string, + files: Express.Multer.File[], + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId, true); + + if (!caseClientKey?.trim()) { + throw new BadRequestException('caseClientKey is required'); + } + + if (!files?.length) { + throw new BadRequestException('At least one file is required'); + } + + const orgDir = join(this.uploadRoot, organizationId); + mkdirSync(orgDir, { recursive: true }); + + const created: { + id: string; + fileName: string; + mimeType: string; + sizeBytes: number; + }[] = []; + + for (const file of files) { + const storageName = `${randomUUID()}-${file.originalname.replace(/[^\w.\-()+]/g, '_')}`; + const storagePath = join(orgDir, storageName); + const { writeFileSync } = await import('fs'); + writeFileSync(storagePath, file.buffer); + + const attachment = await this.prisma.treatmentCaseAttachment.create({ + data: { + appointmentId, + caseClientKey, + fileName: file.originalname, + mimeType: file.mimetype || 'application/octet-stream', + sizeBytes: file.size, + storagePath, + }, + }); + + created.push(this.mapAttachment(attachment)); + } + + return { success: true, data: created }; + } + + async streamAttachmentFile( + attachmentId: string, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanReadTreatment(actorUserId, organizationId); + + const attachment = await this.prisma.treatmentCaseAttachment.findFirst({ + where: { + id: attachmentId, + OR: [ + { case: { treatment: { organizationId } } }, + { appointmentId: { not: null } }, + ], + }, + include: { + case: { select: { treatment: { select: { organizationId: true } } } }, + }, + }); + + if (!attachment) { + throw new NotFoundException('Attachment not found'); + } + + if (attachment.case && attachment.case.treatment.organizationId !== organizationId) { + throw new NotFoundException('Attachment not found'); + } + + if (!attachment.case && attachment.appointmentId) { + const appointment = await this.prisma.appointment.findFirst({ + where: { id: attachment.appointmentId, organizationId }, + select: { id: true }, + }); + if (!appointment) { + throw new NotFoundException('Attachment not found'); + } + } + + if (!existsSync(attachment.storagePath)) { + throw new NotFoundException('File is no longer available'); + } + + return { + stream: createReadStream(attachment.storagePath), + fileName: attachment.fileName, + mimeType: attachment.mimeType, + }; + } + + private mapTreatment(treatment: { + id: string; + patientId: string; + appointmentId: string | null; + title: string; + status: TreatmentStatus; + treatmentAt: Date; + cases: Array<{ + id: string; + clientKey: string | null; + treatmentType: string; + teeth: unknown; + comment: string | null; + sentAt: Date | null; + attachments: Array<{ + id: string; + fileName: string; + mimeType: string; + sizeBytes: number; + }>; + sends: Array<{ organizationId: string; sentAt: Date }>; + }>; + }) { + const documents = treatment.cases.flatMap((c) => + c.attachments.map((a) => this.mapAttachment(a)), + ); + + return { + id: treatment.id, + patientId: treatment.patientId, + appointmentId: treatment.appointmentId, + title: treatment.title, + treatmentAt: treatment.treatmentAt.toISOString(), + status: mapTreatmentStatusForApi(treatment.status), + cases: treatment.cases.map((c) => this.mapCase(c)), + documents, + }; + } + + private mapCase(c: { + id: string; + clientKey?: string | null; + treatmentType: string; + teeth: unknown; + comment?: string | null; + sentAt?: Date | null; + attachments?: Array<{ + id: string; + fileName: string; + mimeType: string; + sizeBytes: number; + }>; + sends?: Array<{ organizationId: string; sentAt: Date }>; + }) { + return { + id: c.id, + clientId: c.clientKey ?? c.id, + treatmentType: c.treatmentType, + teeth: normalizeTeeth(c.teeth), + notes: c.comment ?? null, + sentAt: c.sentAt?.toISOString() ?? null, + sendToOrganizationIds: c.sends?.map((s) => s.organizationId) ?? [], + attachmentMetas: (c.attachments ?? []).map((a) => this.mapAttachment(a)), + }; + } + + private mapAttachment(a: { + id: string; + fileName: string; + mimeType: string; + sizeBytes: number; + }) { + return { + id: a.id, + fileName: a.fileName, + mimeType: a.mimeType, + sizeBytes: a.sizeBytes, + }; + } + + private async getActiveLinkedOrganizationIds(organizationId: string) { + const [linksA, linksB] = await Promise.all([ + this.prisma.organizationLink.findMany({ + where: { organizationAId: organizationId, status: LinkStatus.ACTIVE }, + select: { organizationBId: true }, + }), + this.prisma.organizationLink.findMany({ + where: { organizationBId: organizationId, status: LinkStatus.ACTIVE }, + select: { organizationAId: true }, + }), + ]); + + return new Set([ + ...linksA.map((l) => l.organizationBId), + ...linksB.map((l) => l.organizationAId), + ]); + } + + private async ensurePatientInOrg(patientId: string, organizationId: string) { + const patient = await this.prisma.patient.findFirst({ + where: { id: patientId, organizationId }, + select: { id: true }, + }); + if (!patient) { + throw new NotFoundException('Patient not found'); + } + } + + private async ensureAppointmentProvider( + appointmentId: string, + organizationId: string, + actorUserId: string, + requireProviderMatch: boolean, + ) { + const appointment = await this.prisma.appointment.findFirst({ + where: { id: appointmentId, organizationId }, + select: { + id: true, + patientId: true, + providerUserId: true, + startAt: true, + }, + }); + + if (!appointment) { + throw new NotFoundException('Appointment not found'); + } + + if (requireProviderMatch) { + const membership = await this.getMembership(actorUserId, organizationId); + const isOwner = membership?.isOwner ?? false; + if (!isOwner && appointment.providerUserId !== actorUserId) { + throw new ForbiddenException('You are not the provider for this appointment'); + } + } + + return appointment; + } + + private async assertCanReadTreatment(userId: string, organizationId: string) { + const m = await this.getMembership(userId, organizationId); + if (!m) { + throw new ForbiddenException('You are not a member of this organization'); + } + if (m.isOwner) return; + const names = m.permissions.map((p) => p.permission.name); + if (names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT')) { + return; + } + throw new ForbiddenException('You do not have access to treatments'); + } + + private async assertCanEditTreatment(userId: string, organizationId: string) { + const m = await this.getMembership(userId, organizationId); + if (!m) { + throw new ForbiddenException('You are not a member of this organization'); + } + if (m.isOwner) return; + const names = m.permissions.map((p) => p.permission.name); + if (names.includes('TAB_TREATMENT_EDIT')) { + return; + } + throw new ForbiddenException('You cannot edit treatments'); + } + + private async getMembership(userId: string, organizationId: string) { + return this.prisma.membership.findFirst({ + where: { userId, organizationId, isActive: true }, + include: { permissions: { include: { permission: true } } }, + }); + } +} diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/(dashboard)/patients/page.tsx index 9acc77f..744c193 100644 --- a/frontend/src/app/(dashboard)/patients/page.tsx +++ b/frontend/src/app/(dashboard)/patients/page.tsx @@ -8,16 +8,10 @@ import { formatApiErrorMessage } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; import { hasPermission } from '@/components/shared/permissions'; -import { - CreatePatientInput, - CreateTreatmentHistoryInput, - Patient, - TreatmentHistoryItem, -} from '@/types/patient'; +import { CreatePatientInput, Patient } from '@/types/patient'; import { PatientSearchSelect } from '../../../components/ui/patient/PatientSearchSelect'; import { CreatePatientModal } from '../../../components/ui/patient/CreatePatientModal'; import { PatientSummaryCard } from '../../../components/ui/patient/PatientSummaryCard'; -import { TreatmentHistoryPreview } from '../../../components/ui/patient/TreatmentHistoryPreview'; const EMPTY_PATIENT_FORM: CreatePatientInput = { firstName: '', @@ -32,12 +26,9 @@ export default function PatientsPage() { const [search, setSearch] = useState(''); const [patients, setPatients] = useState([]); const [selectedPatient, setSelectedPatient] = useState(); - const [treatments, setTreatments] = useState([]); const [loadingPatients, setLoadingPatients] = useState(false); - const [loadingTreatments, setLoadingTreatments] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false); const [savingPatient, setSavingPatient] = useState(false); - const [savingTreatment, setSavingTreatment] = useState(false); const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); @@ -79,19 +70,6 @@ export default function PatientsPage() { } } - async function loadTreatments(patientId: string) { - setLoadingTreatments(true); - toast.setError(''); - try { - const response = await patientsApi.listTreatments(patientId); - setTreatments(response.data); - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, 'Failed to load treatment history.')); - } finally { - setLoadingTreatments(false); - } - } - async function handleCreatePatient() { setSavingPatient(true); toast.setError(''); @@ -101,7 +79,6 @@ export default function PatientsPage() { setPatientForm(EMPTY_PATIENT_FORM); await loadPatients(search); setSelectedPatient(response.data); - await loadTreatments(response.data.id); toast.showSuccess( `Patient ${response.data.firstName} ${response.data.lastName} was saved successfully.`, ); @@ -112,31 +89,6 @@ export default function PatientsPage() { } } - async function handleQuickAddTreatment() { - if (!selectedPatient) { - return; - } - - const payload: CreateTreatmentHistoryInput = { - title: 'Initial consultation', - status: 'scheduled', - treatmentAt: new Date().toISOString(), - notes: 'Created from quick action on patients page.', - }; - - setSavingTreatment(true); - toast.setError(''); - try { - await patientsApi.addTreatment(selectedPatient.id, payload); - await loadTreatments(selectedPatient.id); - toast.showSuccess('Treatment entry added successfully.'); - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, 'Failed to add treatment entry.')); - } finally { - setSavingTreatment(false); - } - } - return (
@@ -179,31 +131,13 @@ export default function PatientsPage() { onSearchChange={setSearch} patients={sortedPatients} selectedPatientId={selectedPatient?.id} - onSelectPatient={(patient) => { - setSelectedPatient(patient); - void loadTreatments(patient.id); - }} + onSelectPatient={setSelectedPatient} loading={loadingPatients} />
-
- -
-
diff --git a/frontend/src/components/shared/permissions.ts b/frontend/src/components/shared/permissions.ts index 2d1733c..210bf25 100644 --- a/frontend/src/components/shared/permissions.ts +++ b/frontend/src/components/shared/permissions.ts @@ -82,7 +82,8 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean return ( hasPermission(org, 'TAB_APPOINTMENTS_READ') || hasPermission(org, 'TAB_APPOINTMENTS_EDIT') || - hasPermission(org, 'TAB_TREATMENT_EDIT') + hasPermission(org, 'TAB_TREATMENT_EDIT') || + hasPermission(org, 'TAB_TREATMENT_READ') ); } @@ -92,3 +93,13 @@ export function canEditTreatment(org: Organization | null): boolean { if (org.isOwner) return true; return hasPermission(org, 'TAB_TREATMENT_EDIT'); } + +/** View treatment workspace (read-only or edit) */ +export function canViewTreatment(org: Organization | null): boolean { + if (!org) return false; + if (org.isOwner) return true; + return ( + hasPermission(org, 'TAB_TREATMENT_READ') || + hasPermission(org, 'TAB_TREATMENT_EDIT') + ); +} diff --git a/frontend/src/components/ui/patient/TreatmentHistoryPreview.tsx b/frontend/src/components/ui/patient/TreatmentHistoryPreview.tsx deleted file mode 100644 index 9eb80a2..0000000 --- a/frontend/src/components/ui/patient/TreatmentHistoryPreview.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { TreatmentHistoryItem } from '@/types/patient'; - -interface TreatmentHistoryPreviewProps { - items: TreatmentHistoryItem[]; - loading?: boolean; -} - -export function TreatmentHistoryPreview({ items, loading = false }: TreatmentHistoryPreviewProps) { - return ( -
-

Treatment History

- - {loading &&

Loading treatment history...

} - - {!loading && items.length === 0 && ( -

No treatment history yet.

- )} - -
- {items.map((item) => ( -
-
-

{item.title}

-

- {new Date(item.treatmentAt).toLocaleDateString()} -

-
-

- Status: {item.status} - {item.tooth ? ` | Tooth: ${item.tooth}` : ''} -

-
- ))} -
-
- ); -} diff --git a/frontend/src/components/ui/treatment/FdiToothChart.tsx b/frontend/src/components/ui/treatment/FdiToothChart.tsx index 5857f48..931efec 100644 --- a/frontend/src/components/ui/treatment/FdiToothChart.tsx +++ b/frontend/src/components/ui/treatment/FdiToothChart.tsx @@ -153,7 +153,7 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro

FDI tooth chart

- Tap teeth to multi-select (FDI). Selection applies to the active treatment record until you save. + Tap teeth to multi-select (FDI). Selection applies to the active treatment case until you save.

diff --git a/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx index 1c6c497..b59ff5e 100644 --- a/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx +++ b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx @@ -49,15 +49,15 @@ export function PastTreatmentsPanel({

Status: {t.status}

- {t.records.length > 0 && ( + {t.cases.length > 0 && (
    - {t.records.map((r) => ( -
  • - Record: - {r.treatmentType} + {t.cases.map((c) => ( +
  • + Case: + {c.treatmentType} {' | '} - {r.teeth.length > 0 ? `Teeth ${[...r.teeth].sort().join(', ')}` : 'No teeth tagged'} - {r.notes ? ` — ${r.notes}` : ''} + {c.teeth.length > 0 ? `Teeth ${[...c.teeth].sort().join(', ')}` : 'No teeth tagged'} + {c.notes ? ` — ${c.notes}` : ''}
  • ))}
diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index ed39b37..4e956eb 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -9,32 +9,35 @@ import { Button } from '@/components/ui/shared/Button'; import { Dropdown } from '@/components/ui/shared/Dropdown'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import { Toast } from '@/components/ui/shared/Toast'; -import { compareLocalDayStart, isSameLocalCalendarDay, startOfLocalDay } from '@/components/appointments/appointmentTime'; import { - fetchLinkedOrganizations, - fetchMyAppointmentsForDay, - fetchPastTreatments, - saveTreatmentDraft, - sendTreatmentRecord, -} from '@/lib/mocks/treatmentMockApi'; + addCalendarDays, + compareLocalDayStart, + isSameLocalCalendarDay, + startOfLocalDay, +} from '@/components/appointments/appointmentTime'; +import { appointmentsApi } from '@/lib/api/appointments'; +import { treatmentsApi } from '@/lib/api/treatments'; import { pickAutoAppointment } from '@/components/shared/treatmentSelection'; -import { canEditTreatment } from '@/components/shared/permissions'; +import { canEditTreatment, canViewTreatment } from '@/components/shared/permissions'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; import type { Organization } from '@/types/organization'; +import type { AppointmentRecord } from '@/types/appointment'; import type { FdiToothId, LinkedOrganizationOption, PastTreatment, + PastTreatmentCase, TreatmentAppointment, TreatmentAttachmentMeta, - TreatmentRecordDraft, + TreatmentCaseDraft, } from '@/types/treatment'; -function newRecord(): TreatmentRecordDraft { +function newCase(): TreatmentCaseDraft { return { clientId: typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() - : `rec-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + : `case-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, treatmentType: 'consultation', teeth: [], comment: '', @@ -44,12 +47,54 @@ function newRecord(): TreatmentRecordDraft { }; } +function mapAppointment(record: AppointmentRecord): TreatmentAppointment { + return { + id: record.id, + patientId: record.patientId, + patientFirstName: record.patient.firstName, + patientLastName: record.patient.lastName, + providerUserId: record.providerUserId, + startAt: record.startAt, + endAt: record.endAt, + purpose: record.purpose, + }; +} + +function mapCaseFromApi(c: PastTreatmentCase): TreatmentCaseDraft { + return { + clientId: c.clientId, + id: c.id, + treatmentType: c.treatmentType, + teeth: c.teeth, + comment: c.notes ?? '', + attachmentMetas: c.attachmentMetas ?? [], + sendToOrganizationIds: c.sendToOrganizationIds ?? [], + sentAt: c.sentAt ?? null, + }; +} + +function serializeCases(cases: TreatmentCaseDraft[]) { + return JSON.stringify( + cases.map((c) => ({ + clientId: c.clientId, + id: c.id, + treatmentType: c.treatmentType, + teeth: c.teeth, + comment: c.comment, + attachmentMetas: c.attachmentMetas, + sendToOrganizationIds: c.sendToOrganizationIds, + sentAt: c.sentAt, + })), + ); +} + interface TreatmentWorkspaceProps { userId: string; currentOrganization: Organization | null; } export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWorkspaceProps) { + const canView = canViewTreatment(currentOrganization); const canEdit = canEditTreatment(currentOrganization); const [stripHidden, setStripHidden] = useState(false); @@ -67,8 +112,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor const [orgs, setOrgs] = useState([]); - const [records, setRecords] = useState(() => [newRecord()]); - const [activeRecordId, setActiveRecordId] = useState(() => records[0].clientId); + const [cases, setCases] = useState(() => [newCase()]); + const [activeCaseId, setActiveCaseId] = useState(() => cases[0].clientId); + const [savedSnapshot, setSavedSnapshot] = useState(null); const selectionLockedRef = useRef(selectionLocked); selectionLockedRef.current = selectionLocked; @@ -77,11 +123,20 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor const [saveBusy, setSaveBusy] = useState(false); const [sendBusyId, setSendBusyId] = useState(null); + const [uploadBusy, setUploadBusy] = useState(false); const [banner, setBanner] = useState(null); + const [errorBanner, setErrorBanner] = useState(null); const [organizationSearch, setOrganizationSearch] = useState(''); const [recentOrganizationIds, setRecentOrganizationIds] = useState([]); const [reviewTreatment, setReviewTreatment] = useState(null); + const isDirty = useMemo(() => { + if (savedSnapshot === null) { + return cases.length !== 1 || cases[0].comment !== '' || cases[0].teeth.length > 0; + } + return serializeCases(cases) !== savedSnapshot; + }, [cases, savedSnapshot]); + const selectedAppointment = useMemo( () => appointments.find((a) => a.id === selectedAppointmentId) ?? null, [appointments, selectedAppointmentId], @@ -92,14 +147,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor [selectedDay, todayStart], ); - const canEditTreatmentForDay = Boolean(selectedAppointment) && !isViewingPastDay; + const canEditTreatmentForDay = canEdit && Boolean(selectedAppointment) && !isViewingPastDay; - const activeRecord = useMemo( - () => records.find((r) => r.clientId === activeRecordId) ?? records[0], - [records, activeRecordId], + const activeCase = useMemo( + () => cases.find((c) => c.clientId === activeCaseId) ?? cases[0], + [cases, activeCaseId], ); - const selectedTeethSet = useMemo(() => new Set(activeRecord.teeth), [activeRecord.teeth]); + const selectedTeethSet = useMemo(() => new Set(activeCase.teeth), [activeCase.teeth]); const activeLinkedOrganizations = useMemo(() => orgs.filter((o) => o.active), [orgs]); const filteredOrganizations = useMemo(() => { const q = organizationSearch.trim().toLowerCase(); @@ -114,6 +169,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor .sort((a, b) => recentOrganizationIds.indexOf(a.id) - recentOrganizationIds.indexOf(b.id)) .slice(0, 3); }, [recentOrganizationIds, activeLinkedOrganizations]); + const currentDraftPreview = useMemo(() => { if (!selectedAppointment) return null; return { @@ -122,26 +178,44 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor title: `Current draft for ${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`, treatmentAt: new Date().toISOString(), status: 'draft', - records: records.map((r, idx) => ({ - id: r.clientId || `draft-${idx + 1}`, - treatmentType: r.treatmentType, - teeth: r.teeth, - notes: r.comment || null, + cases: cases.map((c, idx) => ({ + id: c.id ?? c.clientId ?? `draft-${idx + 1}`, + clientId: c.clientId, + treatmentType: c.treatmentType, + teeth: c.teeth, + notes: c.comment || null, })), - documents: records.flatMap((r) => r.attachmentMetas), + documents: cases.flatMap((c) => c.attachmentMetas), }; - }, [records, selectedAppointment]); + }, [cases, selectedAppointment]); const treatmentTypeTextColor = useMemo(() => { - const map: Record = { + const map: Record = { consultation: '#ddd6fe', filling: '#fed7aa', endo: '#fecaca', visit: '#bae6fd', hygiene: '#d9f99d', }; - return map[activeRecord.treatmentType]; - }, [activeRecord.treatmentType]); + return map[activeCase.treatmentType]; + }, [activeCase.treatmentType]); + + const loadDraftForAppointment = useCallback(async (appointmentId: string) => { + const response = await treatmentsApi.getDraft(appointmentId); + if (response.data?.cases?.length) { + const mapped = response.data.cases.map(mapCaseFromApi); + setCases(mapped); + setActiveCaseId(mapped[0].clientId); + setSavedSnapshot(serializeCases(mapped)); + } else { + const first = newCase(); + setCases([first]); + setActiveCaseId(first.clientId); + setSavedSnapshot(serializeCases([first])); + } + setOrganizationSearch(''); + setReviewTreatment(null); + }, []); useEffect(() => { setSelectionLocked(false); @@ -152,12 +226,24 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor setApptsLoading(true); void (async () => { try { - const list = await fetchMyAppointmentsForDay(userId, selectedDay); + const dayStart = startOfLocalDay(selectedDay); + const dayEnd = addCalendarDays(dayStart, 1); + const response = await appointmentsApi.list({ + from: dayStart.toISOString(), + to: dayEnd.toISOString(), + }); if (cancelled) return; + const list = response.data + .filter((a) => a.providerUserId === userId) + .map(mapAppointment); setAppointments(list); if (!selectionLockedRef.current) { setSelectedAppointmentId(pickAutoAppointment(list, selectedDay)); } + } catch (error: unknown) { + if (!cancelled) { + setErrorBanner(formatApiErrorMessage(error, 'Failed to load appointments.')); + } } finally { if (!cancelled) setApptsLoading(false); } @@ -184,8 +270,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor useEffect(() => { let cancelled = false; void (async () => { - const list = await fetchLinkedOrganizations(); - if (!cancelled) setOrgs(list); + try { + const list = await treatmentsApi.listLinkedOrganizations(); + if (!cancelled) setOrgs(list.data); + } catch (error: unknown) { + if (!cancelled) { + setErrorBanner(formatApiErrorMessage(error, 'Failed to load linked organizations.')); + } + } })(); return () => { cancelled = true; @@ -200,10 +292,17 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor let cancelled = false; setHistoryLoading(true); void (async () => { - const items = await fetchPastTreatments(selectedAppointment.patientId); - if (!cancelled) { - setHistory(items); - setHistoryLoading(false); + try { + const response = await treatmentsApi.listPatientHistory(selectedAppointment.patientId); + if (!cancelled) { + setHistory(response.data); + } + } catch (error: unknown) { + if (!cancelled) { + setErrorBanner(formatApiErrorMessage(error, 'Failed to load treatment history.')); + } + } finally { + if (!cancelled) setHistoryLoading(false); } })(); return () => { @@ -212,121 +311,184 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor }, [selectedAppointment?.patientId]); useEffect(() => { - const first = newRecord(); - setRecords([first]); - setActiveRecordId(first.clientId); - setOrganizationSearch(''); - setReviewTreatment(null); - }, [selectedAppointment?.id]); + if (!selectedAppointment?.id) return; + let cancelled = false; + void (async () => { + try { + await loadDraftForAppointment(selectedAppointment.id); + } catch (error: unknown) { + if (!cancelled) { + setErrorBanner(formatApiErrorMessage(error, 'Failed to load treatment draft.')); + } + } + })(); + return () => { + cancelled = true; + }; + }, [selectedAppointment?.id, loadDraftForAppointment]); - const fixActiveAfterRecordsChange = useCallback((next: TreatmentRecordDraft[]) => { - setRecords(next); - setActiveRecordId((id) => (next.some((r) => r.clientId === id) ? id : next[0].clientId)); + const fixActiveAfterCasesChange = useCallback((next: TreatmentCaseDraft[]) => { + setCases(next); + setActiveCaseId((id) => (next.some((c) => c.clientId === id) ? id : next[0].clientId)); }, []); const toggleTooth = useCallback( (fdi: FdiToothId) => { - if (!canEdit) return; - setRecords((prev) => - prev.map((r) => { - if (r.clientId !== activeRecordId) return r; - const set = new Set(r.teeth); + if (!canEditTreatmentForDay) return; + setCases((prev) => + prev.map((c) => { + if (c.clientId !== activeCaseId) return c; + const set = new Set(c.teeth); if (set.has(fdi)) set.delete(fdi); else set.add(fdi); - return { ...r, teeth: [...set].sort() as FdiToothId[] }; + return { ...c, teeth: [...set].sort() as FdiToothId[] }; }), ); }, - [activeRecordId, canEdit], + [activeCaseId, canEditTreatmentForDay], ); - const onPickAppointment = useCallback((id: string) => { - setSelectionLocked(true); - setSelectedAppointmentId(id); - }, []); + const confirmDiscardIfDirty = useCallback(() => { + if (!isDirty) return true; + return window.confirm('You have unsaved changes. Discard them and continue?'); + }, [isDirty]); + + const onPickAppointment = useCallback( + (id: string) => { + if (!confirmDiscardIfDirty()) return; + setSelectionLocked(true); + setSelectedAppointmentId(id); + }, + [confirmDiscardIfDirty], + ); + + const onSelectDay = useCallback( + (day: Date) => { + if (!confirmDiscardIfDirty()) return; + setSelectedDay(day); + }, + [confirmDiscardIfDirty], + ); const addAttachments = useCallback( - (files: FileList | null) => { - if (!files?.length || !canEdit) return; - setRecords((prev) => - prev.map((r) => { - if (r.clientId !== activeRecordId) return r; - const added: TreatmentAttachmentMeta[] = [...r.attachmentMetas]; - for (let i = 0; i < files.length; i++) { - const f = files[i]; - added.push({ - id: `local-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 7)}`, - fileName: f.name, - mimeType: f.type || 'application/octet-stream', - sizeBytes: f.size, - }); - } - return { ...r, attachmentMetas: added }; - }), - ); + async (files: FileList | null) => { + if (!files?.length || !canEditTreatmentForDay || !selectedAppointment) return; + setUploadBusy(true); + setErrorBanner(null); + try { + const uploaded = await treatmentsApi.uploadCaseAttachments( + selectedAppointment.id, + activeCaseId, + Array.from(files), + ); + setCases((prev) => + prev.map((c) => { + if (c.clientId !== activeCaseId) return c; + return { ...c, attachmentMetas: [...c.attachmentMetas, ...uploaded.data] }; + }), + ); + } catch (error: unknown) { + setErrorBanner(formatApiErrorMessage(error, 'Failed to upload attachments.')); + } finally { + setUploadBusy(false); + } }, - [activeRecordId, canEdit], + [activeCaseId, canEditTreatmentForDay, selectedAppointment], ); + const persistDraft = useCallback(async () => { + if (!selectedAppointment) { + throw new Error('No appointment selected'); + } + const response = await treatmentsApi.saveDraft(selectedAppointment.id, { + cases: cases.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({ + clientId, + id, + treatmentType, + teeth, + comment, + attachmentIds: attachmentMetas.map((a) => a.id), + })), + }); + const mapped = response.data.cases.map(mapCaseFromApi); + setCases(mapped); + setActiveCaseId((prev) => { + const stillExists = mapped.some((c) => c.clientId === prev); + return stillExists ? prev : mapped[0]?.clientId ?? prev; + }); + setSavedSnapshot(serializeCases(mapped)); + return response.data; + }, [cases, selectedAppointment]); + const handleSaveAll = useCallback(async () => { - if (!canEdit || !selectedAppointment) return; + if (!canEditTreatmentForDay || !selectedAppointment) return; setSaveBusy(true); setBanner(null); + setErrorBanner(null); try { - await saveTreatmentDraft({ - appointmentId: selectedAppointment.id, - patientId: selectedAppointment.patientId, - records: records.map(({ clientId: _c, sentAt: _s, ...rest }) => rest), - }); - setBanner('Treatment draft saved (mock). You can send records later.'); + await persistDraft(); + setBanner('Treatment draft saved. You can send cases later.'); + } catch (error: unknown) { + setErrorBanner(formatApiErrorMessage(error, 'Failed to save treatment draft.')); } finally { setSaveBusy(false); } - }, [canEdit, selectedAppointment, records]); + }, [canEditTreatmentForDay, selectedAppointment, persistDraft]); - const handleSendRecord = useCallback( - async (record: TreatmentRecordDraft) => { - if (!canEdit || !selectedAppointment) return; - const targets = record.sendToOrganizationIds.filter((id) => + const handleSendCase = useCallback( + async (treatmentCase: TreatmentCaseDraft) => { + if (!canEditTreatmentForDay || !selectedAppointment) return; + const targets = treatmentCase.sendToOrganizationIds.filter((id) => orgs.some((o) => o.id === id && o.active), ); if (targets.length === 0) { - setBanner('Choose at least one active organization to send this record.'); + setErrorBanner('Choose at least one active organization to send this case.'); return; } - setSendBusyId(record.clientId); + setSendBusyId(treatmentCase.clientId); setBanner(null); + setErrorBanner(null); try { - await sendTreatmentRecord({ - appointmentId: selectedAppointment.id, - patientId: selectedAppointment.patientId, - recordClientId: record.clientId, - organizationIds: targets, + const saved = await persistDraft(); + const serverCase = saved.cases.find((c) => c.clientId === treatmentCase.clientId); + if (!serverCase?.id) { + throw new Error('Case must be saved before sending.'); + } + const response = await treatmentsApi.sendCase(serverCase.id, { organizationIds: targets }); + setCases((prev) => { + const next = prev.map((c) => + c.clientId === treatmentCase.clientId + ? { + ...c, + id: response.data.id, + sentAt: response.data.sentAt, + sendToOrganizationIds: response.data.sendToOrganizationIds, + } + : c, + ); + setSavedSnapshot(serializeCases(next)); + return next; }); - setRecords((prev) => - prev.map((r) => - r.clientId === record.clientId ? { ...r, sentAt: new Date().toISOString() } : r, - ), - ); setRecentOrganizationIds((prev) => { - const next = [...record.sendToOrganizationIds.filter((id) => id && !prev.includes(id)), ...prev]; + const next = [...targets.filter((id) => id && !prev.includes(id)), ...prev]; return next.slice(0, 10); }); - setBanner('Record sent to selected organizations (mock).'); + setBanner('Case sent to selected organizations.'); + } catch (error: unknown) { + setErrorBanner(formatApiErrorMessage(error, 'Failed to send case.')); } finally { setSendBusyId(null); } }, - [canEdit, selectedAppointment, orgs], + [canEditTreatmentForDay, selectedAppointment, orgs, persistDraft, cases], ); - if (!canEdit) { + if (!canView) { return (

Treatment workspace

- Your role can view the Treatment tab, but editing clinical workflows requires{' '} - Treatment edit permission. + You do not have permission to view the Treatment tab for this organization.

); @@ -337,7 +499,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor

Treatment

- Mocked data — appointments, history, save, and send are simulated until backend endpoints exist. + {canEdit + ? 'Document cases for your appointments, save drafts, and send work to linked organizations.' + : 'View-only access — you can review appointments and treatment history but cannot edit.'}

@@ -345,7 +509,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor stripHidden={stripHidden} onToggleStripHidden={() => setStripHidden((s) => !s)} selectedDay={selectedDay} - onSelectDay={setSelectedDay} + onSelectDay={onSelectDay} appointments={appointments} selectedAppointmentId={selectedAppointmentId} onSelectAppointment={onPickAppointment} @@ -354,7 +518,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor {isViewingPastDay && (

- Past days are view-only. You can review appointments and history, but treatment records + Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.

)} @@ -411,16 +575,16 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor

Status: {reviewTreatment.status}

- {reviewTreatment.records.map((r, idx) => ( -
-

Record {idx + 1}

+ {reviewTreatment.cases.map((c, idx) => ( +
+

Case {idx + 1}

- Type: {r.treatmentType} + Type: {c.treatmentType}

- Teeth: {r.teeth.length ? [...r.teeth].sort().join(', ') : 'None selected'} + Teeth: {c.teeth.length ? [...c.teeth].sort().join(', ') : 'None selected'}

- {r.notes &&

Notes: {r.notes}

} + {c.notes &&

Notes: {c.notes}

}
))}
@@ -442,9 +606,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
-

Treatment records

+

Treatment cases

- Each record has its own teeth, notes, attachments, and destinations for send. + Each case has its own teeth, notes, attachments, and destinations for send.

- {records.map((r, idx) => ( + {cases.map((c, idx) => ( ))}
- {activeRecord && ( + {activeCase && (