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
|
||||
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"
|
||||
|
||||
@@ -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!');
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
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 { 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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "نوبتها",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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 (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
||||
<p className="text-sm text-text-muted max-w-xl">{t('stubDescription')}</p>
|
||||
<div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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