Merge pull request 'feature/treatment-backend' (#20) from feature/treatment-backend into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 0s
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 0s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/20
This commit was merged in pull request #20.
This commit is contained in:
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -45,6 +45,7 @@ lerna-debug.log*
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
/uploads
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
|
||||
14
backend/package-lock.json
generated
14
backend/package-lock.json
generated
@@ -35,6 +35,7 @@
|
||||
"express-formidable": "^1.2.0",
|
||||
"express-session": "^1.19.0",
|
||||
"helmet": "^8.1.0",
|
||||
"multer": "^2.1.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
@@ -55,6 +56,7 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-session": "^1.18.2",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "^19.2.14",
|
||||
@@ -6251,6 +6253,16 @@
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@types/multer/-/multer-2.1.0.tgz",
|
||||
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||
@@ -12374,7 +12386,7 @@
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"prisma": "^6.19.2",
|
||||
"adminjs": "^7.8.17",
|
||||
"axios": "^1.13.5",
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -54,10 +53,12 @@
|
||||
"express-formidable": "^1.2.0",
|
||||
"express-session": "^1.19.0",
|
||||
"helmet": "^8.1.0",
|
||||
"multer": "^2.1.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.18.0",
|
||||
"prisma": "^6.19.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"styled-components": "^6.3.11",
|
||||
@@ -73,6 +74,7 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-session": "^1.18.2",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "^19.2.14",
|
||||
|
||||
@@ -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")
|
||||
patients Patient[]
|
||||
appointments Appointment[]
|
||||
treatments Treatment[]
|
||||
caseSends TreatmentCaseSend[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -82,7 +84,7 @@ model Patient {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
treatments PatientTreatmentHistory[]
|
||||
treatments Treatment[]
|
||||
appointments Appointment[]
|
||||
|
||||
@@index([organizationId, createdAt])
|
||||
@@ -90,24 +92,6 @@ model Patient {
|
||||
@@map("patients")
|
||||
}
|
||||
|
||||
model PatientTreatmentHistory {
|
||||
id String @id @default(uuid())
|
||||
patientId String
|
||||
title String
|
||||
status String
|
||||
treatmentAt DateTime
|
||||
tooth String?
|
||||
notes String?
|
||||
totalCost Float?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([patientId, treatmentAt])
|
||||
@@map("patient_treatment_histories")
|
||||
}
|
||||
|
||||
model Appointment {
|
||||
id String @id @default(uuid())
|
||||
organizationId String
|
||||
@@ -119,6 +103,7 @@ model Appointment {
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||
treatment Treatment?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -128,6 +113,84 @@ model Appointment {
|
||||
@@map("appointments")
|
||||
}
|
||||
|
||||
enum TreatmentStatus {
|
||||
DRAFT
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
model Treatment {
|
||||
id String @id @default(uuid())
|
||||
organizationId String
|
||||
patientId String
|
||||
appointmentId String? @unique
|
||||
providerUserId String
|
||||
title String
|
||||
status TreatmentStatus @default(DRAFT)
|
||||
treatmentAt DateTime
|
||||
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||
appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull)
|
||||
cases TreatmentCase[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([patientId, treatmentAt])
|
||||
@@index([organizationId, status])
|
||||
@@map("treatments")
|
||||
}
|
||||
|
||||
model TreatmentCase {
|
||||
id String @id @default(uuid())
|
||||
treatmentId String
|
||||
clientKey String?
|
||||
sortOrder Int
|
||||
treatmentType String
|
||||
teeth Json
|
||||
comment String?
|
||||
sentAt DateTime?
|
||||
|
||||
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
||||
attachments TreatmentCaseAttachment[]
|
||||
sends TreatmentCaseSend[]
|
||||
|
||||
@@index([treatmentId, sortOrder])
|
||||
@@map("treatment_cases")
|
||||
}
|
||||
|
||||
model TreatmentCaseAttachment {
|
||||
id String @id @default(uuid())
|
||||
caseId String?
|
||||
appointmentId String?
|
||||
caseClientKey String?
|
||||
fileName String
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
storagePath String
|
||||
|
||||
case TreatmentCase? @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([appointmentId, caseClientKey])
|
||||
@@index([caseId])
|
||||
@@map("treatment_case_attachments")
|
||||
}
|
||||
|
||||
model TreatmentCaseSend {
|
||||
id String @id @default(uuid())
|
||||
caseId String
|
||||
organizationId String
|
||||
sentAt DateTime @default(now())
|
||||
|
||||
case TreatmentCase @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([caseId, organizationId])
|
||||
@@map("treatment_case_sends")
|
||||
}
|
||||
|
||||
model Plan {
|
||||
id String @id @default(uuid())
|
||||
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PatientsModule } from './modules/patients/patients.module';
|
||||
import { StaffModule } from './modules/staff/staff.module';
|
||||
import { OrganizationModule } from './modules/organization/organization.module';
|
||||
import { AppointmentsModule } from './modules/appointments/appointments.module';
|
||||
import { TreatmentsModule } from './modules/treatments/treatments.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -21,6 +22,7 @@ import { AppointmentsModule } from './modules/appointments/appointments.module';
|
||||
AuthModule,
|
||||
PatientsModule,
|
||||
AppointmentsModule,
|
||||
TreatmentsModule,
|
||||
StaffModule,
|
||||
OrganizationModule,
|
||||
AdminModule.forRoot(),
|
||||
|
||||
@@ -220,6 +220,9 @@ export class AppointmentsService {
|
||||
if (names.includes('TAB_TREATMENT_EDIT')) {
|
||||
return;
|
||||
}
|
||||
if (names.includes('TAB_TREATMENT_READ')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You do not have access to appointments');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { UpdatePatientDto } from './dto/update-patient.dto';
|
||||
import { PatientsService } from './patients.service';
|
||||
import { CreateTreatmentHistoryDto } from './dto/create-treatment-history.dto';
|
||||
|
||||
@ApiTags('patients')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@@ -52,26 +51,4 @@ export class PatientsController {
|
||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||
return this.patientsService.update(id, updatePatientDto, organizationId);
|
||||
}
|
||||
|
||||
@Get(':id/treatments')
|
||||
@ApiOperation({ summary: 'Get patient treatment history' })
|
||||
findTreatments(
|
||||
@Param('id') id: string,
|
||||
@Query('limit', new ParseIntPipe({ optional: true })) limit = 20,
|
||||
@Req() req,
|
||||
) {
|
||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||
return this.patientsService.findTreatments(id, organizationId, limit);
|
||||
}
|
||||
|
||||
@Post(':id/treatments')
|
||||
@ApiOperation({ summary: 'Add treatment history item for a patient' })
|
||||
addTreatment(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateTreatmentHistoryDto,
|
||||
@Req() req,
|
||||
) {
|
||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||
return this.patientsService.addTreatment(id, dto, organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { CreatePatientDto } from './dto/create-patient.dto';
|
||||
import { ListPatientsDto } from './dto/list-patients.dto';
|
||||
import { UpdatePatientDto } from './dto/update-patient.dto';
|
||||
import { CreateTreatmentHistoryDto } from './dto/create-treatment-history.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PatientsService {
|
||||
@@ -89,36 +88,6 @@ export class PatientsService {
|
||||
return { success: true, data: patient };
|
||||
}
|
||||
|
||||
async findTreatments(patientId: string, organizationId: string, limit = 20) {
|
||||
await this.ensurePatient(patientId, organizationId);
|
||||
|
||||
const items = await this.prisma.patientTreatmentHistory.findMany({
|
||||
where: { patientId },
|
||||
orderBy: [{ treatmentAt: 'desc' }],
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return { success: true, data: items };
|
||||
}
|
||||
|
||||
async addTreatment(
|
||||
patientId: string,
|
||||
dto: CreateTreatmentHistoryDto,
|
||||
organizationId: string,
|
||||
) {
|
||||
await this.ensurePatient(patientId, organizationId);
|
||||
|
||||
const treatment = await this.prisma.patientTreatmentHistory.create({
|
||||
data: {
|
||||
...dto,
|
||||
treatmentAt: new Date(dto.treatmentAt),
|
||||
patientId,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: treatment };
|
||||
}
|
||||
|
||||
private async ensurePatient(id: string, organizationId: string) {
|
||||
const patient = await this.prisma.patient.findFirst({
|
||||
where: { id, organizationId },
|
||||
|
||||
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 { useToast } from '@/lib/hooks/useToast';
|
||||
import { hasPermission } from '@/components/shared/permissions';
|
||||
import {
|
||||
CreatePatientInput,
|
||||
CreateTreatmentHistoryInput,
|
||||
Patient,
|
||||
TreatmentHistoryItem,
|
||||
} from '@/types/patient';
|
||||
import { CreatePatientInput, Patient } from '@/types/patient';
|
||||
import { PatientSearchSelect } from '../../../components/ui/patient/PatientSearchSelect';
|
||||
import { CreatePatientModal } from '../../../components/ui/patient/CreatePatientModal';
|
||||
import { PatientSummaryCard } from '../../../components/ui/patient/PatientSummaryCard';
|
||||
import { TreatmentHistoryPreview } from '../../../components/ui/patient/TreatmentHistoryPreview';
|
||||
|
||||
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
||||
firstName: '',
|
||||
@@ -32,12 +26,9 @@ export default function PatientsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
|
||||
const [treatments, setTreatments] = useState<TreatmentHistoryItem[]>([]);
|
||||
const [loadingPatients, setLoadingPatients] = useState(false);
|
||||
const [loadingTreatments, setLoadingTreatments] = useState(false);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [savingPatient, setSavingPatient] = useState(false);
|
||||
const [savingTreatment, setSavingTreatment] = useState(false);
|
||||
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
|
||||
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
|
||||
|
||||
@@ -79,19 +70,6 @@ export default function PatientsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTreatments(patientId: string) {
|
||||
setLoadingTreatments(true);
|
||||
toast.setError('');
|
||||
try {
|
||||
const response = await patientsApi.listTreatments(patientId);
|
||||
setTreatments(response.data);
|
||||
} catch (error: unknown) {
|
||||
toast.showError(formatApiErrorMessage(error, 'Failed to load treatment history.'));
|
||||
} finally {
|
||||
setLoadingTreatments(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreatePatient() {
|
||||
setSavingPatient(true);
|
||||
toast.setError('');
|
||||
@@ -101,7 +79,6 @@ export default function PatientsPage() {
|
||||
setPatientForm(EMPTY_PATIENT_FORM);
|
||||
await loadPatients(search);
|
||||
setSelectedPatient(response.data);
|
||||
await loadTreatments(response.data.id);
|
||||
toast.showSuccess(
|
||||
`Patient ${response.data.firstName} ${response.data.lastName} was saved successfully.`,
|
||||
);
|
||||
@@ -112,31 +89,6 @@ export default function PatientsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuickAddTreatment() {
|
||||
if (!selectedPatient) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CreateTreatmentHistoryInput = {
|
||||
title: 'Initial consultation',
|
||||
status: 'scheduled',
|
||||
treatmentAt: new Date().toISOString(),
|
||||
notes: 'Created from quick action on patients page.',
|
||||
};
|
||||
|
||||
setSavingTreatment(true);
|
||||
toast.setError('');
|
||||
try {
|
||||
await patientsApi.addTreatment(selectedPatient.id, payload);
|
||||
await loadTreatments(selectedPatient.id);
|
||||
toast.showSuccess('Treatment entry added successfully.');
|
||||
} catch (error: unknown) {
|
||||
toast.showError(formatApiErrorMessage(error, 'Failed to add treatment entry.'));
|
||||
} finally {
|
||||
setSavingTreatment(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -179,31 +131,13 @@ export default function PatientsPage() {
|
||||
onSearchChange={setSearch}
|
||||
patients={sortedPatients}
|
||||
selectedPatientId={selectedPatient?.id}
|
||||
onSelectPatient={(patient) => {
|
||||
setSelectedPatient(patient);
|
||||
void loadTreatments(patient.id);
|
||||
}}
|
||||
onSelectPatient={setSelectedPatient}
|
||||
loading={loadingPatients}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-2 space-y-4">
|
||||
<PatientSummaryCard patient={selectedPatient} />
|
||||
<div className="flex">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!selectedPatient || !canEditPatients}
|
||||
isLoading={savingTreatment}
|
||||
onClick={() => {
|
||||
if (!canEditPatients) return;
|
||||
void handleQuickAddTreatment();
|
||||
}}
|
||||
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
|
||||
>
|
||||
Add Quick Treatment Entry
|
||||
</Button>
|
||||
</div>
|
||||
<TreatmentHistoryPreview items={treatments} loading={loadingTreatments} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,8 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean
|
||||
return (
|
||||
hasPermission(org, 'TAB_APPOINTMENTS_READ') ||
|
||||
hasPermission(org, 'TAB_APPOINTMENTS_EDIT') ||
|
||||
hasPermission(org, 'TAB_TREATMENT_EDIT')
|
||||
hasPermission(org, 'TAB_TREATMENT_EDIT') ||
|
||||
hasPermission(org, 'TAB_TREATMENT_READ')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,3 +93,13 @@ export function canEditTreatment(org: Organization | null): boolean {
|
||||
if (org.isOwner) return true;
|
||||
return hasPermission(org, 'TAB_TREATMENT_EDIT');
|
||||
}
|
||||
|
||||
/** View treatment workspace (read-only or edit) */
|
||||
export function canViewTreatment(org: Organization | null): boolean {
|
||||
if (!org) return false;
|
||||
if (org.isOwner) return true;
|
||||
return (
|
||||
hasPermission(org, 'TAB_TREATMENT_READ') ||
|
||||
hasPermission(org, 'TAB_TREATMENT_EDIT')
|
||||
);
|
||||
}
|
||||
|
||||
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 }) => (
|
||||
<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) => {
|
||||
const kind = getToothShapeKind(fdi);
|
||||
const gid = `${uid}-g-${fdi}-${i}`;
|
||||
@@ -148,31 +148,30 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="surface-card p-4 space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="surface-card p-3 space-y-3">
|
||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">FDI tooth chart</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Tap teeth to multi-select (FDI). Selection applies to the active treatment record until you save.
|
||||
<p className="text-[11px] text-text-muted mt-0.5">
|
||||
Tap teeth to multi-select. Applies to the active case.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:items-end w-full sm:max-w-xs">
|
||||
<p className="text-xs text-text-secondary tabular-nums text-right">
|
||||
<p className="text-[11px] text-text-secondary tabular-nums sm:text-right">
|
||||
Selected: {selected.size === 0 ? '—' : [...selected].sort().join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
className="pointer-events-none absolute left-1/2 top-3 bottom-3 w-px -translate-x-1/2 bg-border/70"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative z-10 space-y-0">
|
||||
<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) => {
|
||||
const size = toothSizeClass(fdi);
|
||||
const isSel = selected.has(fdi);
|
||||
@@ -189,14 +188,10 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="my-4 h-px w-full max-w-[min(100%,42rem)] mx-auto bg-border/70"
|
||||
role="separator"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="my-2 h-px w-full bg-border/70" role="separator" aria-hidden />
|
||||
|
||||
<div className="my-4 pt-1">
|
||||
<div className="flex justify-center gap-x-1">
|
||||
<div className="pt-0.5">
|
||||
<div className="flex flex-nowrap justify-center gap-x-1">
|
||||
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => {
|
||||
const size = toothSizeClass(fdi);
|
||||
const isSel = selected.has(fdi);
|
||||
@@ -218,6 +213,7 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">Lower arch</p>
|
||||
</div>
|
||||
|
||||
@@ -2,89 +2,92 @@
|
||||
|
||||
import { FileText } from 'lucide-react';
|
||||
import type { PastTreatment } from '@/types/treatment';
|
||||
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
||||
|
||||
interface PastTreatmentsPanelProps {
|
||||
items: PastTreatment[];
|
||||
loading?: boolean;
|
||||
selectedTreatmentId?: string | null;
|
||||
onSelectTreatment?: (treatment: PastTreatment) => void;
|
||||
onReviewTreatment?: (treatment: PastTreatment) => void;
|
||||
}
|
||||
|
||||
export function PastTreatmentsPanel({
|
||||
items,
|
||||
loading,
|
||||
selectedTreatmentId,
|
||||
onSelectTreatment,
|
||||
onReviewTreatment,
|
||||
}: PastTreatmentsPanelProps) {
|
||||
return (
|
||||
<div className="surface-card p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">Previous treatments</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Document preview is not implemented yet; file names are listed for context.
|
||||
<p className="text-[11px] text-text-muted mt-0.5">
|
||||
Completed treatments for this patient. Each case is listed separately.
|
||||
</p>
|
||||
</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 && (
|
||||
<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) => (
|
||||
<article
|
||||
key={t.id}
|
||||
className={`border rounded-[var(--radius-md)] p-3 ${
|
||||
selectedTreatmentId === t.id
|
||||
? 'border-primary/70 bg-primary-soft/35'
|
||||
: 'border-border/70 bg-background-secondary/40'
|
||||
}`}
|
||||
className="border border-border/70 rounded-[var(--radius-md)] p-2.5 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">{t.title}</p>
|
||||
<time className="text-xs text-text-muted tabular-nums shrink-0" dateTime={t.treatmentAt}>
|
||||
<div className="min-w-0">
|
||||
<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()}
|
||||
</time>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary mt-1">Status: {t.status}</p>
|
||||
|
||||
{t.records.length > 0 && (
|
||||
<ul className="mt-2 space-y-1.5 text-xs text-text-secondary">
|
||||
{t.records.map((r) => (
|
||||
<li key={r.id}>
|
||||
<span className="text-text-primary font-medium">Record: </span>
|
||||
<span className="capitalize">{r.treatmentType}</span>
|
||||
{' | '}
|
||||
{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"
|
||||
<div className="space-y-1.5">
|
||||
{t.cases.map((c, idx) => {
|
||||
const attachments = c.attachmentMetas ?? [];
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className="border border-border/60 rounded-[var(--radius-sm)] px-2.5 py-2 bg-background-secondary/30 space-y-1"
|
||||
>
|
||||
Review details
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium text-text-primary capitalize">
|
||||
Case {idx + 1} · {c.treatmentType}
|
||||
</p>
|
||||
{c.sentAt && (
|
||||
<CaseSentLabel
|
||||
treatmentCase={c}
|
||||
className="text-[10px] text-text-muted shrink-0 text-right"
|
||||
/>
|
||||
)}
|
||||
|
||||
{t.documents.length > 0 && (
|
||||
<div className="mt-3 pt-2 border-t border-border/50">
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1.5">Attachments</p>
|
||||
<ul className="space-y-1">
|
||||
{t.documents.map((doc) => (
|
||||
</div>
|
||||
<p className="text-[11px] text-text-secondary">
|
||||
Teeth: {c.teeth.length ? [...c.teeth].sort().join(', ') : 'None selected'}
|
||||
</p>
|
||||
{c.notes?.trim() && (
|
||||
<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
|
||||
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="text-text-muted tabular-nums shrink-0">
|
||||
{(doc.sizeBytes / 1024).toFixed(1)} KB
|
||||
@@ -92,6 +95,22 @@ export function PastTreatmentsPanel({
|
||||
</li>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</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 {
|
||||
CreatePatientInput,
|
||||
CreateTreatmentHistoryInput,
|
||||
Patient,
|
||||
PatientsListResponse,
|
||||
TreatmentHistoryItem,
|
||||
} from '@/types/patient';
|
||||
|
||||
export const patientsApi = {
|
||||
@@ -22,22 +20,4 @@ export const patientsApi = {
|
||||
const response = await apiClient.get(`/patients/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
listTreatments: async (
|
||||
patientId: string,
|
||||
limit = 20,
|
||||
): Promise<{ success: boolean; data: TreatmentHistoryItem[] }> => {
|
||||
const response = await apiClient.get(`/patients/${patientId}/treatments`, {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
addTreatment: async (
|
||||
patientId: string,
|
||||
data: CreateTreatmentHistoryInput,
|
||||
): Promise<{ success: boolean; data: TreatmentHistoryItem }> => {
|
||||
const response = await apiClient.post(`/patients/${patientId}/treatments`, data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface TreatmentHistoryItem {
|
||||
id: string;
|
||||
patientId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
treatmentAt: string;
|
||||
tooth?: string | null;
|
||||
notes?: string | null;
|
||||
totalCost?: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreatePatientInput {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
@@ -34,15 +21,6 @@ export interface CreatePatientInput {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateTreatmentHistoryInput {
|
||||
title: string;
|
||||
status: string;
|
||||
treatmentAt: string;
|
||||
tooth?: string;
|
||||
notes?: string;
|
||||
totalCost?: number;
|
||||
}
|
||||
|
||||
export interface PatientsListResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
|
||||
@@ -61,20 +61,32 @@ export const TREATMENT_TYPES = [
|
||||
|
||||
export type TreatmentType = (typeof TREATMENT_TYPES)[number];
|
||||
|
||||
export interface PastTreatmentRecord {
|
||||
export interface TreatmentCaseSendInfo {
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
sentAt: string;
|
||||
}
|
||||
|
||||
export interface PastTreatmentCase {
|
||||
id: string;
|
||||
clientId: string;
|
||||
treatmentType: TreatmentType;
|
||||
teeth: FdiToothId[];
|
||||
notes?: string | null;
|
||||
attachmentMetas?: TreatmentAttachmentMeta[];
|
||||
sendToOrganizationIds?: string[];
|
||||
sends?: TreatmentCaseSendInfo[];
|
||||
sentAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PastTreatment {
|
||||
id: string;
|
||||
patientId: string;
|
||||
appointmentId?: string | null;
|
||||
title: string;
|
||||
treatmentAt: string;
|
||||
status: string;
|
||||
records: PastTreatmentRecord[];
|
||||
cases: PastTreatmentCase[];
|
||||
documents: TreatmentAttachmentMeta[];
|
||||
}
|
||||
|
||||
@@ -84,29 +96,33 @@ export interface LinkedOrganizationOption {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface TreatmentRecordDraft {
|
||||
export interface TreatmentCaseDraft {
|
||||
clientId: string;
|
||||
id?: string;
|
||||
treatmentType: TreatmentType;
|
||||
teeth: FdiToothId[];
|
||||
comment: string;
|
||||
attachmentMetas: TreatmentAttachmentMeta[];
|
||||
/** Organizations selected for sending this record (mock only until API exists) */
|
||||
sendToOrganizationIds: string[];
|
||||
sends?: TreatmentCaseSendInfo[];
|
||||
sentAt?: string | null;
|
||||
}
|
||||
|
||||
/** Payload for persisting a draft (mock API); omits ephemeral client-only fields */
|
||||
export type SavedTreatmentRecordPayload = Omit<TreatmentRecordDraft, 'clientId' | 'sentAt'>;
|
||||
export type SavedTreatmentCasePayload = {
|
||||
clientId: string;
|
||||
id?: string;
|
||||
treatmentType: TreatmentType;
|
||||
teeth: FdiToothId[];
|
||||
comment: string;
|
||||
attachmentIds: string[];
|
||||
};
|
||||
|
||||
export interface SaveTreatmentPayload {
|
||||
appointmentId: string;
|
||||
patientId: string;
|
||||
records: SavedTreatmentRecordPayload[];
|
||||
cases: SavedTreatmentCasePayload[];
|
||||
}
|
||||
|
||||
export interface SendTreatmentRecordPayload {
|
||||
appointmentId: string;
|
||||
patientId: string;
|
||||
recordClientId: string;
|
||||
export interface SendTreatmentCasePayload {
|
||||
organizationIds: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user