diff --git a/backend/prisma/migrations/20260628150000_lab_case_tasks/migration.sql b/backend/prisma/migrations/20260628150000_lab_case_tasks/migration.sql new file mode 100644 index 0000000..4fc9f0c --- /dev/null +++ b/backend/prisma/migrations/20260628150000_lab_case_tasks/migration.sql @@ -0,0 +1,46 @@ +-- Treatment workflow steps + lab case tasks + +CREATE TYPE "LabTaskStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED'); + +CREATE TABLE "treatment_workflow_steps" ( + "id" TEXT NOT NULL, + "treatmentType" TEXT NOT NULL, + "stepOrder" INTEGER NOT NULL, + "label" TEXT NOT NULL, + + CONSTRAINT "treatment_workflow_steps_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "treatment_workflow_steps_treatmentType_stepOrder_key" + ON "treatment_workflow_steps"("treatmentType", "stepOrder"); + +CREATE TABLE "lab_case_tasks" ( + "id" TEXT NOT NULL, + "labCaseId" TEXT NOT NULL, + "treatmentDetailId" TEXT NOT NULL, + "tooth" TEXT NOT NULL, + "treatmentType" TEXT NOT NULL, + "stepOrder" INTEGER NOT NULL, + "stepLabel" TEXT NOT NULL, + "assigneeUserId" TEXT, + "status" "LabTaskStatus" NOT NULL DEFAULT 'PENDING', + + CONSTRAINT "lab_case_tasks_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "lab_case_tasks_labCaseId_tooth_treatmentType_stepOrder_key" + ON "lab_case_tasks"("labCaseId", "tooth", "treatmentType", "stepOrder"); + +CREATE INDEX "lab_case_tasks_labCaseId_status_idx" ON "lab_case_tasks"("labCaseId", "status"); + +ALTER TABLE "lab_case_tasks" + ADD CONSTRAINT "lab_case_tasks_labCaseId_fkey" + FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_tasks" + ADD CONSTRAINT "lab_case_tasks_treatmentDetailId_fkey" + FOREIGN KEY ("treatmentDetailId") REFERENCES "treatment_details"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "lab_case_tasks" + ADD CONSTRAINT "lab_case_tasks_assigneeUserId_fkey" + FOREIGN KEY ("assigneeUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260628160000_treatment_type_catalog/migration.sql b/backend/prisma/migrations/20260628160000_treatment_type_catalog/migration.sql new file mode 100644 index 0000000..9580aab --- /dev/null +++ b/backend/prisma/migrations/20260628160000_treatment_type_catalog/migration.sql @@ -0,0 +1,49 @@ +-- Treatment type catalog (data-driven; business logic reads from here) + +CREATE TABLE "treatment_types" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "labDependent" BOOLEAN NOT NULL DEFAULT false, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "treatment_types_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "treatment_types_code_key" ON "treatment_types"("code"); + +-- Temporary catalog (will be replaced with 14 real-world types later) +INSERT INTO "treatment_types" ("id", "code", "labDependent", "sortOrder") VALUES + ('tt-consultation', 'consultation', false, 1), + ('tt-filling', 'filling', false, 2), + ('tt-endo', 'endo', true, 3), + ('tt-visit', 'visit', false, 4), + ('tt-hygiene', 'hygiene', false, 5); + +-- Re-link workflow steps to catalog rows +ALTER TABLE "treatment_workflow_steps" ADD COLUMN "treatmentTypeId" TEXT; + +UPDATE "treatment_workflow_steps" AS w +SET "treatmentTypeId" = t."id" +FROM "treatment_types" AS t +WHERE t."code" = w."treatmentType"; + +-- Drop steps for clinic-only types; only lab-dependent types keep workflows +DELETE FROM "treatment_workflow_steps" AS w +USING "treatment_types" AS t +WHERE w."treatmentTypeId" = t."id" AND t."labDependent" = false; + +DELETE FROM "treatment_workflow_steps" WHERE "treatmentTypeId" IS NULL; + +ALTER TABLE "treatment_workflow_steps" DROP CONSTRAINT IF EXISTS "treatment_workflow_steps_treatmentType_stepOrder_key"; +DROP INDEX IF EXISTS "treatment_workflow_steps_treatmentType_stepOrder_key"; + +ALTER TABLE "treatment_workflow_steps" DROP COLUMN "treatmentType"; + +ALTER TABLE "treatment_workflow_steps" ALTER COLUMN "treatmentTypeId" SET NOT NULL; + +ALTER TABLE "treatment_workflow_steps" + ADD CONSTRAINT "treatment_workflow_steps_treatmentTypeId_fkey" + FOREIGN KEY ("treatmentTypeId") REFERENCES "treatment_types"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE UNIQUE INDEX "treatment_workflow_steps_treatmentTypeId_stepOrder_key" + ON "treatment_workflow_steps"("treatmentTypeId", "stepOrder"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bf6f441..41f23ec 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -23,6 +23,7 @@ model User { sessions Session[] // 👈 ADD THIS - opposite relation for Session sentStaffInvites StaffInvitation[] sentOrganizationInvitations OrganizationInvitation[] + assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -118,6 +119,12 @@ enum TreatmentStatus { COMPLETED } +enum LabTaskStatus { + PENDING + IN_PROGRESS + COMPLETED +} + model Treatment { id String @id @default(uuid()) organizationId String @@ -154,6 +161,7 @@ model TreatmentDetail { treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) attachments TreatmentDetailAttachment[] labCaseLink LabCaseDetail? + labCaseTasks LabCaseTask[] @@index([treatmentId, sortOrder]) @@map("treatment_details") @@ -190,6 +198,7 @@ model LabCase { treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) details LabCaseDetail[] sends LabCaseSend[] + tasks LabCaseTask[] @@index([treatmentId, sortOrder]) @@map("lab_cases") @@ -219,6 +228,49 @@ model LabCaseSend { @@map("lab_case_sends") } +model TreatmentType { + id String @id @default(uuid()) + code String @unique + labDependent Boolean @default(false) + sortOrder Int @default(0) + + workflowSteps TreatmentWorkflowStep[] + + @@map("treatment_types") +} + +model TreatmentWorkflowStep { + id String @id @default(uuid()) + treatmentTypeId String + stepOrder Int + label String + + treatmentType TreatmentType @relation(fields: [treatmentTypeId], references: [id], onDelete: Cascade) + + @@unique([treatmentTypeId, stepOrder]) + @@map("treatment_workflow_steps") +} + +model LabCaseTask { + id String @id @default(uuid()) + labCaseId String + treatmentDetailId String + tooth String + treatmentType String + stepOrder Int + stepLabel String + assigneeUserId String? + status LabTaskStatus @default(PENDING) + + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) + assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull) + + @@unique([labCaseId, tooth, treatmentType, stepOrder]) + @@index([labCaseId, status]) + @@map("lab_case_tasks") +} + model Plan { id String @id @default(uuid()) name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise" diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 171ac79..d111482 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -1,5 +1,6 @@ // backend/prisma/seed.ts import { PrismaClient } from '@prisma/client'; +import { randomUUID } from 'crypto'; import { config } from 'dotenv'; import path from 'path'; @@ -123,6 +124,57 @@ async function main() { } console.log('✅ Created features and permissions'); + const workflowSteps = [ + { code: 'endo', stepOrder: 1, label: 'Access review' }, + { code: 'endo', stepOrder: 2, label: 'Fabrication' }, + ] as const; + + const treatmentTypes = [ + { code: 'consultation', labDependent: false, sortOrder: 1 }, + { code: 'filling', labDependent: false, sortOrder: 2 }, + { code: 'endo', labDependent: true, sortOrder: 3 }, + { code: 'visit', labDependent: false, sortOrder: 4 }, + { code: 'hygiene', labDependent: false, sortOrder: 5 }, + ] as const; + + for (const type of treatmentTypes) { + await prisma.treatmentType.upsert({ + where: { code: type.code }, + update: { labDependent: type.labDependent, sortOrder: type.sortOrder }, + create: { + id: randomUUID(), + code: type.code, + labDependent: type.labDependent, + sortOrder: type.sortOrder, + }, + }); + } + console.log('✅ Seeded treatment type catalog'); + + for (const step of workflowSteps) { + const treatmentType = await prisma.treatmentType.findUniqueOrThrow({ + where: { code: step.code }, + select: { id: true }, + }); + + await prisma.treatmentWorkflowStep.upsert({ + where: { + treatmentTypeId_stepOrder: { + treatmentTypeId: treatmentType.id, + stepOrder: step.stepOrder, + }, + }, + update: { label: step.label }, + create: { + id: randomUUID(), + treatmentTypeId: treatmentType.id, + stepOrder: step.stepOrder, + label: step.label, + }, + }); + } + console.log('✅ Seeded lab workflow steps'); + console.log('🌱 Seeding completed successfully!'); } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 70c3e1c..3b7d8ec 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -11,6 +11,8 @@ 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'; +import { CasesModule } from './modules/cases/cases.module'; +import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module'; @Module({ imports: [ @@ -19,10 +21,12 @@ import { TreatmentsModule } from './modules/treatments/treatments.module'; load: [configurations], }), PrismaModule, // ✅ ADD THIS + TreatmentCatalogModule, AuthModule, PatientsModule, AppointmentsModule, TreatmentsModule, + CasesModule, StaffModule, OrganizationModule, AdminModule.forRoot(), diff --git a/backend/src/common/guards/lab-org.guard.ts b/backend/src/common/guards/lab-org.guard.ts new file mode 100644 index 0000000..69bc829 --- /dev/null +++ b/backend/src/common/guards/lab-org.guard.ts @@ -0,0 +1,25 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { assertLabOrganization } from '../../common/organization-type'; + +@Injectable() +export class LabOrgGuard implements CanActivate { + constructor(private readonly prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest<{ user?: { organizationId?: string } }>(); + const organizationId = request.user?.organizationId; + + if (!organizationId) { + throw new UnauthorizedException('Organization is not selected'); + } + + await assertLabOrganization(this.prisma, organizationId); + return true; + } +} diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 769255e..ef1040e 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -14,6 +14,7 @@ import { StaffWorkingHoursService } from '../staff/staff-working-hours.service'; import { CreateAppointmentDto } from './dto/create-appointment.dto'; import { ListAppointmentsDto } from './dto/list-appointments.dto'; import { UpdateAppointmentDto } from './dto/update-appointment.dto'; +import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; const MS_PER_DAY = 86_400_000; @@ -22,6 +23,7 @@ export class AppointmentsService { constructor( private readonly prisma: PrismaService, private readonly staffWorkingHoursService: StaffWorkingHoursService, + private readonly treatmentCatalog: TreatmentCatalogService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { @@ -134,6 +136,7 @@ export class AppointmentsService { await this.ensurePatientInOrg(dto.patientId, organizationId); await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId); + this.treatmentCatalog.assertKnownTreatmentType(dto.purpose); await this.ensureAppointmentWithinProviderWorkingHours( dto.providerUserId, organizationId, @@ -195,6 +198,8 @@ export class AppointmentsService { const providerUserId = dto.providerUserId ?? existing.providerUserId; const purpose = dto.purpose ?? existing.purpose; + this.treatmentCatalog.assertKnownTreatmentType(purpose); + await this.ensurePatientInOrg(patientId, organizationId); await this.ensureProviderIsTreatmentEditor(providerUserId, organizationId); await this.ensureAppointmentWithinProviderWorkingHours( diff --git a/backend/src/modules/appointments/dto/create-appointment.dto.ts b/backend/src/modules/appointments/dto/create-appointment.dto.ts index 19b293b..fb34434 100644 --- a/backend/src/modules/appointments/dto/create-appointment.dto.ts +++ b/backend/src/modules/appointments/dto/create-appointment.dto.ts @@ -1,9 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsDateString, IsIn, IsUUID } from 'class-validator'; - -const APPOINTMENT_PURPOSES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const; - -export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number]; +import { IsDateString, IsString, IsUUID, MaxLength } from 'class-validator'; export class CreateAppointmentDto { @ApiProperty() @@ -22,7 +18,11 @@ export class CreateAppointmentDto { @IsDateString() endAt: string; - @ApiProperty({ enum: APPOINTMENT_PURPOSES }) - @IsIn([...APPOINTMENT_PURPOSES]) - purpose: AppointmentPurpose; + @ApiProperty({ + description: 'Treatment type code from the treatment catalog', + example: 'consultation', + }) + @IsString() + @MaxLength(64) + purpose: string; } diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts new file mode 100644 index 0000000..2216e85 --- /dev/null +++ b/backend/src/modules/cases/cases.controller.ts @@ -0,0 +1,56 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { LabOrgGuard } from '../../common/guards/lab-org.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CasesService } from './cases.service'; +import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto'; + +@ApiTags('cases') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard, LabOrgGuard) +@Controller('cases') +export class CasesController { + constructor(private readonly casesService: CasesService) {} + + @Get() + @ApiOperation({ summary: 'List lab cases received by this organization' }) + list(@Query() query: ListLabCasesDto, @Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.list(organizationId, req.user.id, query); + } + + @Get('assignable-members') + @ApiOperation({ summary: 'List lab staff who can be assigned to tasks' }) + listAssignableMembers(@Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.listAssignableMembers(organizationId, req.user.id); + } + + @Get(':id') + @ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' }) + getOne(@Param('id') id: string, @Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.getOne(id, organizationId, req.user.id); + } + + @Patch(':id/tasks/:taskId') + @ApiOperation({ summary: 'Update task assignee or status' }) + updateTask( + @Param('id') id: string, + @Param('taskId') taskId: string, + @Body() dto: UpdateLabCaseTaskDto, + @Req() req, + ) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.updateTask(id, taskId, dto, organizationId, req.user.id); + } +} diff --git a/backend/src/modules/cases/cases.module.ts b/backend/src/modules/cases/cases.module.ts new file mode 100644 index 0000000..6957773 --- /dev/null +++ b/backend/src/modules/cases/cases.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { LabOrgGuard } from '../../common/guards/lab-org.guard'; +import { CasesController } from './cases.controller'; +import { CasesService } from './cases.service'; + +@Module({ + controllers: [CasesController], + providers: [CasesService, PrismaService, LabOrgGuard], +}) +export class CasesModule {} diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts new file mode 100644 index 0000000..924ed55 --- /dev/null +++ b/backend/src/modules/cases/cases.service.ts @@ -0,0 +1,405 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { LabTaskStatus, Prisma } from '@prisma/client'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { normalizeMobile } from '../../common/phone'; +import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; +import { normalizeTeeth } from '../treatments/treatment.utils'; +import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto'; + +const labCaseListInclude = { + treatment: { + include: { + organization: { select: { id: true, name: true } }, + patient: { select: { id: true, firstName: true, lastName: true, mobile: true } }, + appointment: { select: { startAt: true } }, + }, + }, + details: { + include: { + detail: { + select: { + id: true, + treatmentType: true, + teeth: true, + comment: true, + }, + }, + }, + }, + sends: { + orderBy: [{ sentAt: 'asc' as const }], + include: { organization: { select: { id: true, name: true } } }, + }, + tasks: { + orderBy: [ + { tooth: 'asc' as const }, + { treatmentType: 'asc' as const }, + { stepOrder: 'asc' as const }, + ], + include: { + assignee: { select: { id: true, name: true, email: true } }, + }, + }, +} satisfies Prisma.LabCaseInclude; + +@Injectable() +export class CasesService { + constructor( + private readonly prisma: PrismaService, + private readonly treatmentCatalog: TreatmentCatalogService, + ) {} + + getOrganizationIdFromUser(user: { organizationId?: string }) { + if (!user?.organizationId) { + throw new BadRequestException('Organization is not selected'); + } + return user.organizationId; + } + + async list(labOrganizationId: string, actorUserId: string, query: ListLabCasesDto) { + await this.assertCanReadCases(actorUserId, labOrganizationId); + + if (query.treatmentType) { + this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType); + } + + const page = query.page ?? 1; + const limit = Math.min(Math.max(query.limit ?? 20, 1), 100); + const skip = (page - 1) * limit; + + const where: Prisma.LabCaseWhereInput = { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + ...(query.clinicOrganizationId + ? { treatment: { organizationId: query.clinicOrganizationId } } + : {}), + ...(query.treatmentType + ? { + details: { + some: { detail: { treatmentType: query.treatmentType } }, + }, + } + : {}), + ...(query.q?.trim() + ? this.buildSearchWhere(query.q.trim()) + : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.labCase.findMany({ + where, + include: { + treatment: { + include: { + organization: { select: { id: true, name: true } }, + patient: { select: { id: true, firstName: true, lastName: true, mobile: true } }, + }, + }, + details: { + include: { + detail: { select: { treatmentType: true } }, + }, + }, + tasks: { select: { id: true, status: true } }, + }, + orderBy: [{ sentAt: 'desc' }], + skip, + take: limit, + }), + this.prisma.labCase.count({ where }), + ]); + + return { + success: true, + data: { + items: items.map((lc) => this.mapLabCaseListItem(lc)), + pagination: { + page, + limit, + total, + totalPages: Math.max(1, Math.ceil(total / limit)), + }, + }, + }; + } + + async getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) { + await this.assertCanReadCases(actorUserId, labOrganizationId); + + const labCase = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + include: labCaseListInclude, + }); + + if (!labCase) { + throw new NotFoundException('Case not found'); + } + + return { success: true, data: this.mapLabCaseDetail(labCase) }; + } + + async updateTask( + labCaseId: string, + taskId: string, + dto: UpdateLabCaseTaskDto, + labOrganizationId: string, + actorUserId: string, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + + const task = await this.prisma.labCaseTask.findFirst({ + where: { + id: taskId, + labCaseId, + labCase: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + }, + }); + + if (!task) { + throw new NotFoundException('Task not found'); + } + + if (dto.assigneeUserId !== undefined && dto.assigneeUserId !== null) { + await this.ensureLabMember(dto.assigneeUserId, labOrganizationId); + } + + const updated = await this.prisma.labCaseTask.update({ + where: { id: taskId }, + data: { + ...(dto.assigneeUserId !== undefined ? { assigneeUserId: dto.assigneeUserId } : {}), + ...(dto.status !== undefined ? { status: dto.status } : {}), + }, + include: { + assignee: { select: { id: true, name: true, email: true } }, + }, + }); + + return { success: true, data: this.mapTask(updated) }; + } + + async listAssignableMembers(labOrganizationId: string, actorUserId: string) { + await this.assertCanReadCases(actorUserId, labOrganizationId); + + const memberships = await this.prisma.membership.findMany({ + where: { organizationId: labOrganizationId, isActive: true }, + include: { user: { select: { id: true, name: true, email: true } } }, + orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }], + }); + + return { + success: true, + data: memberships.map((m) => ({ + userId: m.user.id, + name: m.user.name, + email: m.user.email, + isOwner: m.isOwner, + })), + }; + } + + private buildSearchWhere(q: string): Prisma.LabCaseWhereInput { + const orConditions: Prisma.LabCaseWhereInput[] = [ + { + treatment: { + patient: { + OR: [ + { firstName: { contains: q, mode: 'insensitive' } }, + { lastName: { contains: q, mode: 'insensitive' } }, + ], + }, + }, + }, + { + treatment: { + organization: { name: { contains: q, mode: 'insensitive' } }, + }, + }, + ]; + + const normalized = normalizeMobile(q); + if (normalized) { + orConditions.push({ + treatment: { patient: { mobile: normalized } }, + }); + } + + return { OR: orConditions }; + } + + private mapLabCaseListItem(lc: { + id: string; + sentAt: Date | null; + treatment: { + organization: { id: string; name: string }; + patient: { id: string; firstName: string; lastName: string; mobile: string }; + }; + details: Array<{ detail: { treatmentType: string } }>; + tasks: Array<{ id: string; status: LabTaskStatus }>; + }) { + const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))]; + const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length; + + return { + id: lc.id, + sentAt: lc.sentAt?.toISOString() ?? null, + clinic: lc.treatment.organization, + patient: { + id: lc.treatment.patient.id, + firstName: lc.treatment.patient.firstName, + lastName: lc.treatment.patient.lastName, + mobile: lc.treatment.patient.mobile, + }, + treatmentTypes, + taskProgress: { + completed: completedTasks, + total: lc.tasks.length, + }, + }; + } + + private mapLabCaseDetail(lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>) { + const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))]; + const tasksByTooth = this.groupTasksByTooth(lc.tasks); + + return { + id: lc.id, + sentAt: lc.sentAt?.toISOString() ?? null, + labComment: lc.labComment, + clinic: lc.treatment.organization, + patient: lc.treatment.patient, + appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null, + treatmentTypes, + details: lc.details.map((link) => ({ + id: link.detail.id, + treatmentType: link.detail.treatmentType, + teeth: normalizeTeeth(link.detail.teeth), + comment: link.detail.comment, + })), + sends: lc.sends.map((s) => ({ + organizationId: s.organizationId, + organizationName: s.organization.name, + sentAt: s.sentAt.toISOString(), + })), + tasks: lc.tasks.map((t) => this.mapTask(t)), + tasksByTooth, + taskProgress: { + completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length, + total: lc.tasks.length, + }, + }; + } + + private groupTasksByTooth( + tasks: Array<{ + id: string; + tooth: string; + treatmentType: string; + stepOrder: number; + stepLabel: string; + status: LabTaskStatus; + assigneeUserId: string | null; + assignee: { id: string; name: string; email: string } | null; + }>, + ) { + const groups = new Map< + string, + { + tooth: string; + treatmentType: string; + tasks: ReturnType[]; + } + >(); + + for (const task of tasks) { + const key = `${task.tooth}:${task.treatmentType}`; + const entry = groups.get(key) ?? { + tooth: task.tooth, + treatmentType: task.treatmentType, + tasks: [], + }; + entry.tasks.push(this.mapTask(task)); + groups.set(key, entry); + } + + return [...groups.values()]; + } + + private mapTask(task: { + id: string; + tooth: string; + treatmentType: string; + stepOrder: number; + stepLabel: string; + status: LabTaskStatus; + assigneeUserId: string | null; + assignee: { id: string; name: string; email: string } | null; + }) { + return { + id: task.id, + tooth: task.tooth, + treatmentType: task.treatmentType, + stepOrder: task.stepOrder, + stepLabel: task.stepLabel, + status: task.status, + assigneeUserId: task.assigneeUserId, + assignee: task.assignee + ? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email } + : null, + }; + } + + private async ensureLabMember(userId: string, labOrganizationId: string) { + const membership = await this.prisma.membership.findFirst({ + where: { userId, organizationId: labOrganizationId, isActive: true }, + select: { id: true }, + }); + if (!membership) { + throw new BadRequestException('Assignee must be an active member of this lab'); + } + } + + private async assertCanReadCases(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_CASES_READ') || names.includes('TAB_CASES_EDIT')) { + return; + } + throw new ForbiddenException('You do not have access to cases'); + } + + private async assertCanEditCases(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_CASES_EDIT')) { + return; + } + throw new ForbiddenException('You cannot update cases'); + } + + private async getMembership(userId: string, organizationId: string) { + return this.prisma.membership.findFirst({ + where: { userId, organizationId, isActive: true }, + include: { permissions: { include: { permission: true } } }, + }); + } +} diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts new file mode 100644 index 0000000..331b798 --- /dev/null +++ b/backend/src/modules/cases/dto/cases.dto.ts @@ -0,0 +1,41 @@ +import { Transform } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator'; +import { LabTaskStatus } from '@prisma/client'; + +export class UpdateLabCaseTaskDto { + @IsOptional() + @ValidateIf((_, value) => value !== null) + @IsUUID() + assigneeUserId?: string | null; + + @IsOptional() + @IsEnum(LabTaskStatus) + status?: LabTaskStatus; +} + +export class ListLabCasesDto { + @IsOptional() + @IsString() + q?: string; + + @IsOptional() + @IsString() + clinicOrganizationId?: string; + + @IsOptional() + @IsString() + treatmentType?: string; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + @Max(100) + limit = 20; +} \ No newline at end of file diff --git a/backend/src/modules/cases/lab-case-task.generator.ts b/backend/src/modules/cases/lab-case-task.generator.ts new file mode 100644 index 0000000..8474b03 --- /dev/null +++ b/backend/src/modules/cases/lab-case-task.generator.ts @@ -0,0 +1,91 @@ +import { LabTaskStatus, Prisma } from '@prisma/client'; +import { normalizeTeeth } from '../treatments/treatment.utils'; + +type TransactionClient = Prisma.TransactionClient; + +export async function generateLabCaseTasks( + tx: TransactionClient, + labCaseId: string, +): Promise { + const existingCount = await tx.labCaseTask.count({ where: { labCaseId } }); + if (existingCount > 0) { + return 0; + } + + const labCase = await tx.labCase.findUnique({ + where: { id: labCaseId }, + include: { + details: { + include: { + detail: { + select: { id: true, treatmentType: true, teeth: true }, + }, + }, + }, + }, + }); + + if (!labCase?.details.length) { + return 0; + } + + const treatmentTypeCodes = [...new Set(labCase.details.map((d) => d.detail.treatmentType))]; + + const labDependentTypes = await tx.treatmentType.findMany({ + where: { code: { in: treatmentTypeCodes }, labDependent: true }, + select: { id: true, code: true }, + }); + + if (labDependentTypes.length === 0) { + return 0; + } + + const labDependentCodes = new Set(labDependentTypes.map((t) => t.code)); + + const workflowSteps = await tx.treatmentWorkflowStep.findMany({ + where: { treatmentTypeId: { in: labDependentTypes.map((t) => t.id) } }, + orderBy: [{ treatmentTypeId: 'asc' }, { stepOrder: 'asc' }], + include: { treatmentType: { select: { code: true } } }, + }); + + const stepsByTypeCode = new Map(); + for (const step of workflowSteps) { + const code = step.treatmentType.code; + const list = stepsByTypeCode.get(code) ?? []; + list.push({ stepOrder: step.stepOrder, label: step.label }); + stepsByTypeCode.set(code, list); + } + + const taskRows: Prisma.LabCaseTaskCreateManyInput[] = []; + + for (const link of labCase.details) { + const detail = link.detail; + if (!labDependentCodes.has(detail.treatmentType)) { + continue; + } + + const teeth = normalizeTeeth(detail.teeth); + const typeSteps = stepsByTypeCode.get(detail.treatmentType) ?? []; + + for (const tooth of teeth) { + for (const step of typeSteps) { + taskRows.push({ + labCaseId, + treatmentDetailId: detail.id, + tooth, + treatmentType: detail.treatmentType, + stepOrder: step.stepOrder, + stepLabel: step.label, + status: LabTaskStatus.PENDING, + }); + } + } + } + + if (taskRows.length === 0) { + return 0; + } + + await tx.labCaseTask.createMany({ data: taskRows }); + return taskRows.length; +} diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts b/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts new file mode 100644 index 0000000..7e26db5 --- /dev/null +++ b/backend/src/modules/treatment-catalog/treatment-catalog.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { TreatmentCatalogService } from './treatment-catalog.service'; + +@ApiTags('treatment-catalog') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('treatment-catalog') +export class TreatmentCatalogController { + constructor(private readonly treatmentCatalogService: TreatmentCatalogService) {} + + @Get() + @ApiOperation({ summary: 'List treatment types from the catalog (data-driven)' }) + list() { + return { + success: true, + data: this.treatmentCatalogService.list(), + }; + } +} diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.module.ts b/backend/src/modules/treatment-catalog/treatment-catalog.module.ts new file mode 100644 index 0000000..10c4315 --- /dev/null +++ b/backend/src/modules/treatment-catalog/treatment-catalog.module.ts @@ -0,0 +1,12 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { TreatmentCatalogController } from './treatment-catalog.controller'; +import { TreatmentCatalogService } from './treatment-catalog.service'; + +@Global() +@Module({ + controllers: [TreatmentCatalogController], + providers: [TreatmentCatalogService, PrismaService], + exports: [TreatmentCatalogService], +}) +export class TreatmentCatalogModule {} diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.service.ts b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts new file mode 100644 index 0000000..e6c8c9e --- /dev/null +++ b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts @@ -0,0 +1,69 @@ +import { BadRequestException, Injectable, OnModuleInit } from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; + +export type TreatmentTypeCatalogEntry = { + id: string; + code: string; + labDependent: boolean; + sortOrder: number; +}; + +@Injectable() +export class TreatmentCatalogService implements OnModuleInit { + private loaded = false; + private byCode = new Map(); + + constructor(private readonly prisma: PrismaService) {} + + async onModuleInit() { + await this.refresh(); + } + + async refresh(): Promise { + const rows = await this.prisma.treatmentType.findMany({ + orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }], + select: { id: true, code: true, labDependent: true, sortOrder: true }, + }); + + this.byCode = new Map(rows.map((row) => [row.code, row])); + this.loaded = true; + } + + list(): TreatmentTypeCatalogEntry[] { + this.ensureLoaded(); + return [...this.byCode.values()]; + } + + getByCode(code: string): TreatmentTypeCatalogEntry | undefined { + this.ensureLoaded(); + return this.byCode.get(code); + } + + assertKnownTreatmentType(code: string): TreatmentTypeCatalogEntry { + const entry = this.getByCode(code); + if (!entry) { + throw new BadRequestException(`Unknown treatment type: ${code}`); + } + return entry; + } + + assertLabDependentTreatmentType(code: string): TreatmentTypeCatalogEntry { + const entry = this.assertKnownTreatmentType(code); + if (!entry.labDependent) { + throw new BadRequestException( + `Treatment type "${code}" is completed in the clinic and cannot be sent to a lab`, + ); + } + return entry; + } + + isLabDependent(code: string): boolean { + return this.getByCode(code)?.labDependent ?? false; + } + + private ensureLoaded() { + if (!this.loaded) { + throw new Error('Treatment catalog is not loaded yet'); + } + } +} diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts index b61c3e7..3c0312e 100644 --- a/backend/src/modules/treatments/dto/treatment.dto.ts +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -1,7 +1,6 @@ import { ArrayMinSize, IsArray, - IsIn, IsOptional, IsString, IsUUID, @@ -10,8 +9,6 @@ import { } from 'class-validator'; import { Type } from 'class-transformer'; -const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const; - export class SaveTreatmentDetailDto { @IsString() @MaxLength(64) @@ -21,7 +18,8 @@ export class SaveTreatmentDetailDto { @IsUUID() id?: string; - @IsIn(TREATMENT_TYPES) + @IsString() + @MaxLength(64) treatmentType: string; @IsArray() diff --git a/backend/src/modules/treatments/treatment.utils.ts b/backend/src/modules/treatments/treatment.utils.ts index fc13721..6943064 100644 --- a/backend/src/modules/treatments/treatment.utils.ts +++ b/backend/src/modules/treatments/treatment.utils.ts @@ -1,13 +1,5 @@ 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', diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index b0cbe38..99b7846 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -9,13 +9,14 @@ import { createReadStream, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { PrismaService } from '../../../prisma/prisma.service'; +import { generateLabCaseTasks } from '../cases/lab-case-task.generator'; +import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { SaveTreatmentDraftDto, SaveTreatmentLabCasesDto, } from './dto/treatment.dto'; import { generateTreatmentTitle, - isTreatmentType, mapTreatmentStatusForApi, normalizeTeeth, } from './treatment.utils'; @@ -61,7 +62,10 @@ const treatmentInclude = { export class TreatmentsService { private readonly uploadRoot = join(process.cwd(), 'uploads', 'treatments'); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly treatmentCatalog: TreatmentCatalogService, + ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { @@ -163,9 +167,7 @@ export class TreatmentsService { ); for (const d of dto.details) { - if (!isTreatmentType(d.treatmentType)) { - throw new BadRequestException(`Invalid treatment type: ${d.treatmentType}`); - } + this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType); } const normalizedDetails = dto.details.map((d, index) => ({ @@ -328,12 +330,16 @@ export class TreatmentsService { const details = await this.prisma.treatmentDetail.findMany({ where: { treatmentId: treatment.id, id: { in: detailIds } }, - select: { id: true }, + select: { id: true, treatmentType: true }, }); if (details.length !== uniqueDetailIds.size) { throw new BadRequestException('One or more treatment details were not found'); } + for (const detail of details) { + this.treatmentCatalog.assertLabDependentTreatmentType(detail.treatmentType); + } + const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); for (const lc of dto.labCases) { @@ -470,6 +476,8 @@ export class TreatmentsService { data: { sentAt: now }, }); } + + await generateLabCaseTasks(tx, labCaseId); }); const refreshed = await this.prisma.labCase.findUniqueOrThrow({ diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 4cb3bf1..05c96b0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -317,7 +317,26 @@ }, "cases": { "title": "Cases", - "stubDescription": "Received lab cases from linked clinics will appear here. Full inbox and task workflow coming in a later phase." + "subtitle": "Lab cases sent from linked clinics. Assign tasks and track progress by tooth.", + "searchPlaceholder": "Search by patient name or mobile…", + "emptyList": "No cases received yet.", + "selectCaseHint": "Select a case from the list to view tasks.", + "fromClinic": "From {name}", + "sentAt": "Sent {date}", + "taskProgressLabel": "Tasks: {completed} of {total} completed", + "taskProgressShort": "{progress} tasks", + "treatmentDetails": "Treatment details", + "teethLabel": "Teeth", + "tasksByTooth": "Tasks by tooth", + "toothGroupTitle": "Tooth {tooth} · {type}", + "noTasks": "No tasks were generated for this case.", + "unassigned": "Unassigned", + "statusPending": "Pending", + "statusInProgress": "In progress", + "statusCompleted": "Completed", + "errorLoadList": "Failed to load cases.", + "errorLoadDetail": "Failed to load case details.", + "errorUpdateTask": "Failed to update task." }, "appointments": { "title": "Appointments", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 31f11d1..4315638 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -317,7 +317,26 @@ }, "cases": { "title": "پرونده‌ها", - "stubDescription": "پرونده‌های دریافتی از کلینیک‌های متصل به زودی اینجا نمایش داده می‌شوند. صندوق ورودی کامل و گردش کار وظایف در فاز بعدی اضافه می‌شود." + "subtitle": "پرونده‌های ارسالی از کلینیک‌های متصل. وظایف را تخصیص دهید و پیشرفت هر دندان را پیگیری کنید.", + "searchPlaceholder": "جستجو با نام یا موبایل بیمار…", + "emptyList": "هنوز پرونده‌ای دریافت نشده است.", + "selectCaseHint": "برای مشاهده وظایف، یک پرونده از فهرست انتخاب کنید.", + "fromClinic": "از {name}", + "sentAt": "ارسال {date}", + "taskProgressLabel": "وظایف: {completed} از {total} انجام شده", + "taskProgressShort": "{progress} وظیفه", + "treatmentDetails": "جزئیات درمان", + "teethLabel": "دندان‌ها", + "tasksByTooth": "وظایف به تفکیک دندان", + "toothGroupTitle": "دندان {tooth} · {type}", + "noTasks": "برای این پرونده وظیفه‌ای ایجاد نشده است.", + "unassigned": "بدون مسئول", + "statusPending": "در انتظار", + "statusInProgress": "در حال انجام", + "statusCompleted": "انجام شده", + "errorLoadList": "بارگذاری پرونده‌ها ناموفق بود.", + "errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.", + "errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود." }, "appointments": { "title": "نوبت‌ها", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 20ef812..b2ae34e 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -317,7 +317,26 @@ }, "cases": { "title": "Dossiers", - "stubDescription": "Ontvangen labdossiers van gekoppelde klinieken verschijnen hier. Volledige inbox en takenworkflow volgen in een latere fase." + "subtitle": "Labdossiers van gekoppelde klinieken. Wijs taken toe en volg de voortgang per tand.", + "searchPlaceholder": "Zoeken op patiëntnaam of mobiel…", + "emptyList": "Nog geen dossiers ontvangen.", + "selectCaseHint": "Selecteer een dossier uit de lijst om taken te bekijken.", + "fromClinic": "Van {name}", + "sentAt": "Verzonden {date}", + "taskProgressLabel": "Taken: {completed} van {total} voltooid", + "taskProgressShort": "{progress} taken", + "treatmentDetails": "Behandeldetails", + "teethLabel": "Tanden", + "tasksByTooth": "Taken per tand", + "toothGroupTitle": "Tand {tooth} · {type}", + "noTasks": "Er zijn geen taken gegenereerd voor dit dossier.", + "unassigned": "Niet toegewezen", + "statusPending": "In afwachting", + "statusInProgress": "Bezig", + "statusCompleted": "Voltooid", + "errorLoadList": "Dossiers laden mislukt.", + "errorLoadDetail": "Dossierdetails laden mislukt.", + "errorUpdateTask": "Taak bijwerken mislukt." }, "appointments": { "title": "Afspraken", diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx index 1495096..49657df 100644 --- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -1,14 +1,315 @@ 'use client'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; +import { ToastStack } from '@/components/ui/shared/Toast'; +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 { casesApi } from '@/lib/api/cases'; +import type { AssignableMember, LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases'; + +const TREATMENT_TYPE_KEYS = { + consultation: 'typeConsultation', + filling: 'typeFilling', + endo: 'typeEndo', + visit: 'typeVisit', + hygiene: 'typeHygiene', +} as const; + +function formatPatientName(patient: { firstName: string; lastName: string }) { + return `${patient.firstName} ${patient.lastName}`.trim(); +} + +function formatDateTime(value: string | null, locale: string) { + if (!value) return '—'; + return new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); +} export default function CasesPage() { const t = useTranslations('cases'); + const tTreatment = useTranslations('treatment'); + const tCommon = useTranslations('common'); + const { currentOrganization, user } = useAuth(); + const toast = useToast(); + + const [search, setSearch] = useState(''); + const [cases, setCases] = useState([]); + const [selectedCaseId, setSelectedCaseId] = useState(null); + const [selectedCase, setSelectedCase] = useState(null); + const [members, setMembers] = useState([]); + const [loadingList, setLoadingList] = useState(false); + const [loadingDetail, setLoadingDetail] = useState(false); + const [updatingTaskId, setUpdatingTaskId] = useState(null); + + const canEdit = hasPermission(currentOrganization, 'TAB_CASES_EDIT'); + const locale = user?.language ?? 'en'; + + const treatmentLabel = useCallback( + (type: string) => { + const key = TREATMENT_TYPE_KEYS[type as keyof typeof TREATMENT_TYPE_KEYS]; + return key ? tTreatment(key) : type; + }, + [tTreatment], + ); + + const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( + () => [ + { value: 'PENDING', label: t('statusPending') }, + { value: 'IN_PROGRESS', label: t('statusInProgress') }, + { value: 'COMPLETED', label: t('statusCompleted') }, + ], + [t], + ); + + const loadCases = async (q: string) => { + setLoadingList(true); + toast.setError(''); + try { + const response = await casesApi.list({ q: q.trim() || undefined, page: 1, limit: 50 }); + setCases(response.data.items); + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorLoadList'))); + } finally { + setLoadingList(false); + } + }; + + const loadDetail = async (caseId: string) => { + setLoadingDetail(true); + toast.setError(''); + try { + const response = await casesApi.getOne(caseId); + setSelectedCase(response.data); + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorLoadDetail'))); + setSelectedCase(null); + } finally { + setLoadingDetail(false); + } + }; + + useEffect(() => { + void loadCases(''); + void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch + }, []); + + useEffect(() => { + const timeout = setTimeout(() => { + void loadCases(search); + }, 300); + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search only + }, [search]); + + useEffect(() => { + if (selectedCaseId) { + void loadDetail(selectedCaseId); + } else { + setSelectedCase(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes + }, [selectedCaseId]); + + async function handleTaskUpdate( + taskId: string, + payload: { assigneeUserId?: string | null; status?: LabTaskStatus }, + ) { + if (!selectedCaseId || !canEdit) return; + + setUpdatingTaskId(taskId); + toast.setError(''); + try { + await casesApi.updateTask(selectedCaseId, taskId, payload); + await loadDetail(selectedCaseId); + await loadCases(search); + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorUpdateTask'))); + } finally { + setUpdatingTaskId(null); + } + } return (
-

{t('title')}

-

{t('stubDescription')}

+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+
+ setSearch(e.target.value)} + placeholder={t('searchPlaceholder')} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm" + /> + + {loadingList ? ( +

{tCommon('loading')}

+ ) : cases.length === 0 ? ( +

{t('emptyList')}

+ ) : ( +
    + {cases.map((item) => { + const isActive = item.id === selectedCaseId; + const progress = + item.taskProgress.total > 0 + ? `${item.taskProgress.completed}/${item.taskProgress.total}` + : '0/0'; + + return ( +
  • + +
  • + ); + })} +
+ )} +
+ +
+ {!selectedCaseId ? ( +

{t('selectCaseHint')}

+ ) : loadingDetail || !selectedCase ? ( +

{tCommon('loading')}

+ ) : ( +
+
+

+ {formatPatientName(selectedCase.patient)} +

+

+ {t('fromClinic', { name: selectedCase.clinic.name })} +

+

+ {t('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })} +

+

+ {t('taskProgressLabel', { + completed: selectedCase.taskProgress.completed, + total: selectedCase.taskProgress.total, + })} +

+
+ + {selectedCase.details.length > 0 && ( +
+

{t('treatmentDetails')}

+
    + {selectedCase.details.map((detail) => ( +
  • +
    {treatmentLabel(detail.treatmentType)}
    +
    + {t('teethLabel')}: {detail.teeth.join(', ') || '—'} +
    + {detail.comment ? ( +
    {detail.comment}
    + ) : null} +
  • + ))} +
+
+ )} + +
+

{t('tasksByTooth')}

+ {selectedCase.tasksByTooth.length === 0 ? ( +

{t('noTasks')}

+ ) : ( + selectedCase.tasksByTooth.map((group) => ( +
+
+ {t('toothGroupTitle', { + tooth: group.tooth, + type: treatmentLabel(group.treatmentType), + })} +
+
    + {group.tasks.map((task) => ( +
  • + + {task.stepOrder}. {task.stepLabel} + + + +
  • + ))} +
+
+ )) + )} +
+
+ )} +
+
+ +
); } diff --git a/frontend/src/lib/api/cases.ts b/frontend/src/lib/api/cases.ts new file mode 100644 index 0000000..fd6f1da --- /dev/null +++ b/frontend/src/lib/api/cases.ts @@ -0,0 +1,36 @@ +import { apiClient } from './client'; +import type { + AssignableMember, + LabCaseDetail, + LabCaseTask, + ListLabCasesParams, + PaginatedLabCases, +} from '@/types/cases'; + +export const casesApi = { + list: async ( + params: ListLabCasesParams = {}, + ): Promise<{ success: boolean; data: PaginatedLabCases }> => { + const response = await apiClient.get('/cases', { params }); + return response.data; + }, + + getOne: async (id: string): Promise<{ success: boolean; data: LabCaseDetail }> => { + const response = await apiClient.get(`/cases/${id}`); + return response.data; + }, + + listAssignableMembers: async (): Promise<{ success: boolean; data: AssignableMember[] }> => { + const response = await apiClient.get('/cases/assignable-members'); + return response.data; + }, + + updateTask: async ( + caseId: string, + taskId: string, + payload: { assigneeUserId?: string | null; status?: LabCaseTask['status'] }, + ): Promise<{ success: boolean; data: LabCaseTask }> => { + const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, payload); + return response.data; + }, +}; diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts new file mode 100644 index 0000000..fed5383 --- /dev/null +++ b/frontend/src/types/cases.ts @@ -0,0 +1,86 @@ +export type LabTaskStatus = 'PENDING' | 'IN_PROGRESS' | 'COMPLETED'; + +export interface LabCaseListItem { + id: string; + sentAt: string | null; + clinic: { id: string; name: string }; + patient: { + id: string; + firstName: string; + lastName: string; + mobile: string; + }; + treatmentTypes: string[]; + taskProgress: { completed: number; total: number }; +} + +export interface LabCaseTask { + id: string; + tooth: string; + treatmentType: string; + stepOrder: number; + stepLabel: string; + status: LabTaskStatus; + assigneeUserId: string | null; + assignee: { id: string; name: string; email: string } | null; +} + +export interface LabCaseTasksByTooth { + tooth: string; + treatmentType: string; + tasks: LabCaseTask[]; +} + +export interface LabCaseDetail { + id: string; + sentAt: string | null; + labComment: string | null; + clinic: { id: string; name: string }; + patient: { + id: string; + firstName: string; + lastName: string; + mobile: string; + }; + appointmentStartAt: string | null; + treatmentTypes: string[]; + details: Array<{ + id: string; + treatmentType: string; + teeth: string[]; + comment: string | null; + }>; + sends: Array<{ + organizationId: string; + organizationName: string; + sentAt: string; + }>; + tasks: LabCaseTask[]; + tasksByTooth: LabCaseTasksByTooth[]; + taskProgress: { completed: number; total: number }; +} + +export interface AssignableMember { + userId: string; + name: string; + email: string; + isOwner: boolean; +} + +export interface ListLabCasesParams { + q?: string; + page?: number; + limit?: number; + clinicOrganizationId?: string; + treatmentType?: string; +} + +export interface PaginatedLabCases { + items: LabCaseListItem[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +}