feature/treatment-backend #20
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -45,6 +45,7 @@ lerna-debug.log*
|
|||||||
# temp directory
|
# temp directory
|
||||||
.temp
|
.temp
|
||||||
.tmp
|
.tmp
|
||||||
|
/uploads
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
pids
|
pids
|
||||||
|
|||||||
14
backend/package-lock.json
generated
14
backend/package-lock.json
generated
@@ -35,6 +35,7 @@
|
|||||||
"express-formidable": "^1.2.0",
|
"express-formidable": "^1.2.0",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/express-session": "^1.18.2",
|
"@types/express-session": "^1.18.2",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
@@ -6251,6 +6253,16 @@
|
|||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.17",
|
"version": "22.19.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||||
@@ -12374,7 +12386,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.1.1",
|
"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==",
|
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
"@nestjs/swagger": "^11.2.6",
|
"@nestjs/swagger": "^11.2.6",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"prisma": "^6.19.2",
|
|
||||||
"adminjs": "^7.8.17",
|
"adminjs": "^7.8.17",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@@ -54,10 +53,12 @@
|
|||||||
"express-formidable": "^1.2.0",
|
"express-formidable": "^1.2.0",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"multer": "^2.1.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
|
"prisma": "^6.19.2",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"styled-components": "^6.3.11",
|
"styled-components": "^6.3.11",
|
||||||
@@ -73,6 +74,7 @@
|
|||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/express-session": "^1.18.2",
|
"@types/express-session": "^1.18.2",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -61,6 +61,8 @@ model Organization {
|
|||||||
sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter")
|
sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter")
|
||||||
patients Patient[]
|
patients Patient[]
|
||||||
appointments Appointment[]
|
appointments Appointment[]
|
||||||
|
treatments Treatment[]
|
||||||
|
caseSends TreatmentCaseSend[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -82,7 +84,7 @@ model Patient {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id])
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
treatments PatientTreatmentHistory[]
|
treatments Treatment[]
|
||||||
appointments Appointment[]
|
appointments Appointment[]
|
||||||
|
|
||||||
@@index([organizationId, createdAt])
|
@@index([organizationId, createdAt])
|
||||||
@@ -90,24 +92,6 @@ model Patient {
|
|||||||
@@map("patients")
|
@@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 {
|
model Appointment {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
organizationId String
|
organizationId String
|
||||||
@@ -119,6 +103,7 @@ model Appointment {
|
|||||||
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||||
|
treatment Treatment?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -128,6 +113,84 @@ model Appointment {
|
|||||||
@@map("appointments")
|
@@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 {
|
model Plan {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"
|
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PatientsModule } from './modules/patients/patients.module';
|
|||||||
import { StaffModule } from './modules/staff/staff.module';
|
import { StaffModule } from './modules/staff/staff.module';
|
||||||
import { OrganizationModule } from './modules/organization/organization.module';
|
import { OrganizationModule } from './modules/organization/organization.module';
|
||||||
import { AppointmentsModule } from './modules/appointments/appointments.module';
|
import { AppointmentsModule } from './modules/appointments/appointments.module';
|
||||||
|
import { TreatmentsModule } from './modules/treatments/treatments.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -21,6 +22,7 @@ import { AppointmentsModule } from './modules/appointments/appointments.module';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
PatientsModule,
|
PatientsModule,
|
||||||
AppointmentsModule,
|
AppointmentsModule,
|
||||||
|
TreatmentsModule,
|
||||||
StaffModule,
|
StaffModule,
|
||||||
OrganizationModule,
|
OrganizationModule,
|
||||||
AdminModule.forRoot(),
|
AdminModule.forRoot(),
|
||||||
|
|||||||
@@ -220,6 +220,9 @@ export class AppointmentsService {
|
|||||||
if (names.includes('TAB_TREATMENT_EDIT')) {
|
if (names.includes('TAB_TREATMENT_EDIT')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (names.includes('TAB_TREATMENT_READ')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw new ForbiddenException('You do not have access to appointments');
|
throw new ForbiddenException('You do not have access to appointments');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import { CreatePatientDto } from './dto/create-patient.dto';
|
|||||||
import { ListPatientsDto } from './dto/list-patients.dto';
|
import { ListPatientsDto } from './dto/list-patients.dto';
|
||||||
import { UpdatePatientDto } from './dto/update-patient.dto';
|
import { UpdatePatientDto } from './dto/update-patient.dto';
|
||||||
import { PatientsService } from './patients.service';
|
import { PatientsService } from './patients.service';
|
||||||
import { CreateTreatmentHistoryDto } from './dto/create-treatment-history.dto';
|
|
||||||
|
|
||||||
@ApiTags('patients')
|
@ApiTags('patients')
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@@ -52,26 +51,4 @@ export class PatientsController {
|
|||||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||||
return this.patientsService.update(id, updatePatientDto, organizationId);
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { PrismaService } from '../../../prisma/prisma.service';
|
|||||||
import { CreatePatientDto } from './dto/create-patient.dto';
|
import { CreatePatientDto } from './dto/create-patient.dto';
|
||||||
import { ListPatientsDto } from './dto/list-patients.dto';
|
import { ListPatientsDto } from './dto/list-patients.dto';
|
||||||
import { UpdatePatientDto } from './dto/update-patient.dto';
|
import { UpdatePatientDto } from './dto/update-patient.dto';
|
||||||
import { CreateTreatmentHistoryDto } from './dto/create-treatment-history.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PatientsService {
|
export class PatientsService {
|
||||||
@@ -89,36 +88,6 @@ export class PatientsService {
|
|||||||
return { success: true, data: patient };
|
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) {
|
private async ensurePatient(id: string, organizationId: string) {
|
||||||
const patient = await this.prisma.patient.findFirst({
|
const patient = await this.prisma.patient.findFirst({
|
||||||
where: { id, organizationId },
|
where: { id, organizationId },
|
||||||
|
|||||||
60
backend/src/modules/treatments/dto/treatment.dto.ts
Normal file
60
backend/src/modules/treatments/dto/treatment.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
16
backend/src/modules/treatments/treatment.utils.spec.ts
Normal file
16
backend/src/modules/treatments/treatment.utils.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
53
backend/src/modules/treatments/treatment.utils.ts
Normal file
53
backend/src/modules/treatments/treatment.utils.ts
Normal 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';
|
||||||
|
}
|
||||||
148
backend/src/modules/treatments/treatments.controller.ts
Normal file
148
backend/src/modules/treatments/treatments.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/modules/treatments/treatments.module.ts
Normal file
10
backend/src/modules/treatments/treatments.module.ts
Normal 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 {}
|
||||||
625
backend/src/modules/treatments/treatments.service.ts
Normal file
625
backend/src/modules/treatments/treatments.service.ts
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
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 }],
|
||||||
|
include: { organization: { select: { id: true, name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
@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' }],
|
||||||
|
include: { organization: { select: { id: true, name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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; organization: { id: string; name: string } }>;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
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; organization?: { id: string; name: string } }>;
|
||||||
|
}) {
|
||||||
|
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) ?? [],
|
||||||
|
sends:
|
||||||
|
c.sends?.map((s) => ({
|
||||||
|
organizationId: s.organizationId,
|
||||||
|
organizationName: s.organization?.name ?? 'Unknown organization',
|
||||||
|
sentAt: s.sentAt.toISOString(),
|
||||||
|
})) ?? [],
|
||||||
|
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 } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,16 +8,10 @@ import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
|||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import { hasPermission } from '@/components/shared/permissions';
|
import { hasPermission } from '@/components/shared/permissions';
|
||||||
import {
|
import { CreatePatientInput, Patient } from '@/types/patient';
|
||||||
CreatePatientInput,
|
|
||||||
CreateTreatmentHistoryInput,
|
|
||||||
Patient,
|
|
||||||
TreatmentHistoryItem,
|
|
||||||
} from '@/types/patient';
|
|
||||||
import { PatientSearchSelect } from '../../../components/ui/patient/PatientSearchSelect';
|
import { PatientSearchSelect } from '../../../components/ui/patient/PatientSearchSelect';
|
||||||
import { CreatePatientModal } from '../../../components/ui/patient/CreatePatientModal';
|
import { CreatePatientModal } from '../../../components/ui/patient/CreatePatientModal';
|
||||||
import { PatientSummaryCard } from '../../../components/ui/patient/PatientSummaryCard';
|
import { PatientSummaryCard } from '../../../components/ui/patient/PatientSummaryCard';
|
||||||
import { TreatmentHistoryPreview } from '../../../components/ui/patient/TreatmentHistoryPreview';
|
|
||||||
|
|
||||||
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
||||||
firstName: '',
|
firstName: '',
|
||||||
@@ -32,12 +26,9 @@ export default function PatientsPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
|
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
|
||||||
const [treatments, setTreatments] = useState<TreatmentHistoryItem[]>([]);
|
|
||||||
const [loadingPatients, setLoadingPatients] = useState(false);
|
const [loadingPatients, setLoadingPatients] = useState(false);
|
||||||
const [loadingTreatments, setLoadingTreatments] = useState(false);
|
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const [savingPatient, setSavingPatient] = useState(false);
|
const [savingPatient, setSavingPatient] = useState(false);
|
||||||
const [savingTreatment, setSavingTreatment] = useState(false);
|
|
||||||
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
|
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
|
||||||
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
|
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() {
|
async function handleCreatePatient() {
|
||||||
setSavingPatient(true);
|
setSavingPatient(true);
|
||||||
toast.setError('');
|
toast.setError('');
|
||||||
@@ -101,7 +79,6 @@ export default function PatientsPage() {
|
|||||||
setPatientForm(EMPTY_PATIENT_FORM);
|
setPatientForm(EMPTY_PATIENT_FORM);
|
||||||
await loadPatients(search);
|
await loadPatients(search);
|
||||||
setSelectedPatient(response.data);
|
setSelectedPatient(response.data);
|
||||||
await loadTreatments(response.data.id);
|
|
||||||
toast.showSuccess(
|
toast.showSuccess(
|
||||||
`Patient ${response.data.firstName} ${response.data.lastName} was saved successfully.`,
|
`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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@@ -179,31 +131,13 @@ export default function PatientsPage() {
|
|||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
patients={sortedPatients}
|
patients={sortedPatients}
|
||||||
selectedPatientId={selectedPatient?.id}
|
selectedPatientId={selectedPatient?.id}
|
||||||
onSelectPatient={(patient) => {
|
onSelectPatient={setSelectedPatient}
|
||||||
setSelectedPatient(patient);
|
|
||||||
void loadTreatments(patient.id);
|
|
||||||
}}
|
|
||||||
loading={loadingPatients}
|
loading={loadingPatients}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="xl:col-span-2 space-y-4">
|
<div className="xl:col-span-2 space-y-4">
|
||||||
<PatientSummaryCard patient={selectedPatient} />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean
|
|||||||
return (
|
return (
|
||||||
hasPermission(org, 'TAB_APPOINTMENTS_READ') ||
|
hasPermission(org, 'TAB_APPOINTMENTS_READ') ||
|
||||||
hasPermission(org, 'TAB_APPOINTMENTS_EDIT') ||
|
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;
|
if (org.isOwner) return true;
|
||||||
return hasPermission(org, 'TAB_TREATMENT_EDIT');
|
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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
40
frontend/src/components/treatment/caseSendLabel.ts
Normal file
40
frontend/src/components/treatment/caseSendLabel.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { LinkedOrganizationOption, TreatmentCaseSendInfo } from '@/types/treatment';
|
||||||
|
|
||||||
|
export function formatCaseSentLines(
|
||||||
|
sends: TreatmentCaseSendInfo[] | undefined,
|
||||||
|
fallback?: {
|
||||||
|
organizationIds: string[];
|
||||||
|
sentAt: string | null;
|
||||||
|
orgs?: LinkedOrganizationOption[];
|
||||||
|
},
|
||||||
|
): string[] {
|
||||||
|
if (sends?.length) {
|
||||||
|
return sends.map((s) => {
|
||||||
|
const at = new Date(s.sentAt).toLocaleString();
|
||||||
|
return `Sent to ${s.organizationName} at ${at}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallback?.sentAt && fallback.organizationIds.length > 0) {
|
||||||
|
const at = new Date(fallback.sentAt).toLocaleString();
|
||||||
|
const nameById = new Map(fallback.orgs?.map((o) => [o.id, o.name]) ?? []);
|
||||||
|
return fallback.organizationIds.map((id) => {
|
||||||
|
const name = nameById.get(id) ?? 'organization';
|
||||||
|
return `Sent to ${name} at ${at}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCaseSentSummary(
|
||||||
|
sends: TreatmentCaseSendInfo[] | undefined,
|
||||||
|
fallback?: {
|
||||||
|
organizationIds: string[];
|
||||||
|
sentAt: string | null;
|
||||||
|
orgs?: LinkedOrganizationOption[];
|
||||||
|
},
|
||||||
|
): string | null {
|
||||||
|
const lines = formatCaseSentLines(sends, fallback);
|
||||||
|
return lines.length > 0 ? lines.join(' · ') : null;
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FileText } from 'lucide-react';
|
||||||
|
import { treatmentsApi } from '@/lib/api/treatments';
|
||||||
|
import type { TreatmentAttachmentMeta } from '@/types/treatment';
|
||||||
|
|
||||||
|
interface CaseLatestAttachmentPreviewProps {
|
||||||
|
attachment: TreatmentAttachmentMeta;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CaseLatestAttachmentPreview({
|
||||||
|
attachment,
|
||||||
|
className = 'aspect-square w-full max-w-[11rem]',
|
||||||
|
}: CaseLatestAttachmentPreviewProps) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const blob = await treatmentsApi.getAttachmentFileBlob(attachment.id);
|
||||||
|
if (cancelled) return;
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
setUrl(objectUrl);
|
||||||
|
setFailed(false);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setFailed(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [attachment.id]);
|
||||||
|
|
||||||
|
const isImage = attachment.mimeType.startsWith('image/');
|
||||||
|
const isPdf = attachment.mimeType === 'application/pdf';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${className} rounded-[var(--radius-md)] border border-border/60 bg-background-secondary overflow-hidden`}
|
||||||
|
title={attachment.fileName}
|
||||||
|
>
|
||||||
|
{url && isImage ? (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={attachment.fileName}
|
||||||
|
className="h-full w-full object-fill"
|
||||||
|
/>
|
||||||
|
) : url && isPdf ? (
|
||||||
|
<iframe
|
||||||
|
src={url}
|
||||||
|
title={attachment.fileName}
|
||||||
|
className="h-full w-full border-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5 p-2 text-text-muted">
|
||||||
|
<FileText className="h-8 w-8 shrink-0 icon-flat" aria-hidden />
|
||||||
|
<span className="line-clamp-2 text-center text-[10px] leading-tight">
|
||||||
|
{failed ? 'Preview unavailable' : attachment.fileName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
frontend/src/components/ui/treatment/CaseSentLabel.tsx
Normal file
31
frontend/src/components/ui/treatment/CaseSentLabel.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { formatCaseSentLines } from '@/components/treatment/caseSendLabel';
|
||||||
|
import type { LinkedOrganizationOption, PastTreatmentCase, TreatmentCaseDraft } from '@/types/treatment';
|
||||||
|
|
||||||
|
interface CaseSentLabelProps {
|
||||||
|
treatmentCase: Pick<
|
||||||
|
PastTreatmentCase | TreatmentCaseDraft,
|
||||||
|
'sends' | 'sendToOrganizationIds' | 'sentAt'
|
||||||
|
>;
|
||||||
|
orgs?: LinkedOrganizationOption[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CaseSentLabel({ treatmentCase, orgs, className = 'text-xs text-text-muted' }: CaseSentLabelProps) {
|
||||||
|
const lines = formatCaseSentLines(treatmentCase.sends, {
|
||||||
|
organizationIds: treatmentCase.sendToOrganizationIds ?? [],
|
||||||
|
sentAt: treatmentCase.sentAt ?? null,
|
||||||
|
orgs,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lines.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<span key={`${line}-${i}`} className="block">
|
||||||
|
{line}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
|
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
|
||||||
<div className="flex flex-wrap justify-center gap-x-1 gap-y-1">
|
<div className="flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit">
|
||||||
{teeth.map((fdi, i) => {
|
{teeth.map((fdi, i) => {
|
||||||
const kind = getToothShapeKind(fdi);
|
const kind = getToothShapeKind(fdi);
|
||||||
const gid = `${uid}-g-${fdi}-${i}`;
|
const gid = `${uid}-g-${fdi}-${i}`;
|
||||||
@@ -148,31 +148,30 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="surface-card p-4 space-y-6">
|
<div className="surface-card p-3 space-y-3">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-text-primary">FDI tooth chart</h3>
|
<h3 className="text-sm font-semibold text-text-primary">FDI tooth chart</h3>
|
||||||
<p className="text-xs text-text-muted mt-0.5">
|
<p className="text-[11px] 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. Applies to the active case.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:items-end w-full sm:max-w-xs">
|
<p className="text-[11px] text-text-secondary tabular-nums sm:text-right">
|
||||||
<p className="text-xs text-text-secondary tabular-nums text-right">
|
|
||||||
Selected: {selected.size === 0 ? '—' : [...selected].sort().join(', ')}
|
Selected: {selected.size === 0 ? '—' : [...selected].sort().join(', ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">Upper arch</p>
|
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">Upper arch</p>
|
||||||
|
|
||||||
<div className="relative isolate py-1">
|
<div className="overflow-x-auto py-1 -mx-1 px-1">
|
||||||
|
<div className="relative isolate min-w-max mx-auto w-fit">
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute left-1/2 top-3 bottom-3 w-px -translate-x-1/2 bg-border/70"
|
className="pointer-events-none absolute left-1/2 top-3 bottom-3 w-px -translate-x-1/2 bg-border/70"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<div className="relative z-10 space-y-0">
|
<div className="relative z-10 space-y-0">
|
||||||
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} upper />
|
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} upper />
|
||||||
<div className={`flex justify-center gap-x-1 ${TOOTH_NUMBER_GAP}`}>
|
<div className={`flex flex-nowrap justify-center gap-x-1 ${TOOTH_NUMBER_GAP}`}>
|
||||||
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => {
|
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => {
|
||||||
const size = toothSizeClass(fdi);
|
const size = toothSizeClass(fdi);
|
||||||
const isSel = selected.has(fdi);
|
const isSel = selected.has(fdi);
|
||||||
@@ -189,14 +188,10 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="my-2 h-px w-full bg-border/70" role="separator" aria-hidden />
|
||||||
className="my-4 h-px w-full max-w-[min(100%,42rem)] mx-auto bg-border/70"
|
|
||||||
role="separator"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="my-4 pt-1">
|
<div className="pt-0.5">
|
||||||
<div className="flex justify-center gap-x-1">
|
<div className="flex flex-nowrap justify-center gap-x-1">
|
||||||
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => {
|
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => {
|
||||||
const size = toothSizeClass(fdi);
|
const size = toothSizeClass(fdi);
|
||||||
const isSel = selected.has(fdi);
|
const isSel = selected.has(fdi);
|
||||||
@@ -218,6 +213,7 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">Lower arch</p>
|
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">Lower arch</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,89 +2,92 @@
|
|||||||
|
|
||||||
import { FileText } from 'lucide-react';
|
import { FileText } from 'lucide-react';
|
||||||
import type { PastTreatment } from '@/types/treatment';
|
import type { PastTreatment } from '@/types/treatment';
|
||||||
|
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
||||||
|
|
||||||
interface PastTreatmentsPanelProps {
|
interface PastTreatmentsPanelProps {
|
||||||
items: PastTreatment[];
|
items: PastTreatment[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
selectedTreatmentId?: string | null;
|
onReviewTreatment?: (treatment: PastTreatment) => void;
|
||||||
onSelectTreatment?: (treatment: PastTreatment) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PastTreatmentsPanel({
|
export function PastTreatmentsPanel({
|
||||||
items,
|
items,
|
||||||
loading,
|
loading,
|
||||||
selectedTreatmentId,
|
onReviewTreatment,
|
||||||
onSelectTreatment,
|
|
||||||
}: PastTreatmentsPanelProps) {
|
}: PastTreatmentsPanelProps) {
|
||||||
return (
|
return (
|
||||||
<div className="surface-card p-4 space-y-3">
|
<div className="surface-card p-4 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-text-primary">Previous treatments</h3>
|
<h3 className="text-sm font-semibold text-text-primary">Previous treatments</h3>
|
||||||
<p className="text-xs text-text-muted mt-0.5">
|
<p className="text-[11px] text-text-muted mt-0.5">
|
||||||
Document preview is not implemented yet; file names are listed for context.
|
Completed treatments for this patient. Each case is listed separately.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <p className="text-sm text-text-muted">Loading history...</p>}
|
{loading && <p className="text-sm text-text-muted">Loading history…</p>}
|
||||||
|
|
||||||
{!loading && items.length === 0 && (
|
{!loading && items.length === 0 && (
|
||||||
<p className="text-sm text-text-muted">No prior treatments for this patient.</p>
|
<p className="text-sm text-text-muted">No prior treatments for this patient.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3 max-h-[min(420px,55vh)] overflow-y-auto pr-1">
|
<div className="space-y-3 max-h-[min(420px,50vh)] overflow-y-auto pr-1">
|
||||||
{items.map((t) => (
|
{items.map((t) => (
|
||||||
<article
|
<article
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className={`border rounded-[var(--radius-md)] p-3 ${
|
className="border border-border/70 rounded-[var(--radius-md)] p-2.5 bg-background-secondary/40 space-y-2"
|
||||||
selectedTreatmentId === t.id
|
|
||||||
? 'border-primary/70 bg-primary-soft/35'
|
|
||||||
: 'border-border/70 bg-background-secondary/40'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p className="text-sm font-medium text-text-primary">{t.title}</p>
|
<div className="min-w-0">
|
||||||
<time className="text-xs text-text-muted tabular-nums shrink-0" dateTime={t.treatmentAt}>
|
<p className="text-sm font-medium text-text-primary truncate">{t.title}</p>
|
||||||
|
<p className="text-[11px] text-text-secondary capitalize mt-0.5">Status: {t.status}</p>
|
||||||
|
</div>
|
||||||
|
<time
|
||||||
|
className="text-[11px] text-text-muted tabular-nums shrink-0"
|
||||||
|
dateTime={t.treatmentAt}
|
||||||
|
>
|
||||||
{new Date(t.treatmentAt).toLocaleDateString()}
|
{new Date(t.treatmentAt).toLocaleDateString()}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-text-secondary mt-1">Status: {t.status}</p>
|
|
||||||
|
|
||||||
{t.records.length > 0 && (
|
<div className="space-y-1.5">
|
||||||
<ul className="mt-2 space-y-1.5 text-xs text-text-secondary">
|
{t.cases.map((c, idx) => {
|
||||||
{t.records.map((r) => (
|
const attachments = c.attachmentMetas ?? [];
|
||||||
<li key={r.id}>
|
return (
|
||||||
<span className="text-text-primary font-medium">Record: </span>
|
<div
|
||||||
<span className="capitalize">{r.treatmentType}</span>
|
key={c.id}
|
||||||
{' | '}
|
className="border border-border/60 rounded-[var(--radius-sm)] px-2.5 py-2 bg-background-secondary/30 space-y-1"
|
||||||
{r.teeth.length > 0 ? `Teeth ${[...r.teeth].sort().join(', ')}` : 'No teeth tagged'}
|
|
||||||
{r.notes ? ` — ${r.notes}` : ''}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onSelectTreatment && (
|
|
||||||
<div className="mt-3 pt-2 border-t border-border/50 flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelectTreatment(t)}
|
|
||||||
className="text-xs text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 rounded-[var(--radius-sm)] px-1"
|
|
||||||
>
|
>
|
||||||
Review details
|
<div className="flex items-center justify-between gap-2">
|
||||||
</button>
|
<p className="text-xs font-medium text-text-primary capitalize">
|
||||||
</div>
|
Case {idx + 1} · {c.treatmentType}
|
||||||
|
</p>
|
||||||
|
{c.sentAt && (
|
||||||
|
<CaseSentLabel
|
||||||
|
treatmentCase={c}
|
||||||
|
className="text-[10px] text-text-muted shrink-0 text-right"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{t.documents.length > 0 && (
|
<p className="text-[11px] text-text-secondary">
|
||||||
<div className="mt-3 pt-2 border-t border-border/50">
|
Teeth: {c.teeth.length ? [...c.teeth].sort().join(', ') : 'None selected'}
|
||||||
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1.5">Attachments</p>
|
</p>
|
||||||
<ul className="space-y-1">
|
{c.notes?.trim() && (
|
||||||
{t.documents.map((doc) => (
|
<p className="text-[11px] text-text-muted line-clamp-2">{c.notes}</p>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-text-muted mb-1">
|
||||||
|
Attachments
|
||||||
|
</p>
|
||||||
|
{attachments.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-text-muted">None</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{attachments.map((doc) => (
|
||||||
<li
|
<li
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
className="flex items-center gap-2 text-xs text-text-secondary"
|
className="flex items-center gap-1.5 text-[11px] text-text-secondary"
|
||||||
>
|
>
|
||||||
<FileText className="w-3.5 h-3.5 shrink-0 icon-flat" aria-hidden />
|
<FileText className="w-3 h-3 shrink-0 icon-flat" aria-hidden />
|
||||||
<span className="truncate">{doc.fileName}</span>
|
<span className="truncate">{doc.fileName}</span>
|
||||||
<span className="text-text-muted tabular-nums shrink-0">
|
<span className="text-text-muted tabular-nums shrink-0">
|
||||||
{(doc.sizeBytes / 1024).toFixed(1)} KB
|
{(doc.sizeBytes / 1024).toFixed(1)} KB
|
||||||
@@ -92,6 +95,22 @@ export function PastTreatmentsPanel({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onReviewTreatment && (
|
||||||
|
<div className="pt-1 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onReviewTreatment(t)}
|
||||||
|
className="text-xs text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 rounded-[var(--radius-sm)] px-1"
|
||||||
|
>
|
||||||
|
Review details
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
296
frontend/src/components/ui/treatment/TreatmentCasesEditor.tsx
Normal file
296
frontend/src/components/ui/treatment/TreatmentCasesEditor.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
|
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||||
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
|
import type { LinkedOrganizationOption, TreatmentCaseDraft } from '@/types/treatment';
|
||||||
|
import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
|
||||||
|
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
||||||
|
|
||||||
|
interface TreatmentCasesEditorProps {
|
||||||
|
cases: TreatmentCaseDraft[];
|
||||||
|
activeCaseId: string;
|
||||||
|
onActiveCaseChange: (id: string) => void;
|
||||||
|
onCasesChange: (cases: TreatmentCaseDraft[]) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
isDirty: boolean;
|
||||||
|
saveBusy: boolean;
|
||||||
|
sendBusyId: string | null;
|
||||||
|
uploadBusy: boolean;
|
||||||
|
orgs: LinkedOrganizationOption[];
|
||||||
|
organizationSearch: string;
|
||||||
|
onOrganizationSearchChange: (value: string) => void;
|
||||||
|
recentOrganizationIds: string[];
|
||||||
|
onRecentOrganizationPick: (orgId: string) => void;
|
||||||
|
onAddCase: () => void;
|
||||||
|
onPreview: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onSendCase: (c: TreatmentCaseDraft) => void;
|
||||||
|
onUploadFiles: (files: FileList | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TreatmentCasesEditor({
|
||||||
|
cases,
|
||||||
|
activeCaseId,
|
||||||
|
onActiveCaseChange,
|
||||||
|
onCasesChange,
|
||||||
|
disabled,
|
||||||
|
canEdit,
|
||||||
|
isDirty,
|
||||||
|
saveBusy,
|
||||||
|
sendBusyId,
|
||||||
|
uploadBusy,
|
||||||
|
orgs,
|
||||||
|
organizationSearch,
|
||||||
|
onOrganizationSearchChange,
|
||||||
|
recentOrganizationIds,
|
||||||
|
onRecentOrganizationPick,
|
||||||
|
onAddCase,
|
||||||
|
onPreview,
|
||||||
|
onSave,
|
||||||
|
onSendCase,
|
||||||
|
onUploadFiles,
|
||||||
|
}: TreatmentCasesEditorProps) {
|
||||||
|
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const activeCase = cases.find((c) => c.clientId === activeCaseId) ?? cases[0];
|
||||||
|
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
||||||
|
const filteredOrganizations = (() => {
|
||||||
|
const q = organizationSearch.trim().toLowerCase();
|
||||||
|
if (!q) return activeLinkedOrganizations;
|
||||||
|
return activeLinkedOrganizations.filter((o) => o.name.toLowerCase().includes(q));
|
||||||
|
})();
|
||||||
|
const recentOrganizations = recentOrganizationIds
|
||||||
|
.map((id) => activeLinkedOrganizations.find((o) => o.id === id))
|
||||||
|
.filter(Boolean) as LinkedOrganizationOption[];
|
||||||
|
|
||||||
|
const treatmentTypeTextColor = activeCase
|
||||||
|
? (
|
||||||
|
{
|
||||||
|
consultation: '#ddd6fe',
|
||||||
|
filling: '#fed7aa',
|
||||||
|
endo: '#fecaca',
|
||||||
|
visit: '#bae6fd',
|
||||||
|
hygiene: '#d9f99d',
|
||||||
|
} as Record<TreatmentCaseDraft['treatmentType'], string>
|
||||||
|
)[activeCase.treatmentType]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!activeCase) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 cases</h3>
|
||||||
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
|
Each case has its own teeth, notes, attachments, and destinations for send.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button type="button" variant="secondary" disabled={!canEdit || disabled} onClick={onPreview}>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="primary" disabled={!canEdit || disabled} onClick={onAddCase}>
|
||||||
|
Add case
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{cases.map((c, idx) => {
|
||||||
|
const sentSummary = formatCaseSentSummary(c.sends, {
|
||||||
|
organizationIds: c.sendToOrganizationIds ?? [],
|
||||||
|
sentAt: c.sentAt ?? null,
|
||||||
|
orgs,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.clientId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onActiveCaseChange(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
|
||||||
|
${
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
Case {idx + 1}
|
||||||
|
{sentSummary ? ` · ${sentSummary}` : ''}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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={activeCase.comment}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
onCasesChange(
|
||||||
|
cases.map((c) => (c.clientId === activeCaseId ? { ...c, comment: v } : c)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
placeholder="Write clinical notes for this case…"
|
||||||
|
rows={5}
|
||||||
|
disabled={disabled || 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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Dropdown
|
||||||
|
label="Treatment type"
|
||||||
|
value={activeCase.treatmentType}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextType = e.target.value as TreatmentCaseDraft['treatmentType'];
|
||||||
|
onCasesChange(
|
||||||
|
cases.map((c) =>
|
||||||
|
c.clientId === activeCaseId ? { ...c, treatmentType: nextType } : c,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={disabled || Boolean(activeCase.sentAt)}
|
||||||
|
className="capitalize"
|
||||||
|
style={{ color: treatmentTypeTextColor }}
|
||||||
|
>
|
||||||
|
<option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }} className="capitalize">consultation</option>
|
||||||
|
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }} className="capitalize">filling</option>
|
||||||
|
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }} className="capitalize">endo</option>
|
||||||
|
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }} className="capitalize">visit</option>
|
||||||
|
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }} className="capitalize">hygiene</option>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-text-secondary mb-2">Attachments</p>
|
||||||
|
<input
|
||||||
|
ref={attachmentInputRef}
|
||||||
|
id="treatment-case-attachments"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
disabled={disabled || uploadBusy || Boolean(activeCase.sentAt)}
|
||||||
|
onChange={(e) => {
|
||||||
|
onUploadFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
className="sr-only"
|
||||||
|
aria-label="Attach files for this treatment case"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
disabled={disabled || uploadBusy || Boolean(activeCase.sentAt)}
|
||||||
|
isLoading={uploadBusy}
|
||||||
|
onClick={() => attachmentInputRef.current?.click()}
|
||||||
|
aria-controls="treatment-case-attachments"
|
||||||
|
>
|
||||||
|
Choose files
|
||||||
|
</Button>
|
||||||
|
{activeCase.attachmentMetas.length > 0 && (
|
||||||
|
<ul className="mt-2 space-y-1 text-xs text-text-muted">
|
||||||
|
{activeCase.attachmentMetas.map((f) => (
|
||||||
|
<li key={f.id} className="truncate">
|
||||||
|
{f.fileName} ({(f.sizeBytes / 1024).toFixed(1)} KB)
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-text-secondary mb-2">
|
||||||
|
Send this case to linked organizations
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 mb-2">
|
||||||
|
<SearchBar
|
||||||
|
value={organizationSearch}
|
||||||
|
onChange={onOrganizationSearchChange}
|
||||||
|
placeholder="Search active organizations..."
|
||||||
|
/>
|
||||||
|
{recentOrganizations.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs text-text-muted">Recent:</span>
|
||||||
|
{recentOrganizations.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.id}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || Boolean(activeCase.sentAt)}
|
||||||
|
onClick={() => onRecentOrganizationPick(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 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{o.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{filteredOrganizations.map((o) => (
|
||||||
|
<Checkbox
|
||||||
|
key={o.id}
|
||||||
|
checked={activeCase.sendToOrganizationIds.includes(o.id)}
|
||||||
|
disabled={disabled || Boolean(activeCase.sentAt)}
|
||||||
|
onChange={(checked) => {
|
||||||
|
onCasesChange(
|
||||||
|
cases.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 { ...c, sendToOrganizationIds: [...next] };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
label={o.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{filteredOrganizations.length === 0 && (
|
||||||
|
<p className="text-xs text-text-muted">No active organization matches your search.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
disabled={disabled || Boolean(activeCase.sentAt) || sendBusyId === activeCase.clientId}
|
||||||
|
isLoading={sendBusyId === activeCase.clientId}
|
||||||
|
onClick={() => onSendCase(activeCase)}
|
||||||
|
>
|
||||||
|
Send this case
|
||||||
|
</Button>
|
||||||
|
{activeCase.sentAt && (
|
||||||
|
<CaseSentLabel treatmentCase={activeCase} orgs={orgs} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex flex-wrap gap-3 pt-2 border-t border-border/60">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
disabled={disabled || saveBusy}
|
||||||
|
isLoading={saveBusy}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FileText } from 'lucide-react';
|
||||||
|
import { treatmentsApi } from '@/lib/api/treatments';
|
||||||
|
import type { TreatmentAttachmentMeta } from '@/types/treatment';
|
||||||
|
|
||||||
|
interface TreatmentLatestAttachmentPreviewProps {
|
||||||
|
attachment?: TreatmentAttachmentMeta | null;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageMime(mimeType: string): boolean {
|
||||||
|
return mimeType.startsWith('image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdfMime(mimeType: string): boolean {
|
||||||
|
return mimeType === 'application/pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TreatmentLatestAttachmentPreview({
|
||||||
|
attachment,
|
||||||
|
className = '',
|
||||||
|
}: TreatmentLatestAttachmentPreviewProps) {
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const canRenderPreview = attachment
|
||||||
|
? isImageMime(attachment.mimeType) || isPdfMime(attachment.mimeType)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!attachment || !canRenderPreview) {
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setLoadFailed(false);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setLoadFailed(false);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
|
||||||
|
void treatmentsApi
|
||||||
|
.getAttachmentFileBlob(attachment.id)
|
||||||
|
.then((blob) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
setPreviewUrl(objectUrl);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setLoadFailed(true);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [attachment, canRenderPreview]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`aspect-square w-[6rem] shrink-0 overflow-hidden rounded-[var(--radius-md)] border border-border/70 bg-background-secondary/50 ${className}`}
|
||||||
|
title={attachment?.fileName}
|
||||||
|
>
|
||||||
|
{!attachment ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-[10px] text-text-muted">
|
||||||
|
None
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-[10px] text-text-muted">
|
||||||
|
…
|
||||||
|
</div>
|
||||||
|
) : loadFailed || !canRenderPreview || !previewUrl ? (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-1.5 text-center">
|
||||||
|
<FileText className="h-4 w-4 shrink-0 icon-flat text-text-muted" aria-hidden />
|
||||||
|
<span className="line-clamp-2 text-[9px] leading-tight text-text-secondary">
|
||||||
|
{attachment.fileName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : isImageMime(attachment.mimeType) ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={attachment.fileName}
|
||||||
|
className="h-full w-full object-fill"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<iframe
|
||||||
|
src={previewUrl}
|
||||||
|
title={attachment.fileName}
|
||||||
|
className="h-full w-full border-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
|
import type { PastTreatment } from '@/types/treatment';
|
||||||
|
|
||||||
|
interface TreatmentPreviewCardProps {
|
||||||
|
draft: PastTreatment | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
onPreview: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPreviewCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="surface-card p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary">Treatment preview</h3>
|
||||||
|
<Button type="button" variant="primary" disabled={disabled || !draft} onClick={onPreview}>
|
||||||
|
Preview current draft
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!draft ? (
|
||||||
|
<p className="text-sm text-text-muted">Select an appointment to preview its draft.</p>
|
||||||
|
) : (
|
||||||
|
<div className="border border-border/70 rounded-[var(--radius-md)] p-3 bg-background-secondary/40 space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium text-text-primary">{draft.title}</p>
|
||||||
|
<span className="text-xs text-text-muted tabular-nums shrink-0 capitalize">{draft.status}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
{draft.cases.length} case{draft.cases.length === 1 ? '' : 's'} ·{' '}
|
||||||
|
{draft.cases.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0)} attachment
|
||||||
|
{draft.cases.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0) === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{draft.cases.slice(0, 2).map((c, idx) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="rounded-[var(--radius-sm)] border border-border/60 px-2.5 py-2 text-xs text-text-secondary"
|
||||||
|
>
|
||||||
|
<span className="text-text-primary font-medium capitalize">
|
||||||
|
Case {idx + 1}: {c.treatmentType}
|
||||||
|
</span>
|
||||||
|
{c.teeth.length > 0 && (
|
||||||
|
<span className="ml-1 tabular-nums">· Teeth {[...c.teeth].sort().join(', ')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{draft.cases.length > 2 && (
|
||||||
|
<p className="text-xs text-text-muted">+ {draft.cases.length - 2} more case(s)</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx
Normal file
235
frontend/src/components/ui/treatment/TreatmentPreviewDialog.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Loader2, Paperclip, Send } from 'lucide-react';
|
||||||
|
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||||
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
|
import type { LinkedOrganizationOption, PastTreatment, PastTreatmentCase } from '@/types/treatment';
|
||||||
|
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
||||||
|
import { TreatmentLatestAttachmentPreview } from '@/components/ui/treatment/TreatmentLatestAttachmentPreview';
|
||||||
|
|
||||||
|
export type TreatmentPreviewMode = 'readonly' | 'editable';
|
||||||
|
|
||||||
|
interface TreatmentPreviewDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
treatment: PastTreatment | null;
|
||||||
|
mode: TreatmentPreviewMode;
|
||||||
|
orgs?: LinkedOrganizationOption[];
|
||||||
|
sendBusyCaseId?: string | null;
|
||||||
|
uploadBusyCaseId?: string | null;
|
||||||
|
onAttach?: (caseKey: string, files: FileList) => void | Promise<void>;
|
||||||
|
onSend?: (caseKey: string, organizationIds: string[]) => void | Promise<void>;
|
||||||
|
getCaseOrgIds?: (caseKey: string) => string[];
|
||||||
|
onToggleCaseOrg?: (caseKey: string, organizationId: string, checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function caseKey(c: PastTreatmentCase): string {
|
||||||
|
return c.clientId ?? c.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const caseActionIconClass =
|
||||||
|
'inline-flex items-center justify-center rounded-[var(--radius-sm)] p-1.5 text-text-secondary transition-colors hover:bg-background-card/80 hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-40';
|
||||||
|
|
||||||
|
export function TreatmentPreviewDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
treatment,
|
||||||
|
mode,
|
||||||
|
orgs = [],
|
||||||
|
sendBusyCaseId,
|
||||||
|
uploadBusyCaseId,
|
||||||
|
onAttach,
|
||||||
|
onSend,
|
||||||
|
getCaseOrgIds,
|
||||||
|
onToggleCaseOrg,
|
||||||
|
}: TreatmentPreviewDialogProps) {
|
||||||
|
const [expandedSendCaseId, setExpandedSendCaseId] = useState<string | null>(null);
|
||||||
|
const fileInputsRef = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
|
if (!open || !treatment) return null;
|
||||||
|
|
||||||
|
const editable = mode === 'editable';
|
||||||
|
const activeOrgs = orgs.filter((o) => o.active);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-[min(56rem,calc(100vw-17rem))] max-h-[90vh] overflow-y-auto rounded-[var(--radius-md)] border border-border bg-background-secondary p-6 shadow-xl space-y-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="treatment-preview-title"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 id="treatment-preview-title" className="text-lg font-semibold text-text-primary pr-2">
|
||||||
|
Treatment preview
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
|
Review cases, attachments, and send destinations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogCloseButton onClick={onClose} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-border/70 rounded-[var(--radius-md)] p-4 bg-background-secondary/40 space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium text-text-primary">{treatment.title}</p>
|
||||||
|
<span className="text-xs text-text-muted tabular-nums shrink-0">
|
||||||
|
{new Date(treatment.treatmentAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-secondary capitalize">Status: {treatment.status}</p>
|
||||||
|
|
||||||
|
{treatment.cases.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-muted">No cases in this treatment.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{treatment.cases.map((c, idx) => {
|
||||||
|
const key = caseKey(c);
|
||||||
|
const attachments = c.attachmentMetas ?? [];
|
||||||
|
const latestAttachment =
|
||||||
|
attachments.length > 0 ? attachments[attachments.length - 1] : null;
|
||||||
|
const sent = Boolean(c.sentAt);
|
||||||
|
const actionsEnabled = editable && !sent;
|
||||||
|
const selectedOrgIds = getCaseOrgIds?.(key) ?? c.sendToOrganizationIds ?? [];
|
||||||
|
const sendExpanded = expandedSendCaseId === key;
|
||||||
|
const comment = c.notes?.trim() ?? '';
|
||||||
|
const attachBusy = uploadBusyCaseId === key;
|
||||||
|
const sendBusy = sendBusyCaseId === key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="rounded-[var(--radius-md)] border border-border/60 px-3 py-2 bg-background-secondary/30"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1">
|
||||||
|
<div className="min-w-0 space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-xs font-medium text-text-primary">Case {idx + 1}</p>
|
||||||
|
{actionsEnabled && (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<input
|
||||||
|
ref={(el) => {
|
||||||
|
fileInputsRef.current[key] = el;
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="sr-only"
|
||||||
|
aria-hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files?.length) {
|
||||||
|
void onAttach?.(key, e.target.files);
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={caseActionIconClass}
|
||||||
|
disabled={attachBusy}
|
||||||
|
aria-label="Attach files"
|
||||||
|
title="Attach files"
|
||||||
|
onClick={() => fileInputsRef.current[key]?.click()}
|
||||||
|
>
|
||||||
|
{attachBusy ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Paperclip className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${caseActionIconClass} ${
|
||||||
|
sendExpanded ? 'bg-primary-soft text-primary' : ''
|
||||||
|
}`}
|
||||||
|
disabled={sendBusy}
|
||||||
|
aria-label="Send this case"
|
||||||
|
title="Send this case"
|
||||||
|
aria-expanded={sendExpanded}
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedSendCaseId((prev) => (prev === key ? null : key))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sendBusy ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Send className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-text-secondary capitalize">
|
||||||
|
Type: {c.treatmentType}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-text-secondary">
|
||||||
|
Teeth:{' '}
|
||||||
|
{c.teeth.length ? [...c.teeth].sort().join(', ') : 'None selected'}
|
||||||
|
</p>
|
||||||
|
{comment ? (
|
||||||
|
<p className="text-[11px] text-text-muted line-clamp-2" title={comment}>
|
||||||
|
Comments: {comment}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-text-muted">Comments: —</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-[6rem] flex-col items-end gap-1">
|
||||||
|
{sent && (
|
||||||
|
<CaseSentLabel
|
||||||
|
treatmentCase={c}
|
||||||
|
orgs={orgs}
|
||||||
|
className="text-[10px] text-text-muted text-right"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-text-muted">
|
||||||
|
Attachments
|
||||||
|
</p>
|
||||||
|
<TreatmentLatestAttachmentPreview attachment={latestAttachment} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sendExpanded && editable && !sent && (
|
||||||
|
<div className="mt-2 space-y-2 border-t border-border/40 pt-2">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">
|
||||||
|
Send to linked organizations
|
||||||
|
</p>
|
||||||
|
{activeOrgs.length === 0 ? (
|
||||||
|
<p className="text-xs text-text-muted">No active linked organizations.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{activeOrgs.map((o) => (
|
||||||
|
<Checkbox
|
||||||
|
key={o.id}
|
||||||
|
checked={selectedOrgIds.includes(o.id)}
|
||||||
|
onChange={(checked) => onToggleCaseOrg?.(key, o.id, checked)}
|
||||||
|
label={o.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={!selectedOrgIds.length || sendBusy}
|
||||||
|
isLoading={sendBusy}
|
||||||
|
onClick={() => void onSend?.(key, selectedOrgIds)}
|
||||||
|
>
|
||||||
|
Confirm send
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,8 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
import {
|
import {
|
||||||
CreatePatientInput,
|
CreatePatientInput,
|
||||||
CreateTreatmentHistoryInput,
|
|
||||||
Patient,
|
Patient,
|
||||||
PatientsListResponse,
|
PatientsListResponse,
|
||||||
TreatmentHistoryItem,
|
|
||||||
} from '@/types/patient';
|
} from '@/types/patient';
|
||||||
|
|
||||||
export const patientsApi = {
|
export const patientsApi = {
|
||||||
@@ -22,22 +20,4 @@ export const patientsApi = {
|
|||||||
const response = await apiClient.get(`/patients/${id}`);
|
const response = await apiClient.get(`/patients/${id}`);
|
||||||
return response.data;
|
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;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
86
frontend/src/lib/api/treatments.ts
Normal file
86
frontend/src/lib/api/treatments.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
import type {
|
||||||
|
LinkedOrganizationOption,
|
||||||
|
PastTreatment,
|
||||||
|
SaveTreatmentPayload,
|
||||||
|
SendTreatmentCasePayload,
|
||||||
|
TreatmentAttachmentMeta,
|
||||||
|
TreatmentCaseSendInfo,
|
||||||
|
} 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;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAttachmentFileBlob: async (attachmentId: string): Promise<Blob> => {
|
||||||
|
const response = await apiClient.get(`/treatments/attachments/${attachmentId}/file`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PastTreatmentCaseResponse {
|
||||||
|
id: string;
|
||||||
|
clientId: string;
|
||||||
|
treatmentType: string;
|
||||||
|
teeth: string[];
|
||||||
|
notes: string | null;
|
||||||
|
sentAt: string | null;
|
||||||
|
sendToOrganizationIds: string[];
|
||||||
|
sends: TreatmentCaseSendInfo[];
|
||||||
|
attachmentMetas: TreatmentAttachmentMeta[];
|
||||||
|
}
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -12,19 +12,6 @@ export interface Patient {
|
|||||||
updatedAt: string;
|
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 {
|
export interface CreatePatientInput {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
@@ -34,15 +21,6 @@ export interface CreatePatientInput {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateTreatmentHistoryInput {
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
treatmentAt: string;
|
|
||||||
tooth?: string;
|
|
||||||
notes?: string;
|
|
||||||
totalCost?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PatientsListResponse {
|
export interface PatientsListResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -61,20 +61,32 @@ export const TREATMENT_TYPES = [
|
|||||||
|
|
||||||
export type TreatmentType = (typeof TREATMENT_TYPES)[number];
|
export type TreatmentType = (typeof TREATMENT_TYPES)[number];
|
||||||
|
|
||||||
export interface PastTreatmentRecord {
|
export interface TreatmentCaseSendInfo {
|
||||||
|
organizationId: string;
|
||||||
|
organizationName: string;
|
||||||
|
sentAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PastTreatmentCase {
|
||||||
id: string;
|
id: string;
|
||||||
|
clientId: string;
|
||||||
treatmentType: TreatmentType;
|
treatmentType: TreatmentType;
|
||||||
teeth: FdiToothId[];
|
teeth: FdiToothId[];
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
|
attachmentMetas?: TreatmentAttachmentMeta[];
|
||||||
|
sendToOrganizationIds?: string[];
|
||||||
|
sends?: TreatmentCaseSendInfo[];
|
||||||
|
sentAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PastTreatment {
|
export interface PastTreatment {
|
||||||
id: string;
|
id: string;
|
||||||
patientId: string;
|
patientId: string;
|
||||||
|
appointmentId?: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
treatmentAt: string;
|
treatmentAt: string;
|
||||||
status: string;
|
status: string;
|
||||||
records: PastTreatmentRecord[];
|
cases: PastTreatmentCase[];
|
||||||
documents: TreatmentAttachmentMeta[];
|
documents: TreatmentAttachmentMeta[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,29 +96,33 @@ export interface LinkedOrganizationOption {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TreatmentRecordDraft {
|
export interface TreatmentCaseDraft {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
id?: string;
|
||||||
treatmentType: TreatmentType;
|
treatmentType: TreatmentType;
|
||||||
teeth: FdiToothId[];
|
teeth: FdiToothId[];
|
||||||
comment: string;
|
comment: string;
|
||||||
attachmentMetas: TreatmentAttachmentMeta[];
|
attachmentMetas: TreatmentAttachmentMeta[];
|
||||||
/** Organizations selected for sending this record (mock only until API exists) */
|
|
||||||
sendToOrganizationIds: string[];
|
sendToOrganizationIds: string[];
|
||||||
|
sends?: TreatmentCaseSendInfo[];
|
||||||
sentAt?: string | null;
|
sentAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for persisting a draft (mock API); omits ephemeral client-only fields */
|
export type SavedTreatmentCasePayload = {
|
||||||
export type SavedTreatmentRecordPayload = Omit<TreatmentRecordDraft, 'clientId' | 'sentAt'>;
|
clientId: string;
|
||||||
|
id?: string;
|
||||||
|
treatmentType: TreatmentType;
|
||||||
|
teeth: FdiToothId[];
|
||||||
|
comment: string;
|
||||||
|
attachmentIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export interface SaveTreatmentPayload {
|
export interface SaveTreatmentPayload {
|
||||||
appointmentId: string;
|
appointmentId: string;
|
||||||
patientId: string;
|
patientId: string;
|
||||||
records: SavedTreatmentRecordPayload[];
|
cases: SavedTreatmentCasePayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendTreatmentRecordPayload {
|
export interface SendTreatmentCasePayload {
|
||||||
appointmentId: string;
|
|
||||||
patientId: string;
|
|
||||||
recordClientId: string;
|
|
||||||
organizationIds: string[];
|
organizationIds: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user