feature: a minimal v1 backend implemented for treatments feature.

This commit is contained in:
2026-05-19 22:29:29 +03:30
parent 935bb8c214
commit f743046b38
27 changed files with 1636 additions and 656 deletions

1
backend/.gitignore vendored
View File

@@ -45,6 +45,7 @@ lerna-debug.log*
# temp directory
.temp
.tmp
/uploads
# Runtime data
pids

View File

@@ -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": {

View File

@@ -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",

View File

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

View File

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

View File

@@ -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(),

View File

@@ -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');
}

View File

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

View File

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

View File

@@ -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 },

View File

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

View File

@@ -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');
});
});

View File

@@ -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<string>();
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';
}

View File

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

View File

@@ -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 {}

View File

@@ -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 } } },
});
}
}

View File

@@ -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<Patient[]>([]);
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
const [treatments, setTreatments] = useState<TreatmentHistoryItem[]>([]);
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<CreatePatientInput>(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 (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
@@ -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}
/>
</div>
<div className="xl:col-span-2 space-y-4">
<PatientSummaryCard patient={selectedPatient} />
<div className="flex">
<Button
variant="secondary"
disabled={!selectedPatient || !canEditPatients}
isLoading={savingTreatment}
onClick={() => {
if (!canEditPatients) return;
void handleQuickAddTreatment();
}}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
>
Add Quick Treatment Entry
</Button>
</div>
<TreatmentHistoryPreview items={treatments} loading={loadingTreatments} />
</div>
</div>
</div>

View File

@@ -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')
);
}

View File

@@ -1,37 +0,0 @@
import { TreatmentHistoryItem } from '@/types/patient';
interface TreatmentHistoryPreviewProps {
items: TreatmentHistoryItem[];
loading?: boolean;
}
export function TreatmentHistoryPreview({ items, loading = false }: TreatmentHistoryPreviewProps) {
return (
<div className="surface-card p-4 space-y-3">
<h3 className="text-base font-semibold text-text-primary">Treatment History</h3>
{loading && <p className="text-sm text-text-muted">Loading treatment history...</p>}
{!loading && items.length === 0 && (
<p className="text-sm text-text-muted">No treatment history yet.</p>
)}
<div className="space-y-2">
{items.map((item) => (
<div key={item.id} className="border border-border/60 rounded-[var(--radius-sm)] p-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-text-primary">{item.title}</p>
<p className="text-xs text-text-muted">
{new Date(item.treatmentAt).toLocaleDateString()}
</p>
</div>
<p className="text-xs text-text-secondary mt-1">
Status: {item.status}
{item.tooth ? ` | Tooth: ${item.tooth}` : ''}
</p>
</div>
))}
</div>
</div>
);
}

View File

@@ -153,7 +153,7 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
<div>
<h3 className="text-sm font-semibold text-text-primary">FDI tooth chart</h3>
<p className="text-xs text-text-muted mt-0.5">
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.
</p>
</div>
<div className="flex flex-col gap-2 sm:items-end w-full sm:max-w-xs">

View File

