feature: Phase3 - Task templates + generation on send
This commit is contained in:
@@ -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;
|
||||||
@@ -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");
|
||||||
@@ -23,6 +23,7 @@ model User {
|
|||||||
sessions Session[] // 👈 ADD THIS - opposite relation for Session
|
sessions Session[] // 👈 ADD THIS - opposite relation for Session
|
||||||
sentStaffInvites StaffInvitation[]
|
sentStaffInvites StaffInvitation[]
|
||||||
sentOrganizationInvitations OrganizationInvitation[]
|
sentOrganizationInvitations OrganizationInvitation[]
|
||||||
|
assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee")
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -118,6 +119,12 @@ enum TreatmentStatus {
|
|||||||
COMPLETED
|
COMPLETED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum LabTaskStatus {
|
||||||
|
PENDING
|
||||||
|
IN_PROGRESS
|
||||||
|
COMPLETED
|
||||||
|
}
|
||||||
|
|
||||||
model Treatment {
|
model Treatment {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
organizationId String
|
organizationId String
|
||||||
@@ -154,6 +161,7 @@ model TreatmentDetail {
|
|||||||
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
||||||
attachments TreatmentDetailAttachment[]
|
attachments TreatmentDetailAttachment[]
|
||||||
labCaseLink LabCaseDetail?
|
labCaseLink LabCaseDetail?
|
||||||
|
labCaseTasks LabCaseTask[]
|
||||||
|
|
||||||
@@index([treatmentId, sortOrder])
|
@@index([treatmentId, sortOrder])
|
||||||
@@map("treatment_details")
|
@@map("treatment_details")
|
||||||
@@ -190,6 +198,7 @@ model LabCase {
|
|||||||
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
||||||
details LabCaseDetail[]
|
details LabCaseDetail[]
|
||||||
sends LabCaseSend[]
|
sends LabCaseSend[]
|
||||||
|
tasks LabCaseTask[]
|
||||||
|
|
||||||
@@index([treatmentId, sortOrder])
|
@@index([treatmentId, sortOrder])
|
||||||
@@map("lab_cases")
|
@@map("lab_cases")
|
||||||
@@ -219,6 +228,49 @@ model LabCaseSend {
|
|||||||
@@map("lab_case_sends")
|
@@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 {
|
model Plan {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"
|
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// backend/prisma/seed.ts
|
// backend/prisma/seed.ts
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -123,6 +124,57 @@ async function main() {
|
|||||||
}
|
}
|
||||||
console.log('✅ Created features and permissions');
|
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!');
|
console.log('🌱 Seeding completed successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { StaffModule } from './modules/staff/staff.module';
|
|||||||
import { OrganizationModule } from './modules/organization/organization.module';
|
import { OrganizationModule } from './modules/organization/organization.module';
|
||||||
import { AppointmentsModule } from './modules/appointments/appointments.module';
|
import { AppointmentsModule } from './modules/appointments/appointments.module';
|
||||||
import { TreatmentsModule } from './modules/treatments/treatments.module';
|
import { TreatmentsModule } from './modules/treatments/treatments.module';
|
||||||
|
import { CasesModule } from './modules/cases/cases.module';
|
||||||
|
import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -19,10 +21,12 @@ import { TreatmentsModule } from './modules/treatments/treatments.module';
|
|||||||
load: [configurations],
|
load: [configurations],
|
||||||
}),
|
}),
|
||||||
PrismaModule, // ✅ ADD THIS
|
PrismaModule, // ✅ ADD THIS
|
||||||
|
TreatmentCatalogModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
PatientsModule,
|
PatientsModule,
|
||||||
AppointmentsModule,
|
AppointmentsModule,
|
||||||
TreatmentsModule,
|
TreatmentsModule,
|
||||||
|
CasesModule,
|
||||||
StaffModule,
|
StaffModule,
|
||||||
OrganizationModule,
|
OrganizationModule,
|
||||||
AdminModule.forRoot(),
|
AdminModule.forRoot(),
|
||||||
|
|||||||
25
backend/src/common/guards/lab-org.guard.ts
Normal file
25
backend/src/common/guards/lab-org.guard.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { StaffWorkingHoursService } from '../staff/staff-working-hours.service';
|
|||||||
import { CreateAppointmentDto } from './dto/create-appointment.dto';
|
import { CreateAppointmentDto } from './dto/create-appointment.dto';
|
||||||
import { ListAppointmentsDto } from './dto/list-appointments.dto';
|
import { ListAppointmentsDto } from './dto/list-appointments.dto';
|
||||||
import { UpdateAppointmentDto } from './dto/update-appointment.dto';
|
import { UpdateAppointmentDto } from './dto/update-appointment.dto';
|
||||||
|
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||||
|
|
||||||
const MS_PER_DAY = 86_400_000;
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export class AppointmentsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly staffWorkingHoursService: StaffWorkingHoursService,
|
private readonly staffWorkingHoursService: StaffWorkingHoursService,
|
||||||
|
private readonly treatmentCatalog: TreatmentCatalogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||||
@@ -134,6 +136,7 @@ export class AppointmentsService {
|
|||||||
|
|
||||||
await this.ensurePatientInOrg(dto.patientId, organizationId);
|
await this.ensurePatientInOrg(dto.patientId, organizationId);
|
||||||
await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId);
|
await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId);
|
||||||
|
this.treatmentCatalog.assertKnownTreatmentType(dto.purpose);
|
||||||
await this.ensureAppointmentWithinProviderWorkingHours(
|
await this.ensureAppointmentWithinProviderWorkingHours(
|
||||||
dto.providerUserId,
|
dto.providerUserId,
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -195,6 +198,8 @@ export class AppointmentsService {
|
|||||||
const providerUserId = dto.providerUserId ?? existing.providerUserId;
|
const providerUserId = dto.providerUserId ?? existing.providerUserId;
|
||||||
const purpose = dto.purpose ?? existing.purpose;
|
const purpose = dto.purpose ?? existing.purpose;
|
||||||
|
|
||||||
|
this.treatmentCatalog.assertKnownTreatmentType(purpose);
|
||||||
|
|
||||||
await this.ensurePatientInOrg(patientId, organizationId);
|
await this.ensurePatientInOrg(patientId, organizationId);
|
||||||
await this.ensureProviderIsTreatmentEditor(providerUserId, organizationId);
|
await this.ensureProviderIsTreatmentEditor(providerUserId, organizationId);
|
||||||
await this.ensureAppointmentWithinProviderWorkingHours(
|
await this.ensureAppointmentWithinProviderWorkingHours(
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsDateString, IsIn, IsUUID } from 'class-validator';
|
import { IsDateString, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
const APPOINTMENT_PURPOSES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const;
|
|
||||||
|
|
||||||
export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number];
|
|
||||||
|
|
||||||
export class CreateAppointmentDto {
|
export class CreateAppointmentDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -22,7 +18,11 @@ export class CreateAppointmentDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
endAt: string;
|
endAt: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: APPOINTMENT_PURPOSES })
|
@ApiProperty({
|
||||||
@IsIn([...APPOINTMENT_PURPOSES])
|
description: 'Treatment type code from the treatment catalog',
|
||||||
purpose: AppointmentPurpose;
|
example: 'consultation',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
purpose: string;
|
||||||
}
|
}
|
||||||
|
|||||||
56
backend/src/modules/cases/cases.controller.ts
Normal file
56
backend/src/modules/cases/cases.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/src/modules/cases/cases.module.ts
Normal file
11
backend/src/modules/cases/cases.module.ts
Normal file
@@ -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 {}
|
||||||
405
backend/src/modules/cases/cases.service.ts
Normal file
405
backend/src/modules/cases/cases.service.ts
Normal file
@@ -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<CasesService['mapTask']>[];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
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 } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/src/modules/cases/dto/cases.dto.ts
Normal file
41
backend/src/modules/cases/dto/cases.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
91
backend/src/modules/cases/lab-case-task.generator.ts
Normal file
91
backend/src/modules/cases/lab-case-task.generator.ts
Normal file
@@ -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<number> {
|
||||||
|
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<string, { stepOrder: number; label: string }[]>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<string, TreatmentTypeCatalogEntry>();
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsIn,
|
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
@@ -10,8 +9,6 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
const TREATMENT_TYPES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const;
|
|
||||||
|
|
||||||
export class SaveTreatmentDetailDto {
|
export class SaveTreatmentDetailDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(64)
|
@MaxLength(64)
|
||||||
@@ -21,7 +18,8 @@ export class SaveTreatmentDetailDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
||||||
@IsIn(TREATMENT_TYPES)
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
treatmentType: string;
|
treatmentType: string;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import { TreatmentStatus } from '@prisma/client';
|
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([
|
const FDI_TOOTH_IDS = new Set([
|
||||||
'11', '12', '13', '14', '15', '16', '17', '18',
|
'11', '12', '13', '14', '15', '16', '17', '18',
|
||||||
'21', '22', '23', '24', '25', '26', '27', '28',
|
'21', '22', '23', '24', '25', '26', '27', '28',
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import { createReadStream, existsSync, mkdirSync } from 'fs';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
|
||||||
|
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||||
import {
|
import {
|
||||||
SaveTreatmentDraftDto,
|
SaveTreatmentDraftDto,
|
||||||
SaveTreatmentLabCasesDto,
|
SaveTreatmentLabCasesDto,
|
||||||
} from './dto/treatment.dto';
|
} from './dto/treatment.dto';
|
||||||
import {
|
import {
|
||||||
generateTreatmentTitle,
|
generateTreatmentTitle,
|
||||||
isTreatmentType,
|
|
||||||
mapTreatmentStatusForApi,
|
mapTreatmentStatusForApi,
|
||||||
normalizeTeeth,
|
normalizeTeeth,
|
||||||
} from './treatment.utils';
|
} from './treatment.utils';
|
||||||
@@ -61,7 +62,10 @@ const treatmentInclude = {
|
|||||||
export class TreatmentsService {
|
export class TreatmentsService {
|
||||||
private readonly uploadRoot = join(process.cwd(), 'uploads', 'treatments');
|
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 }) {
|
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||||
if (!user?.organizationId) {
|
if (!user?.organizationId) {
|
||||||
@@ -163,9 +167,7 @@ export class TreatmentsService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const d of dto.details) {
|
for (const d of dto.details) {
|
||||||
if (!isTreatmentType(d.treatmentType)) {
|
this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType);
|
||||||
throw new BadRequestException(`Invalid treatment type: ${d.treatmentType}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedDetails = dto.details.map((d, index) => ({
|
const normalizedDetails = dto.details.map((d, index) => ({
|
||||||
@@ -328,12 +330,16 @@ export class TreatmentsService {
|
|||||||
|
|
||||||
const details = await this.prisma.treatmentDetail.findMany({
|
const details = await this.prisma.treatmentDetail.findMany({
|
||||||
where: { treatmentId: treatment.id, id: { in: detailIds } },
|
where: { treatmentId: treatment.id, id: { in: detailIds } },
|
||||||
select: { id: true },
|
select: { id: true, treatmentType: true },
|
||||||
});
|
});
|
||||||
if (details.length !== uniqueDetailIds.size) {
|
if (details.length !== uniqueDetailIds.size) {
|
||||||
throw new BadRequestException('One or more treatment details were not found');
|
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);
|
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
|
||||||
|
|
||||||
for (const lc of dto.labCases) {
|
for (const lc of dto.labCases) {
|
||||||
@@ -470,6 +476,8 @@ export class TreatmentsService {
|
|||||||
data: { sentAt: now },
|
data: { sentAt: now },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await generateLabCaseTasks(tx, labCaseId);
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
|
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
|
||||||
|
|||||||
@@ -317,7 +317,26 @@
|
|||||||
},
|
},
|
||||||
"cases": {
|
"cases": {
|
||||||
"title": "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": {
|
"appointments": {
|
||||||
"title": "Appointments",
|
"title": "Appointments",
|
||||||
|
|||||||
@@ -317,7 +317,26 @@
|
|||||||
},
|
},
|
||||||
"cases": {
|
"cases": {
|
||||||
"title": "پروندهها",
|
"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": {
|
"appointments": {
|
||||||
"title": "نوبتها",
|
"title": "نوبتها",
|
||||||
|
|||||||
@@ -317,7 +317,26 @@
|
|||||||
},
|
},
|
||||||
"cases": {
|
"cases": {
|
||||||
"title": "Dossiers",
|
"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": {
|
"appointments": {
|
||||||
"title": "Afspraken",
|
"title": "Afspraken",
|
||||||
|
|||||||
@@ -1,14 +1,315 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
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() {
|
export default function CasesPage() {
|
||||||
const t = useTranslations('cases');
|
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<LabCaseListItem[]>([]);
|
||||||
|
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||||
|
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
|
||||||
|
const [members, setMembers] = useState<AssignableMember[]>([]);
|
||||||
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
|
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
<div>
|
||||||
<p className="text-sm text-text-muted max-w-xl">{t('stubDescription')}</p>
|
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
||||||
|
<p className="text-sm text-text-muted mt-1">{t('subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[minmax(280px,360px)_1fr]">
|
||||||
|
<section className="rounded-lg border border-border bg-surface p-4 space-y-3">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t('searchPlaceholder')}
|
||||||
|
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loadingList ? (
|
||||||
|
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
|
||||||
|
) : cases.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-muted">{t('emptyList')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2 max-h-[70vh] overflow-y-auto">
|
||||||
|
{cases.map((item) => {
|
||||||
|
const isActive = item.id === selectedCaseId;
|
||||||
|
const progress =
|
||||||
|
item.taskProgress.total > 0
|
||||||
|
? `${item.taskProgress.completed}/${item.taskProgress.total}`
|
||||||
|
: '0/0';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedCaseId(item.id)}
|
||||||
|
className={`w-full rounded-md border px-3 py-2 text-left transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-text-primary">
|
||||||
|
{formatPatientName(item.patient)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-muted mt-0.5">{item.clinic.name}</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-text-muted mt-1">
|
||||||
|
<span>{formatDateTime(item.sentAt, locale)}</span>
|
||||||
|
<span>{t('taskProgressShort', { progress })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-muted mt-1 truncate">
|
||||||
|
{item.treatmentTypes.map(treatmentLabel).join(', ')}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-border bg-surface p-4 min-h-[420px]">
|
||||||
|
{!selectedCaseId ? (
|
||||||
|
<p className="text-sm text-text-muted">{t('selectCaseHint')}</p>
|
||||||
|
) : loadingDetail || !selectedCase ? (
|
||||||
|
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<header className="space-y-1 border-b border-border pb-3">
|
||||||
|
<h2 className="text-lg font-semibold text-text-primary">
|
||||||
|
{formatPatientName(selectedCase.patient)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
{t('fromClinic', { name: selectedCase.clinic.name })}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
{t('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
{t('taskProgressLabel', {
|
||||||
|
completed: selectedCase.taskProgress.completed,
|
||||||
|
total: selectedCase.taskProgress.total,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{selectedCase.details.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium text-text-primary">{t('treatmentDetails')}</h3>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{selectedCase.details.map((detail) => (
|
||||||
|
<li key={detail.id} className="rounded-md bg-background border border-border p-2">
|
||||||
|
<div className="font-medium">{treatmentLabel(detail.treatmentType)}</div>
|
||||||
|
<div className="text-text-muted">
|
||||||
|
{t('teethLabel')}: {detail.teeth.join(', ') || '—'}
|
||||||
|
</div>
|
||||||
|
{detail.comment ? (
|
||||||
|
<div className="text-text-muted mt-1">{detail.comment}</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-text-primary">{t('tasksByTooth')}</h3>
|
||||||
|
{selectedCase.tasksByTooth.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-muted">{t('noTasks')}</p>
|
||||||
|
) : (
|
||||||
|
selectedCase.tasksByTooth.map((group) => (
|
||||||
|
<div
|
||||||
|
key={`${group.tooth}-${group.treatmentType}`}
|
||||||
|
className="rounded-md border border-border p-3 space-y-2"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-text-primary">
|
||||||
|
{t('toothGroupTitle', {
|
||||||
|
tooth: group.tooth,
|
||||||
|
type: treatmentLabel(group.treatmentType),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{group.tasks.map((task) => (
|
||||||
|
<li
|
||||||
|
key={task.id}
|
||||||
|
className="grid gap-2 sm:grid-cols-[1fr_160px_180px] items-center text-sm rounded bg-background p-2"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{task.stepOrder}. {task.stepLabel}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={task.status}
|
||||||
|
disabled={!canEdit || updatingTaskId === task.id}
|
||||||
|
onChange={(e) =>
|
||||||
|
void handleTaskUpdate(task.id, {
|
||||||
|
status: e.target.value as LabTaskStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded border border-border bg-surface px-2 py-1 text-sm disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{statusOptions.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={task.assigneeUserId ?? ''}
|
||||||
|
disabled={!canEdit || updatingTaskId === task.id}
|
||||||
|
onChange={(e) =>
|
||||||
|
void handleTaskUpdate(task.id, {
|
||||||
|
assigneeUserId: e.target.value || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded border border-border bg-surface px-2 py-1 text-sm disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<option value="">{t('unassigned')}</option>
|
||||||
|
{members.map((member) => (
|
||||||
|
<option key={member.userId} value={member.userId}>
|
||||||
|
{member.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToastStack {...toast.messages} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
36
frontend/src/lib/api/cases.ts
Normal file
36
frontend/src/lib/api/cases.ts
Normal file
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
86
frontend/src/types/cases.ts
Normal file
86
frontend/src/types/cases.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user