feature: Phase3 - Task templates + generation on send

This commit is contained in:
2026-06-28 16:46:42 +03:30
parent 8b4ef6195d
commit 21f545ebdb
25 changed files with 1448 additions and 31 deletions

View File

@@ -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;

View File

@@ -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");

View File

@@ -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"

View File

@@ -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!');
}

View File

@@ -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(),

View 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;
}
}

View File

@@ -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(

View File

@@ -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;
}

View 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);
}
}

View 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 {}

View 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 } } },
});
}
}

View 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;
}

View 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;
}

View File

@@ -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(),
};
}
}

View File

@@ -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 {}

View File

@@ -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');
}
}
}

View File

@@ -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()

View File

@@ -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',

View File

@@ -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({

View File

@@ -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",

View File

@@ -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": "نوبت‌ها",

View File

@@ -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",

View File

@@ -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>
);
}

View 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;
},
};

View 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;
};
}