@@ -49,15 +49,15 @@ export function PastTreatmentsPanel({
</div>
<p className="text-xs text-text-secondary mt-1">Status: {t.status}</p>
{t.records.length > 0 && (
{t.cases.length > 0 && (
<ul className="mt-2 space-y-1.5 text-xs text-text-secondary">
{t.records.map((r) => (
<li key={r.id}>
<span className="text-text-primary font-medium">Record: </span>
<span className="capitalize">{r.treatmentType}</span>
{t.cases.map((c) => (
<li key={c.id}>
<span className="text-text-primary font-medium">Case: </span>
<span className="capitalize">{c.treatmentType}</span>
{' | '}
{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}` : ''}
</li>
))}
</ul>

View File

@@ -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<LinkedOrganizationOption[]>([]);
const [records, setRecords] = useState<TreatmentRecordDraft[]>(() => [newRecord()]);
const [activeRecordId, setActiveRecordId] = useState<string>(() => records[0].clientId);
const [cases, setCases] = useState<TreatmentCaseDraft[]>(() => [newCase()]);
const [activeCaseId, setActiveCaseId] = useState<string>(() => cases[0].clientId);
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(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<string | null>(null);
const [uploadBusy, setUploadBusy] = useState(false);
const [banner, setBanner] = useState<string | null>(null);
const [errorBanner, setErrorBanner] = useState<string | null>(null);
const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
const [reviewTreatment, setReviewTreatment] = useState<PastTreatment | null>(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<PastTreatment | null>(() => {
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<TreatmentRecordDraft['treatmentType'], string> = {
const map: Record<TreatmentCaseDraft['treatmentType'], string> = {
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 (
<div className="surface-card p-6 max-w-xl">
<h2 className="text-lg font-semibold text-text-primary">Treatment workspace</h2>
<p className="text-sm text-text-secondary mt-2">
Your role can view the Treatment tab, but editing clinical workflows requires{' '}
<span className="text-text-primary font-medium">Treatment edit</span> permission.
You do not have permission to view the Treatment tab for this organization.
</p>
</div>
);
@@ -337,7 +499,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<header className="space-y-1">
<h1 className="text-2xl font-semibold text-text-primary">Treatment</h1>
<p className="text-sm text-text-secondary">
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.'}
</p>
</header>
@@ -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 && (
<p className="text-sm text-text-secondary rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/50 px-3 py-2">
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.
</p>
)}
@@ -411,16 +575,16 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
</div>
<p className="text-xs text-text-secondary capitalize">Status: {reviewTreatment.status}</p>
<div className="space-y-2">
{reviewTreatment.records.map((r, idx) => (
<div key={r.id} className="rounded-[var(--radius-sm)] border border-border/60 px-2.5 py-2">
<p className="text-xs text-text-primary font-medium">Record {idx + 1}</p>
{reviewTreatment.cases.map((c, idx) => (
<div key={c.id} className="rounded-[var(--radius-sm)] border border-border/60 px-2.5 py-2">
<p className="text-xs text-text-primary font-medium">Case {idx + 1}</p>
<p className="text-xs text-text-secondary capitalize mt-1">
Type: {r.treatmentType}
Type: {c.treatmentType}
</p>
<p className="text-xs text-text-secondary">
Teeth: {r.teeth.length ? [...r.teeth].sort().join(', ') : 'None selected'}
Teeth: {c.teeth.length ? [...c.teeth].sort().join(', ') : 'None selected'}
</p>
{r.notes && <p className="text-xs text-text-muted mt-1">Notes: {r.notes}</p>}
{c.notes && <p className="text-xs text-text-muted mt-1">Notes: {c.notes}</p>}
</div>
))}
</div>
@@ -442,9 +606,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<div className="surface-card p-4 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-primary">Treatment records</h3>
<h3 className="text-sm font-semibold text-text-primary">Treatment cases</h3>
<p className="text-xs text-text-muted mt-0.5">
Each record has its own teeth, notes, attachments, and destinations for send.
Each case has its own teeth, notes, attachments, and destinations for send.
</p>
</div>
<Button
@@ -452,54 +616,52 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
variant="primary"
disabled={!canEditTreatmentForDay}
onClick={() => {
const nr = newRecord();
fixActiveAfterRecordsChange([...records, nr]);
setActiveRecordId(nr.clientId);
const nextCase = newCase();
fixActiveAfterCasesChange([...cases, nextCase]);
setActiveCaseId(nextCase.clientId);
}}
>
Add record
Add case
</Button>
</div>
<div className="flex flex-wrap gap-2">
{records.map((r, idx) => (
{cases.map((c, idx) => (
<button
key={r.clientId}
key={c.clientId}
type="button"
onClick={() => setActiveRecordId(r.clientId)}
onClick={() => setActiveCaseId(c.clientId)}
className={`
rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${
r.clientId === activeRecordId
c.clientId === activeCaseId
? 'border-primary bg-primary-soft font-medium text-text-primary'
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
}
`}
>
Record {idx + 1}
{r.sentAt ? ' · sent' : ''}
Case {idx + 1}
{c.sentAt ? ' · sent' : ''}
</button>
))}
</div>
{activeRecord && (
{activeCase && (
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
<label className="block text-xs font-medium text-text-secondary">
Comments
<textarea
value={activeRecord.comment}
value={activeCase.comment}
onChange={(e) => {
const v = e.target.value;
setRecords((prev) =>
prev.map((r) =>
r.clientId === activeRecordId ? { ...r, comment: v } : r,
),
setCases((prev) =>
prev.map((c) => (c.clientId === activeCaseId ? { ...c, comment: v } : c)),
);
}}
placeholder="Write clinical notes for this record…"
placeholder="Write clinical notes for this case…"
rows={5}
disabled={!canEditTreatmentForDay}
disabled={!canEditTreatmentForDay || Boolean(activeCase.sentAt)}
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[120px]"
/>
</label>
@@ -507,16 +669,16 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<div>
<Dropdown
label="Treatment type"
value={activeRecord.treatmentType}
value={activeCase.treatmentType}
onChange={(e) => {
const nextType = e.target.value as TreatmentRecordDraft['treatmentType'];
setRecords((prev) =>
prev.map((r) =>
r.clientId === activeRecordId ? { ...r, treatmentType: nextType } : r,
const nextType = e.target.value as TreatmentCaseDraft['treatmentType'];
setCases((prev) =>
prev.map((c) =>
c.clientId === activeCaseId ? { ...c, treatmentType: nextType } : c,
),
);
}}
disabled={!canEditTreatmentForDay}
disabled={!canEditTreatmentForDay || Boolean(activeCase.sentAt)}
className="capitalize"
style={{ color: treatmentTypeTextColor }}
>
@@ -529,32 +691,33 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
</div>
<div>
<p className="text-xs font-medium text-text-secondary mb-2">Attachments (mock)</p>
<p className="text-xs font-medium text-text-secondary mb-2">Attachments</p>
<input
ref={attachmentInputRef}
id="treatment-record-attachments"
id="treatment-case-attachments"
type="file"
multiple
disabled={!canEditTreatmentForDay}
disabled={!canEditTreatmentForDay || uploadBusy || Boolean(activeCase.sentAt)}
onChange={(e) => {
addAttachments(e.target.files);
void addAttachments(e.target.files);
e.target.value = '';
}}
className="sr-only"
aria-label="Attach files for this treatment record"
aria-label="Attach files for this treatment case"
/>
<Button
type="button"
variant="primary"
disabled={!canEditTreatmentForDay}
disabled={!canEditTreatmentForDay || uploadBusy || Boolean(activeCase.sentAt)}
isLoading={uploadBusy}
onClick={() => attachmentInputRef.current?.click()}
aria-controls="treatment-record-attachments"
aria-controls="treatment-case-attachments"
>
Choose files
</Button>
{activeRecord.attachmentMetas.length > 0 && (
{activeCase.attachmentMetas.length > 0 && (
<ul className="mt-2 space-y-1 text-xs text-text-muted">
{activeRecord.attachmentMetas.map((f) => (
{activeCase.attachmentMetas.map((f) => (
<li key={f.id} className="truncate">
{f.fileName} ({(f.sizeBytes / 1024).toFixed(1)} KB)
</li>
@@ -565,7 +728,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<div>
<p className="text-xs font-medium text-text-secondary mb-2">
Send this record to linked organizations
Send this case to linked organizations
</p>
<div className="space-y-2 mb-2">
<SearchBar
@@ -580,16 +743,17 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<button
key={o.id}
type="button"
disabled={!canEditTreatmentForDay || Boolean(activeCase.sentAt)}
onClick={() => {
setRecords((prev) =>
prev.map((r) => {
if (r.clientId !== activeRecordId || r.sentAt) return r;
if (r.sendToOrganizationIds.includes(o.id)) return r;
return { ...r, sendToOrganizationIds: [...r.sendToOrganizationIds, o.id] };
setCases((prev) =>
prev.map((c) => {
if (c.clientId !== activeCaseId || c.sentAt) return c;
if (c.sendToOrganizationIds.includes(o.id)) return c;
return { ...c, sendToOrganizationIds: [...c.sendToOrganizationIds, o.id] };
}),
);
}}
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35"
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
>
{o.name}
</button>
@@ -601,20 +765,20 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
{filteredOrganizations.map((o) => (
<Checkbox
key={o.id}
checked={activeRecord.sendToOrganizationIds.includes(o.id)}
disabled={!canEditTreatmentForDay || Boolean(activeRecord.sentAt)}
checked={activeCase.sendToOrganizationIds.includes(o.id)}
disabled={!canEditTreatmentForDay || Boolean(activeCase.sentAt)}
onChange={(checked) => {
setRecords((prev) =>
prev.map((r) => {
if (r.clientId !== activeRecordId) return r;
const next = new Set(r.sendToOrganizationIds);
setCases((prev) =>
prev.map((c) => {
if (c.clientId !== activeCaseId) return c;
const next = new Set(c.sendToOrganizationIds);
if (checked) next.add(o.id);
else next.delete(o.id);
return { ...r, sendToOrganizationIds: [...next] };
return { ...c, sendToOrganizationIds: [...next] };
}),
);
}}
label={`${o.name}${o.active ? '' : ' (inactive)'}`}
label={o.name}
/>
))}
{filteredOrganizations.length === 0 && (
@@ -627,43 +791,54 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
<Button
type="button"
variant="primary"
disabled={!canEditTreatmentForDay || Boolean(activeRecord.sentAt) || sendBusyId === activeRecord.clientId}
isLoading={sendBusyId === activeRecord.clientId}
onClick={() => void handleSendRecord(activeRecord)}
disabled={
!canEditTreatmentForDay ||
Boolean(activeCase.sentAt) ||
sendBusyId === activeCase.clientId
}
isLoading={sendBusyId === activeCase.clientId}
onClick={() => void handleSendCase(activeCase)}
>
Send this record
Send this case
</Button>
{activeRecord.sentAt && (
{activeCase.sentAt && (
<span className="text-xs text-text-muted">
Sent at {new Date(activeRecord.sentAt).toLocaleString()}
Sent at {new Date(activeCase.sentAt).toLocaleString()}
</span>
)}
</div>
</div>
)}
<div className="flex flex-wrap gap-3 pt-2 border-t border-border/60">
<Button
type="button"
variant="primary"
disabled={!canEditTreatmentForDay || saveBusy}
isLoading={saveBusy}
onClick={() => void handleSaveAll()}
>
Save treatment draft
</Button>
<p className="text-xs text-text-muted self-center">
Saving stores all records locally (mock). Sending is per record and can happen after save.
</p>
</div>
{canEdit && (
<div className="flex flex-wrap gap-3 pt-2 border-t border-border/60">
<Button
type="button"
variant="primary"
disabled={!canEditTreatmentForDay || saveBusy}
isLoading={saveBusy}
onClick={() => void handleSaveAll()}
>
Save treatment draft
</Button>
<p className="text-xs text-text-muted self-center">
{isDirty ? 'Unsaved changes' : 'Draft saved'}. Sending is per case and saves first automatically.
</p>
</div>
)}
</div>
</div>
</div>
{banner && (
{(banner || errorBanner) && (
<div className="fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none">
<div className="pointer-events-auto w-full">
<Toast variant="success">{banner}</Toast>
{banner && <Toast variant="success">{banner}</Toast>}
{errorBanner && (
<div className={banner ? 'mt-2' : ''}>
<Toast variant="danger">{errorBanner}</Toast>
</div>
)}
</div>
</div>
)}

View File

@@ -1,10 +1,8 @@
import { apiClient } from './client';
import {
CreatePatientInput,
CreateTreatmentHistoryInput,
Patient,
PatientsListResponse,
TreatmentHistoryItem,
} from '@/types/patient';
export const patientsApi = {
@@ -22,22 +20,4 @@ export const patientsApi = {
const response = await apiClient.get(`/patients/${id}`);
return response.data;
},
listTreatments: async (
patientId: string,
limit = 20,
): Promise<{ success: boolean; data: TreatmentHistoryItem[] }> => {
const response = await apiClient.get(`/patients/${patientId}/treatments`, {
params: { limit },
});
return response.data;
},
addTreatment: async (
patientId: string,
data: CreateTreatmentHistoryInput,
): Promise<{ success: boolean; data: TreatmentHistoryItem }> => {
const response = await apiClient.post(`/patients/${patientId}/treatments`, data);
return response.data;
},
};

View File

@@ -0,0 +1,76 @@
import { apiClient } from './client';
import type {
LinkedOrganizationOption,
PastTreatment,
SaveTreatmentPayload,
SendTreatmentCasePayload,
TreatmentAttachmentMeta,
} from '@/types/treatment';
export const treatmentsApi = {
listLinkedOrganizations: async (): Promise<{ success: boolean; data: LinkedOrganizationOption[] }> => {
const response = await apiClient.get('/treatments/linked-organizations');
return response.data;
},
listPatientHistory: async (
patientId: string,
limit = 20,
): Promise<{ success: boolean; data: PastTreatment[] }> => {
const response = await apiClient.get(`/treatments/patients/${patientId}/history`, {
params: { limit },
});
return response.data;
},
getDraft: async (
appointmentId: string,
): Promise<{ success: boolean; data: PastTreatment | null }> => {
const response = await apiClient.get(`/treatments/appointments/${appointmentId}/draft`);
return response.data;
},
saveDraft: async (
appointmentId: string,
payload: Pick<SaveTreatmentPayload, 'cases'>,
): Promise<{ success: boolean; data: PastTreatment }> => {
const response = await apiClient.put(`/treatments/appointments/${appointmentId}/draft`, payload);
return response.data;
},
uploadCaseAttachments: async (
appointmentId: string,
caseClientId: string,
files: File[],
): Promise<{ success: boolean; data: TreatmentAttachmentMeta[] }> => {
const form = new FormData();
for (const file of files) {
form.append('files', file);
}
const response = await apiClient.post(
`/treatments/appointments/${appointmentId}/cases/${encodeURIComponent(caseClientId)}/attachments`,
form,
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120_000 },
);
return response.data;
},
sendCase: async (
caseId: string,
payload: SendTreatmentCasePayload,
): Promise<{ success: boolean; data: PastTreatmentCaseResponse }> => {
const response = await apiClient.post(`/treatments/cases/${caseId}/send`, payload);
return response.data;
},
};
export interface PastTreatmentCaseResponse {
id: string;
clientId: string;
treatmentType: string;
teeth: string[];
notes: string | null;
sentAt: string | null;
sendToOrganizationIds: string[];
attachmentMetas: TreatmentAttachmentMeta[];
}

View File

@@ -1,200 +0,0 @@
import {
addCalendarDays,
isSameLocalCalendarDay,
startOfLocalDay,
} from '@/components/appointments/appointmentTime';
import type {
FdiToothId,
LinkedOrganizationOption,
PastTreatment,
SaveTreatmentPayload,
SendTreatmentRecordPayload,
TreatmentAppointment,
} from '@/types/treatment';
const MOCK_DELAY_MS = 280;
function sleep(ms = MOCK_DELAY_MS) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
function pad2(n: number) {
return String(n).padStart(2, '0');
}
/** Builds deterministic ids so React keys stay stable across hot reloads */
function mockApptId(dayKey: string, slotIndex: number | string) {
return `mock-appt-${dayKey}-${slotIndex}`;
}
function buildSlotsForDay(day: Date, userId: string): TreatmentAppointment[] {
const dayStart = startOfLocalDay(day);
const y = dayStart.getFullYear();
const m = dayStart.getMonth();
const d = dayStart.getDate();
const dayKey = `${y}-${pad2(m + 1)}-${pad2(d)}`;
const base: Omit<TreatmentAppointment, 'id' | 'startAt' | 'endAt'>[] = [
{
patientId: 'pat-alice',
patientFirstName: 'Alice',
patientLastName: 'Moradi',
providerUserId: userId,
purpose: 'consultation',
},
{
patientId: 'pat-babak',
patientFirstName: 'Babak',
patientLastName: 'Karimi',
providerUserId: userId,
purpose: 'filling',
},
{
patientId: 'pat-sara',
patientFirstName: 'Sara',
patientLastName: 'Hosseini',
providerUserId: userId,
purpose: 'endo',
},
];
const slots: TreatmentAppointment[] = [];
const times = [
[9, 0, 9, 45],
[11, 15, 12, 0],
[14, 30, 15, 30],
];
times.forEach(([h1, m1, h2, m2], i) => {
const baseSlot = base[i % base.length];
const startAt = new Date(y, m, d, h1, m1, 0, 0).toISOString();
const endAt = new Date(y, m, d, h2, m2, 0, 0).toISOString();
slots.push({
id: mockApptId(dayKey, i),
...baseSlot,
startAt,
endAt,
});
});
const today = new Date();
if (isSameLocalCalendarDay(day, today)) {
const now = today.getTime();
const start = new Date(now - 12 * 60 * 1000);
const end = new Date(now + 48 * 60 * 1000);
slots.unshift({
id: mockApptId(dayKey, 'now'),
patientId: 'pat-hesam',
patientFirstName: 'Hesam',
patientLastName: 'Aghaie',
providerUserId: userId,
startAt: start.toISOString(),
endAt: end.toISOString(),
purpose: 'visit',
});
}
return slots.sort((a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime());
}
const MOCK_HISTORY: Record<string, PastTreatment[]> = {
'pat-hesam': [
{
id: 'pt-h-1',
patientId: 'pat-hesam',
title: 'Root canal 45',
treatmentAt: new Date(2026, 8, 12).toISOString(),
status: 'completed',
records: [
{
id: 'ptr-1',
treatmentType: 'endo',
teeth: ['45'] as FdiToothId[],
notes: 'Instrumented and temporized.',
},
],
documents: [
{
id: 'doc-1',
fileName: 'periapical-45.png',
mimeType: 'image/png',
sizeBytes: 842_120,
},
{
id: 'doc-2',
fileName: 'consent-signed.pdf',
mimeType: 'application/pdf',
sizeBytes: 312_000,
},
],
},
{
id: 'pt-h-2',
patientId: 'pat-hesam',
title: 'Filling 14, 15',
treatmentAt: new Date(2025, 9, 10).toISOString(),
status: 'completed',
records: [
{
id: 'ptr-2',
treatmentType: 'filling',
teeth: ['14', '15'] as FdiToothId[],
notes: 'Composite restoration.',
},
],
documents: [
{
id: 'doc-3',
fileName: 'notes.txt',
mimeType: 'text/plain',
sizeBytes: 420,
},
],
},
],
'pat-alice': [
{
id: 'pt-a-1',
patientId: 'pat-alice',
title: 'Hygiene visit',
treatmentAt: addCalendarDays(new Date(), -21).toISOString(),
status: 'completed',
records: [{ id: 'ptr-a', treatmentType: 'hygiene', teeth: [], notes: 'Scale & polish.' }],
documents: [],
},
],
};
const MOCK_ORGS: LinkedOrganizationOption[] = [
{ id: 'org-lab-north', name: 'North Dental Lab', active: true },
{ id: 'org-lab-smile', name: 'Smile Ceramics', active: true },
{ id: 'org-lab-old', name: 'Legacy Lab (inactive)', active: false },
];
export async function fetchMyAppointmentsForDay(
userId: string,
day: Date,
): Promise<TreatmentAppointment[]> {
await sleep();
return buildSlotsForDay(day, userId);
}
export async function fetchPastTreatments(patientId: string): Promise<PastTreatment[]> {
await sleep();
return MOCK_HISTORY[patientId] ?? [];
}
export async function fetchLinkedOrganizations(): Promise<LinkedOrganizationOption[]> {
await sleep(180);
return MOCK_ORGS;
}
export async function saveTreatmentDraft(_payload: SaveTreatmentPayload): Promise<{ ok: true }> {
await sleep();
return { ok: true };
}
export async function sendTreatmentRecord(_payload: SendTreatmentRecordPayload): Promise<{ ok: true }> {
await sleep();
return { ok: true };
}

View File

@@ -12,19 +12,6 @@ export interface Patient {
updatedAt: string;
}
export interface TreatmentHistoryItem {
id: string;
patientId: string;
title: string;
status: string;
treatmentAt: string;
tooth?: string | null;
notes?: string | null;
totalCost?: number | null;
createdAt: string;
updatedAt: string;
}
export interface CreatePatientInput {
firstName: string;
lastName: string;
@@ -34,15 +21,6 @@ export interface CreatePatientInput {
notes?: string;
}
export interface CreateTreatmentHistoryInput {
title: string;
status: string;
treatmentAt: string;
tooth?: string;
notes?: string;
totalCost?: number;
}
export interface PatientsListResponse {
success: boolean;
data: {

View File

@@ -61,20 +61,25 @@ export const TREATMENT_TYPES = [
export type TreatmentType = (typeof TREATMENT_TYPES)[number];
export interface PastTreatmentRecord {
export interface PastTreatmentCase {
id: string;
clientId: string;
treatmentType: TreatmentType;
teeth: FdiToothId[];
notes?: string | null;
attachmentMetas?: TreatmentAttachmentMeta[];
sendToOrganizationIds?: string[];
sentAt?: string | null;
}
export interface PastTreatment {
id: string;
patientId: string;
appointmentId?: string | null;
title: string;
treatmentAt: string;
status: string;
records: PastTreatmentRecord[];
cases: PastTreatmentCase[];
documents: TreatmentAttachmentMeta[];
}
@@ -84,29 +89,32 @@ export interface LinkedOrganizationOption {
active: boolean;
}
export interface TreatmentRecordDraft {
export interface TreatmentCaseDraft {
clientId: string;
id?: string;
treatmentType: TreatmentType;
teeth: FdiToothId[];
comment: string;
attachmentMetas: TreatmentAttachmentMeta[];
/** Organizations selected for sending this record (mock only until API exists) */
sendToOrganizationIds: string[];
sentAt?: string | null;
}
/** Payload for persisting a draft (mock API); omits ephemeral client-only fields */
export type SavedTreatmentRecordPayload = Omit<TreatmentRecordDraft, 'clientId' | 'sentAt'>;
export type SavedTreatmentCasePayload = {
clientId: string;
id?: string;
treatmentType: TreatmentType;
teeth: FdiToothId[];
comment: string;
attachmentIds: string[];
};
export interface SaveTreatmentPayload {
appointmentId: string;
patientId: string;
records: SavedTreatmentRecordPayload[];
cases: SavedTreatmentCasePayload[];
}
export interface SendTreatmentRecordPayload {
appointmentId: string;
patientId: string;
recordClientId: string;
export interface SendTreatmentCasePayload {
organizationIds: string[];
}