diff --git a/backend/package.json b/backend/package.json index ed0e131..25196c6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,7 +22,8 @@ "prisma:migrate": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy", "prisma:seed": "prisma db seed", - "prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts" + "prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts", + "prisma:regenerate-tasks": "ts-node prisma/regenerate-lab-tasks.ts" }, "prisma": { "seed": "ts-node prisma/seed.ts" diff --git a/backend/prisma/migrations/20260707130000_lab_workflow_refactor/migration.sql b/backend/prisma/migrations/20260707130000_lab_workflow_refactor/migration.sql new file mode 100644 index 0000000..a2e2384 --- /dev/null +++ b/backend/prisma/migrations/20260707130000_lab_workflow_refactor/migration.sql @@ -0,0 +1,83 @@ +-- Lab workflow refactor: remove task assignment/priority, group tasks by prosthesis, +-- add importance flag, status timeline, and per-case comments. +-- Local dev data only: existing tasks are truncated and regenerated on next dispatch/send. + +-- 1. Clear existing task data (task shape changes: tooth -> teeth[]). +TRUNCATE TABLE "lab_case_tasks" CASCADE; + +-- 2. Drop assignment / priority machinery. +ALTER TABLE "lab_case_tasks" DROP CONSTRAINT IF EXISTS "lab_case_tasks_assigneeUserId_fkey"; +DROP INDEX IF EXISTS "lab_case_tasks_assigneeUserId_priority_createdAt_idx"; +DROP INDEX IF EXISTS "lab_case_tasks_assignedAt_labCaseId_priority_idx"; +DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_tooth_stepOrder_key"; + +ALTER TABLE "lab_case_tasks" + DROP COLUMN IF EXISTS "assigneeUserId", + DROP COLUMN IF EXISTS "assignedAt", + DROP COLUMN IF EXISTS "priority", + DROP COLUMN IF EXISTS "tooth"; + +-- 3. Rebuild LabTaskStatus enum without PENDING. +ALTER TABLE "lab_case_tasks" ALTER COLUMN "status" DROP DEFAULT; +ALTER TYPE "LabTaskStatus" RENAME TO "LabTaskStatus_old"; +CREATE TYPE "LabTaskStatus" AS ENUM ('IN_PROGRESS', 'COMPLETED'); +ALTER TABLE "lab_case_tasks" + ALTER COLUMN "status" TYPE "LabTaskStatus" USING ("status"::text::"LabTaskStatus"); +ALTER TABLE "lab_case_tasks" ALTER COLUMN "status" SET DEFAULT 'IN_PROGRESS'; +DROP TYPE "LabTaskStatus_old"; + +-- 4. New task columns. +ALTER TABLE "lab_case_tasks" ADD COLUMN "teeth" JSONB NOT NULL DEFAULT '[]'; +ALTER TABLE "lab_case_tasks" ALTER COLUMN "teeth" DROP DEFAULT; +ALTER TABLE "lab_case_tasks" ADD COLUMN "isImportant" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "lab_case_tasks" ADD COLUMN "lastStatusChangedByUserId" TEXT; +ALTER TABLE "lab_case_tasks" ADD COLUMN "lastStatusChangedAt" TIMESTAMP(3); + +-- 5. New unique + indexes. +CREATE UNIQUE INDEX "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_stepOrder_key" + ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "prosthesisTypeCode", "stepOrder"); +CREATE INDEX "lab_case_tasks_labCaseId_isImportant_idx" + ON "lab_case_tasks"("labCaseId", "isImportant"); + +ALTER TABLE "lab_case_tasks" ADD CONSTRAINT "lab_case_tasks_lastStatusChangedByUserId_fkey" + FOREIGN KEY ("lastStatusChangedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- 6. Task status timeline. +CREATE TABLE "lab_case_task_status_events" ( + "id" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "fromStatus" "LabTaskStatus", + "toStatus" "LabTaskStatus" NOT NULL, + "changedByUserId" TEXT, + "changedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "lab_case_task_status_events_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "lab_case_task_status_events_taskId_changedAt_idx" + ON "lab_case_task_status_events"("taskId", "changedAt"); +ALTER TABLE "lab_case_task_status_events" ADD CONSTRAINT "lab_case_task_status_events_taskId_fkey" + FOREIGN KEY ("taskId") REFERENCES "lab_case_tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "lab_case_task_status_events" ADD CONSTRAINT "lab_case_task_status_events_changedByUserId_fkey" + FOREIGN KEY ("changedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- 7. Per-case comments. +CREATE TYPE "LabCaseCommentSide" AS ENUM ('LAB', 'CLINIC'); +CREATE TABLE "lab_case_comments" ( + "id" TEXT NOT NULL, + "labCaseId" TEXT NOT NULL, + "authorUserId" TEXT, + "authorOrganizationId" TEXT, + "authorSide" "LabCaseCommentSide" NOT NULL, + "body" TEXT NOT NULL, + "visibleToClinic" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "lab_case_comments_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "lab_case_comments_labCaseId_createdAt_idx" + ON "lab_case_comments"("labCaseId", "createdAt"); +ALTER TABLE "lab_case_comments" ADD CONSTRAINT "lab_case_comments_labCaseId_fkey" + FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "lab_case_comments" ADD CONSTRAINT "lab_case_comments_authorUserId_fkey" + FOREIGN KEY ("authorUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "lab_case_comments" ADD CONSTRAINT "lab_case_comments_authorOrganizationId_fkey" + FOREIGN KEY ("authorOrganizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/regenerate-lab-tasks.ts b/backend/prisma/regenerate-lab-tasks.ts new file mode 100644 index 0000000..31cf1ae --- /dev/null +++ b/backend/prisma/regenerate-lab-tasks.ts @@ -0,0 +1,60 @@ +/** + * Dev-only: regenerate lab case tasks from existing LabCaseToothProsthesis rows. + * Usage: npx ts-node prisma/regenerate-lab-tasks.ts + * + * The lab workflow refactor truncated lab_case_tasks. This rebuilds task sets + * (grouped by treatment detail + prosthesis type, one set per workflow step) + * for every already-sent case that still has prosthesis selections. + */ +import { PrismaClient } from '@prisma/client'; +import { config } from 'dotenv'; +import path from 'path'; +import { generateLabCaseTasks } from '../src/modules/cases/lab-case-task.generator'; + +const envPath = path.join(__dirname, '..', '.env'); +config({ path: envPath }); + +if (process.env.NODE_ENV === 'production') { + console.error('regenerate-lab-tasks is not allowed in production'); + process.exit(1); +} + +const prisma = new PrismaClient(); + +async function main() { + const cases = await prisma.labCase.findMany({ + where: { + sentAt: { not: null }, + toothProsthesis: { some: {} }, + }, + select: { + id: true, + treatment: { select: { organization: { select: { owner: { select: { language: true } } } } } }, + }, + }); + + console.log(`Regenerating tasks for ${cases.length} sent case(s)...`); + + let total = 0; + for (const labCase of cases) { + const locale = labCase.treatment.organization.owner.language ?? 'en'; + // Clear any stale tasks first so the generator's "already exists" guard passes. + await prisma.labCaseTask.deleteMany({ where: { labCaseId: labCase.id } }); + const created = await prisma.$transaction((tx) => + generateLabCaseTasks(tx, labCase.id, locale), + ); + total += created; + console.log(` - ${labCase.id}: ${created} task(s)`); + } + + console.log(`Done. ${total} task(s) created.`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/prisma/reset-treatment-data.ts b/backend/prisma/reset-treatment-data.ts index 6bc6387..1f3bfa5 100644 --- a/backend/prisma/reset-treatment-data.ts +++ b/backend/prisma/reset-treatment-data.ts @@ -21,6 +21,8 @@ const prisma = new PrismaClient(); // FK-safe order: children before parents. const TABLES_IN_ORDER = [ + 'lab_case_task_status_events', + 'lab_case_comments', 'lab_case_tasks', 'lab_case_sends', 'lab_case_tooth_prosthesis', diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f076995..c317f39 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -23,7 +23,9 @@ model User { sessions Session[] // 👈 ADD THIS - opposite relation for Session sentStaffInvites StaffInvitation[] sentOrganizationInvitations OrganizationInvitation[] - assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee") + statusChangedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskLastStatusChangedBy") + labCaseTaskStatusEvents LabCaseTaskStatusEvent[] + labCaseComments LabCaseComment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -65,6 +67,7 @@ model Organization { appointments Appointment[] treatments Treatment[] labCaseSends LabCaseSend[] + labCaseComments LabCaseComment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -115,11 +118,15 @@ model Appointment { } enum LabTaskStatus { - PENDING IN_PROGRESS COMPLETED } +enum LabCaseCommentSide { + LAB + CLINIC +} + model Treatment { id String @id @default(uuid()) organizationId String @@ -194,6 +201,7 @@ model LabCase { sends LabCaseSend[] tasks LabCaseTask[] toothProsthesis LabCaseToothProsthesis[] + comments LabCaseComment[] @@index([treatmentId, sortOrder]) @@map("lab_cases") @@ -306,31 +314,66 @@ model LabCaseTask { id String @id @default(uuid()) labCaseId String treatmentDetailId String - tooth String + teeth Json treatmentType String prosthesisTypeCode String workflowStepCode String stepOrder Int stepLabel String - assigneeUserId String? - assignedAt DateTime? - priority Int @default(3) - status LabTaskStatus @default(PENDING) + isImportant Boolean @default(false) + status LabTaskStatus @default(IN_PROGRESS) + lastStatusChangedByUserId String? + lastStatusChangedAt DateTime? 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) + lastStatusChangedBy User? @relation("LabCaseTaskLastStatusChangedBy", fields: [lastStatusChangedByUserId], references: [id], onDelete: SetNull) + statusEvents LabCaseTaskStatusEvent[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([labCaseId, treatmentDetailId, tooth, stepOrder]) + @@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder]) @@index([labCaseId, status]) - @@index([assigneeUserId, priority, createdAt]) - @@index([assignedAt, labCaseId, priority]) + @@index([labCaseId, isImportant]) @@map("lab_case_tasks") } +model LabCaseTaskStatusEvent { + id String @id @default(uuid()) + taskId String + fromStatus LabTaskStatus? + toStatus LabTaskStatus + changedByUserId String? + changedAt DateTime @default(now()) + + task LabCaseTask @relation(fields: [taskId], references: [id], onDelete: Cascade) + changedBy User? @relation(fields: [changedByUserId], references: [id], onDelete: SetNull) + + @@index([taskId, changedAt]) + @@map("lab_case_task_status_events") +} + +model LabCaseComment { + id String @id @default(uuid()) + labCaseId String + authorUserId String? + authorOrganizationId String? + authorSide LabCaseCommentSide + body String + visibleToClinic Boolean @default(false) + + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + authorUser User? @relation(fields: [authorUserId], references: [id], onDelete: SetNull) + authorOrganization Organization? @relation(fields: [authorOrganizationId], references: [id], onDelete: SetNull) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([labCaseId, createdAt]) + @@map("lab_case_comments") +} + model Plan { id String @id @default(uuid()) name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise" diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6c2aab6..11390d4 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -16,6 +16,7 @@ import { TasksModule } from './modules/tasks/tasks.module'; import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module'; import { CatalogModule } from './modules/catalog/catalog.module'; import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module'; +import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module'; @Module({ imports: [ @@ -33,6 +34,7 @@ import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis TreatmentsModule, CasesModule, TasksModule, + LabCaseCommentsModule, StaffModule, OrganizationModule, AdminModule.forRoot(), diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts index 58ec144..00ecae5 100644 --- a/backend/src/modules/cases/cases.controller.ts +++ b/backend/src/modules/cases/cases.controller.ts @@ -35,13 +35,6 @@ export class CasesController { return this.casesService.listFilterOptions(organizationId, req.user.id); } - @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) { @@ -50,7 +43,7 @@ export class CasesController { } @Patch(':id/tasks/:taskId') - @ApiOperation({ summary: 'Update task assignee or priority' }) + @ApiOperation({ summary: 'Toggle task important flag' }) updateTask( @Param('id') id: string, @Param('taskId') taskId: string, diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 733a478..f873ccb 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -14,6 +14,7 @@ import { import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { normalizeTeeth } from '../treatments/treatment.utils'; import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto'; +import { normalizeTaskTeeth } from './lab-case-task.util'; const labCaseListInclude = { treatment: { @@ -41,16 +42,29 @@ const labCaseListInclude = { }, tasks: { orderBy: [ - { tooth: 'asc' as const }, - { treatmentType: 'asc' as const }, + { treatmentDetailId: 'asc' as const }, + { prosthesisTypeCode: 'asc' as const }, { stepOrder: 'asc' as const }, ], include: { - assignee: { select: { id: true, name: true, email: true } }, + lastStatusChangedBy: { select: { id: true, name: true } }, + statusEvents: { + orderBy: { changedAt: 'asc' as const }, + include: { changedBy: { select: { id: true, name: true } } }, + }, }, }, } satisfies Prisma.LabCaseInclude; +type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{ + include: { + lastStatusChangedBy: { select: { id: true; name: true } }; + statusEvents: { + include: { changedBy: { select: { id: true; name: true } } }; + }; + }; +}>; + @Injectable() export class CasesService { constructor( @@ -294,23 +308,15 @@ export class CasesService { throw new NotFoundException('Task not found'); } - if (dto.assigneeUserId !== undefined && dto.assigneeUserId !== null) { - await this.ensureAssignableMember(dto.assigneeUserId, labOrganizationId); - } - const updated = await this.prisma.labCaseTask.update({ where: { id: taskId }, - data: { - ...(dto.assigneeUserId !== undefined - ? { - assigneeUserId: dto.assigneeUserId, - assignedAt: dto.assigneeUserId === null ? null : new Date(), - } - : {}), - ...(dto.priority !== undefined ? { priority: dto.priority } : {}), - }, + data: { isImportant: dto.isImportant }, include: { - assignee: { select: { id: true, name: true, email: true } }, + lastStatusChangedBy: { select: { id: true, name: true } }, + statusEvents: { + orderBy: { changedAt: 'asc' }, + include: { changedBy: { select: { id: true, name: true } } }, + }, }, }); @@ -324,35 +330,6 @@ export class CasesService { return { success: true, data: this.mapTask(updated, prosthesisLabels) }; } - 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 } }, - permissions: { include: { permission: true } }, - }, - orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }], - }); - - return { - success: true, - data: memberships - .filter((m) => { - if (m.isOwner) return true; - const names = m.permissions.map((p) => p.permission.name); - return names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT'); - }) - .map((m) => ({ - userId: m.user.id, - name: m.user.name, - email: m.user.email, - isOwner: m.isOwner, - })), - }; - } - private buildListWhere( labOrganizationId: string, query: ListLabCasesDto, @@ -465,7 +442,7 @@ export class CasesService { prosthesisCodes, locale, ); - const tasksByTooth = this.groupTasksByTooth(lc.tasks, prosthesisLabels); + const tasksByTooth = this.groupTasks(lc.tasks, prosthesisLabels); return { id: lc.id, @@ -495,27 +472,15 @@ export class CasesService { }; } - private groupTasksByTooth( - tasks: Array<{ - id: string; - tooth: string; - treatmentType: string; - prosthesisTypeCode: string; - stepOrder: number; - stepLabel: string; - status: LabTaskStatus; - priority: number; - assigneeUserId: string | null; - assignedAt: Date | null; - createdAt: Date; - assignee: { id: string; name: string; email: string } | null; - }>, + private groupTasks( + tasks: LabCaseTaskWithRelations[], prosthesisLabels: Map, ) { const groups = new Map< string, { - tooth: string; + treatmentDetailId: string; + teeth: string[]; treatmentType: string; prosthesisTypeCode: string; prosthesisTypeLabel: string; @@ -524,9 +489,10 @@ export class CasesService { >(); for (const task of tasks) { - const key = `${task.tooth}:${task.treatmentType}:${task.prosthesisTypeCode}`; + const key = `${task.treatmentDetailId}:${task.prosthesisTypeCode}`; const entry = groups.get(key) ?? { - tooth: task.tooth, + treatmentDetailId: task.treatmentDetailId, + teeth: normalizeTaskTeeth(task.teeth), treatmentType: task.treatmentType, prosthesisTypeCode: task.prosthesisTypeCode, prosthesisTypeLabel: @@ -541,26 +507,13 @@ export class CasesService { } private mapTask( - task: { - id: string; - tooth: string; - treatmentType: string; - prosthesisTypeCode: string; - workflowStepCode?: string; - stepOrder: number; - stepLabel: string; - status: LabTaskStatus; - priority: number; - assigneeUserId: string | null; - assignedAt: Date | null; - createdAt: Date; - assignee: { id: string; name: string; email: string } | null; - }, + task: LabCaseTaskWithRelations, prosthesisLabels: Map, ) { return { id: task.id, - tooth: task.tooth, + treatmentDetailId: task.treatmentDetailId, + teeth: normalizeTaskTeeth(task.teeth), treatmentType: task.treatmentType, prosthesisTypeCode: task.prosthesisTypeCode, prosthesisTypeLabel: @@ -569,32 +522,24 @@ export class CasesService { stepOrder: task.stepOrder, stepLabel: task.stepLabel, status: task.status, - priority: task.priority, - assignedAt: task.assignedAt?.toISOString() ?? null, + isImportant: task.isImportant, createdAt: task.createdAt.toISOString(), - assigneeUserId: task.assigneeUserId, - assignee: task.assignee - ? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email } + lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null, + lastStatusChangedBy: task.lastStatusChangedBy + ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name } : null, + timeline: task.statusEvents.map((event) => ({ + id: event.id, + fromStatus: event.fromStatus, + toStatus: event.toStatus, + changedAt: event.changedAt.toISOString(), + changedBy: event.changedBy + ? { id: event.changedBy.id, name: event.changedBy.name } + : null, + })), }; } - private async ensureAssignableMember(userId: string, labOrganizationId: string) { - const membership = await this.prisma.membership.findFirst({ - where: { userId, organizationId: labOrganizationId, isActive: true }, - include: { permissions: { include: { permission: true } } }, - }); - if (!membership) { - throw new BadRequestException('Assignee must be an active member of this lab'); - } - if (membership.isOwner) return; - const names = membership.permissions.map((p) => p.permission.name); - if (names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT')) { - return; - } - throw new BadRequestException('Assignee must have access to the Tasks tab'); - } - private async assertCanReadCases(userId: string, organizationId: string) { const m = await this.getMembership(userId, organizationId); if (!m) { diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts index e7ca9bb..6e567fd 100644 --- a/backend/src/modules/cases/dto/cases.dto.ts +++ b/backend/src/modules/cases/dto/cases.dto.ts @@ -1,18 +1,9 @@ import { Transform } from 'class-transformer'; -import { IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator'; +import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; export class UpdateLabCaseTaskDto { - @IsOptional() - @ValidateIf((_, value) => value !== null) - @IsUUID() - assigneeUserId?: string | null; - - @IsOptional() - @Transform(({ value }) => Number(value)) - @IsInt() - @Min(1) - @Max(5) - priority?: number; + @IsBoolean() + isImportant: boolean; } export class ListLabCasesDto { diff --git a/backend/src/modules/cases/lab-case-task.generator.spec.ts b/backend/src/modules/cases/lab-case-task.generator.spec.ts index 56d7350..31abb0e 100644 --- a/backend/src/modules/cases/lab-case-task.generator.spec.ts +++ b/backend/src/modules/cases/lab-case-task.generator.spec.ts @@ -101,10 +101,11 @@ describe('generateLabCaseTasks', () => { expect(count).toBe(pfmSteps.length); expect(created).toHaveLength(pfmSteps.length); expect(created[0]).toMatchObject({ - tooth: '14', + teeth: ['14'], prosthesisTypeCode: 'pfm_crown', workflowStepCode: 'intraoral_scan', stepLabel: 'Intraoral Scan', + status: 'IN_PROGRESS', }); const stepCodes = (created as Array<{ workflowStepCode: string }>).map( (row) => row.workflowStepCode, @@ -115,6 +116,35 @@ describe('generateLabCaseTasks', () => { expect(stepCodes).toContain('milling_wet'); }); + it('groups teeth sharing a prosthesis in one detail, and keeps other prosthesis separate', async () => { + const pfmSteps = stepsFromSeed('pfm_crown'); + const zirconiaSteps = stepsFromSeed('monolithic_zirconia'); + const { tx, created } = buildMockTx({ + toothProsthesisRows: [ + { treatmentDetailId: 'detail-1', tooth: '15', prosthesisTypeCode: 'pfm_crown' }, + { treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'pfm_crown' }, + { treatmentDetailId: 'detail-1', tooth: '16', prosthesisTypeCode: 'monolithic_zirconia' }, + ], + prosthesisTypes: [ + { code: 'pfm_crown', steps: pfmSteps }, + { code: 'monolithic_zirconia', steps: zirconiaSteps }, + ], + }); + + const count = await generateLabCaseTasks(tx as never, 'lab-case-group', 'en'); + + expect(count).toBe(pfmSteps.length + zirconiaSteps.length); + const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>; + const pfmRows = rows.filter((r) => r.prosthesisTypeCode === 'pfm_crown'); + const zirconiaRows = rows.filter((r) => r.prosthesisTypeCode === 'monolithic_zirconia'); + + expect(pfmRows).toHaveLength(pfmSteps.length); + expect(zirconiaRows).toHaveLength(zirconiaSteps.length); + // Teeth sharing the prosthesis in the same detail are merged and sorted. + expect(pfmRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15']))).toBe(true); + expect(zirconiaRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['16']))).toBe(true); + }); + it('omits packing and shipping for smile_design', async () => { const smileSteps = stepsFromSeed('smile_design'); const { tx, created } = buildMockTx({ diff --git a/backend/src/modules/cases/lab-case-task.generator.ts b/backend/src/modules/cases/lab-case-task.generator.ts index bac6c3e..3279291 100644 --- a/backend/src/modules/cases/lab-case-task.generator.ts +++ b/backend/src/modules/cases/lab-case-task.generator.ts @@ -58,25 +58,46 @@ export async function generateLabCaseTasks( const stepLabels = await resolveStepLabels(tx, allStepCodes, locale); - const taskRows: Prisma.LabCaseTaskCreateManyInput[] = []; + // Group teeth that share the same (treatment detail + prosthesis type): one task set + // per group, with each step covering every tooth in that group. + const groups = new Map< + string, + { treatmentDetailId: string; treatmentType: string; prosthesisTypeCode: string; teeth: string[] } + >(); for (const row of toothProsthesisRows) { - const typeSteps = stepsByProsthesisCode.get(row.prosthesisTypeCode) ?? []; + const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`; + const group = groups.get(key) ?? { + treatmentDetailId: row.treatmentDetailId, + treatmentType: row.detail.treatmentType, + prosthesisTypeCode: row.prosthesisTypeCode, + teeth: [], + }; + group.teeth.push(row.tooth); + groups.set(key, group); + } + + const taskRows: Prisma.LabCaseTaskCreateManyInput[] = []; + + for (const group of groups.values()) { + const typeSteps = stepsByProsthesisCode.get(group.prosthesisTypeCode) ?? []; if (typeSteps.length === 0) { continue; } + const teeth = sortTeeth(group.teeth); + for (const step of typeSteps) { taskRows.push({ labCaseId, - treatmentDetailId: row.treatmentDetailId, - tooth: row.tooth, - treatmentType: row.detail.treatmentType, - prosthesisTypeCode: row.prosthesisTypeCode, + treatmentDetailId: group.treatmentDetailId, + teeth, + treatmentType: group.treatmentType, + prosthesisTypeCode: group.prosthesisTypeCode, workflowStepCode: step.workflowStepCode, stepOrder: step.stepOrder, stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode, - status: LabTaskStatus.PENDING, + status: LabTaskStatus.IN_PROGRESS, }); } } @@ -89,6 +110,15 @@ export async function generateLabCaseTasks( return taskRows.length; } +function sortTeeth(teeth: string[]): string[] { + return [...new Set(teeth)].sort((a, b) => { + const na = Number(a); + const nb = Number(b); + if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb; + return a.localeCompare(b); + }); +} + async function resolveStepLabels( tx: TransactionClient, stepCodes: string[], diff --git a/backend/src/modules/cases/lab-case-task.util.ts b/backend/src/modules/cases/lab-case-task.util.ts new file mode 100644 index 0000000..b445412 --- /dev/null +++ b/backend/src/modules/cases/lab-case-task.util.ts @@ -0,0 +1,11 @@ +import { Prisma } from '@prisma/client'; + +/** Normalize the JSON `teeth` column of a lab case task into a clean string[]. */ +export function normalizeTaskTeeth(value: Prisma.JsonValue | null | undefined): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .filter((v): v is string | number => typeof v === 'string' || typeof v === 'number') + .map((v) => String(v)); +} diff --git a/backend/src/modules/lab-case-comments/dto/lab-case-comment.dto.ts b/backend/src/modules/lab-case-comments/dto/lab-case-comment.dto.ts new file mode 100644 index 0000000..773f158 --- /dev/null +++ b/backend/src/modules/lab-case-comments/dto/lab-case-comment.dto.ts @@ -0,0 +1,17 @@ +import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class CreateLabCaseCommentDto { + @IsString() + @MinLength(1) + @MaxLength(2000) + body: string; + + @IsOptional() + @IsBoolean() + visibleToClinic?: boolean; +} + +export class SetCommentVisibilityDto { + @IsBoolean() + visibleToClinic: boolean; +} diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts b/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts new file mode 100644 index 0000000..0328c03 --- /dev/null +++ b/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts @@ -0,0 +1,61 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + 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 { + CreateLabCaseCommentDto, + SetCommentVisibilityDto, +} from './dto/lab-case-comment.dto'; +import { LabCaseCommentsService } from './lab-case-comments.service'; + +@ApiTags('case-comments') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard, LabOrgGuard) +@Controller('case-comments') +export class LabCaseCommentsController { + constructor(private readonly service: LabCaseCommentsService) {} + + private orgId(req: { user: { organizationId?: string } }) { + return req.user.organizationId as string; + } + + @Get(':caseId') + @ApiOperation({ summary: 'List comments for a lab case (lab side)' }) + list(@Param('caseId') caseId: string, @Req() req) { + return this.service.listForLab(caseId, this.orgId(req), req.user.id); + } + + @Post(':caseId') + @ApiOperation({ summary: 'Add a comment to a lab case (lab side)' }) + add( + @Param('caseId') caseId: string, + @Body() dto: CreateLabCaseCommentDto, + @Req() req, + ) { + return this.service.addForLab(caseId, this.orgId(req), req.user.id, dto); + } + + @Patch('item/:commentId/visibility') + @ApiOperation({ summary: 'Toggle whether a comment is visible to the clinic' }) + setVisibility( + @Param('commentId') commentId: string, + @Body() dto: SetCommentVisibilityDto, + @Req() req, + ) { + return this.service.setVisibility( + commentId, + this.orgId(req), + req.user.id, + dto.visibleToClinic, + ); + } +} diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.module.ts b/backend/src/modules/lab-case-comments/lab-case-comments.module.ts new file mode 100644 index 0000000..e6f8abd --- /dev/null +++ b/backend/src/modules/lab-case-comments/lab-case-comments.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { LabOrgGuard } from '../../common/guards/lab-org.guard'; +import { LabCaseCommentsController } from './lab-case-comments.controller'; +import { LabCaseCommentsService } from './lab-case-comments.service'; + +@Module({ + controllers: [LabCaseCommentsController], + providers: [LabCaseCommentsService, PrismaService, LabOrgGuard], + exports: [LabCaseCommentsService], +}) +export class LabCaseCommentsModule {} diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts new file mode 100644 index 0000000..a1a04c9 --- /dev/null +++ b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts @@ -0,0 +1,183 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { LabCaseCommentSide, Prisma } from '@prisma/client'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto'; + +const commentInclude = { + authorUser: { select: { id: true, name: true } }, + authorOrganization: { select: { id: true, name: true } }, +} satisfies Prisma.LabCaseCommentInclude; + +type CommentWithRelations = Prisma.LabCaseCommentGetPayload<{ + include: typeof commentInclude; +}>; + +@Injectable() +export class LabCaseCommentsService { + constructor(private readonly prisma: PrismaService) {} + + // ---------- Lab side (TAB_TASKS_EDIT) ---------- + + async listForLab(caseId: string, labOrganizationId: string, actorUserId: string) { + await this.assertLabCanComment(caseId, labOrganizationId, actorUserId); + const comments = await this.fetchComments(caseId); + return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.LAB)) }; + } + + async addForLab( + caseId: string, + labOrganizationId: string, + actorUserId: string, + dto: CreateLabCaseCommentDto, + ) { + await this.assertLabCanComment(caseId, labOrganizationId, actorUserId); + const created = await this.prisma.labCaseComment.create({ + data: { + labCaseId: caseId, + authorUserId: actorUserId, + authorOrganizationId: labOrganizationId, + authorSide: LabCaseCommentSide.LAB, + body: dto.body.trim(), + visibleToClinic: dto.visibleToClinic ?? false, + }, + include: commentInclude, + }); + return { success: true, data: this.mapComment(created, LabCaseCommentSide.LAB) }; + } + + async setVisibility( + commentId: string, + labOrganizationId: string, + actorUserId: string, + visibleToClinic: boolean, + ) { + const comment = await this.prisma.labCaseComment.findUnique({ + where: { id: commentId }, + select: { id: true, labCaseId: true, authorSide: true }, + }); + if (!comment) { + throw new NotFoundException('Comment not found'); + } + await this.assertLabCanComment(comment.labCaseId, labOrganizationId, actorUserId); + if (comment.authorSide !== LabCaseCommentSide.LAB) { + throw new ForbiddenException('Only lab comments can change visibility'); + } + const updated = await this.prisma.labCaseComment.update({ + where: { id: commentId }, + data: { visibleToClinic }, + include: commentInclude, + }); + return { success: true, data: this.mapComment(updated, LabCaseCommentSide.LAB) }; + } + + // ---------- Clinic side (connection access is validated by caller) ---------- + + async listForClinic(caseId: string, clinicOrganizationId: string) { + await this.assertClinicOwnsCase(caseId, clinicOrganizationId); + const comments = await this.fetchComments(caseId, { visibleOnly: true }); + return { + success: true, + data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.CLINIC)), + }; + } + + async addForClinic( + caseId: string, + clinicOrganizationId: string, + actorUserId: string, + dto: CreateLabCaseCommentDto, + ) { + await this.assertClinicOwnsCase(caseId, clinicOrganizationId); + const created = await this.prisma.labCaseComment.create({ + data: { + labCaseId: caseId, + authorUserId: actorUserId, + authorOrganizationId: clinicOrganizationId, + authorSide: LabCaseCommentSide.CLINIC, + body: dto.body.trim(), + // Clinic-authored comments are inherently visible to the clinic. + visibleToClinic: true, + }, + include: commentInclude, + }); + return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) }; + } + + // ---------- Helpers ---------- + + private fetchComments(caseId: string, opts?: { visibleOnly?: boolean }) { + return this.prisma.labCaseComment.findMany({ + where: { + labCaseId: caseId, + ...(opts?.visibleOnly ? { visibleToClinic: true } : {}), + }, + include: commentInclude, + orderBy: { createdAt: 'asc' }, + }); + } + + private mapComment(comment: CommentWithRelations, viewerSide: LabCaseCommentSide) { + return { + id: comment.id, + body: comment.body, + authorSide: comment.authorSide, + authorName: comment.authorUser?.name ?? null, + authorOrganizationName: comment.authorOrganization?.name ?? null, + visibleToClinic: comment.visibleToClinic, + createdAt: comment.createdAt.toISOString(), + // Only lab viewers can toggle visibility, and only on lab-authored comments. + canToggleVisibility: + viewerSide === LabCaseCommentSide.LAB && + comment.authorSide === LabCaseCommentSide.LAB, + }; + } + + private async assertLabCanComment( + caseId: string, + labOrganizationId: string, + actorUserId: string, + ) { + const labCase = await this.prisma.labCase.findFirst({ + where: { + id: caseId, + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + select: { id: true }, + }); + if (!labCase) { + throw new NotFoundException('Case not found'); + } + + const membership = await this.prisma.membership.findFirst({ + where: { userId: actorUserId, organizationId: labOrganizationId, isActive: true }, + include: { permissions: { include: { permission: true } } }, + }); + if (!membership) { + throw new ForbiddenException('You are not a member of this organization'); + } + if (membership.isOwner) return; + const names = membership.permissions.map((p) => p.permission.name); + if (!names.includes('TAB_TASKS_EDIT')) { + throw new ForbiddenException('You do not have access to task comments'); + } + } + + private async assertClinicOwnsCase(caseId: string, clinicOrganizationId: string) { + const labCase = await this.prisma.labCase.findFirst({ + where: { + id: caseId, + sentAt: { not: null }, + treatment: { organizationId: clinicOrganizationId }, + }, + select: { id: true }, + }); + if (!labCase) { + throw new NotFoundException('Case not found'); + } + } +} diff --git a/backend/src/modules/organization/organization.controller.ts b/backend/src/modules/organization/organization.controller.ts index 9f3ff7d..548dd3c 100644 --- a/backend/src/modules/organization/organization.controller.ts +++ b/backend/src/modules/organization/organization.controller.ts @@ -19,6 +19,7 @@ import { PreviewOrganizationInviteDto } from './dto/preview-organization-invite. import { RespondConnectionRequestDto } from './dto/respond-connection-request.dto'; import { OrganizationService } from './organization.service'; import { ListLabCasesDto } from '../cases/dto/cases.dto'; +import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto'; /** * Counterpart orgs (clinic↔lab). @@ -157,6 +158,42 @@ export class OrganizationController { ); } + @Get('connections/:connectionId/cases/:caseId/comments') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'List clinic-visible comments for a connection case' }) + listConnectionCaseComments( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('connectionId') connectionId: string, + @Param('caseId') caseId: string, + ) { + const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); + return this.organizationService.listConnectionCaseComments( + req.user.id, + organizationId, + connectionId, + caseId, + ); + } + + @Post('connections/:connectionId/cases/:caseId/comments') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Reply to a connection case as the clinic' }) + addConnectionCaseComment( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('connectionId') connectionId: string, + @Param('caseId') caseId: string, + @Body() dto: CreateLabCaseCommentDto, + ) { + const organizationId = this.organizationService.getOrganizationIdFromUser(req.user); + return this.organizationService.addConnectionCaseComment( + req.user.id, + organizationId, + connectionId, + caseId, + dto, + ); + } + @Post('invitations/:invitationId/link') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Get a shareable invite link for a pending invitation' }) diff --git a/backend/src/modules/organization/organization.module.ts b/backend/src/modules/organization/organization.module.ts index b8626c4..18a7d93 100644 --- a/backend/src/modules/organization/organization.module.ts +++ b/backend/src/modules/organization/organization.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { CasesModule } from '../cases/cases.module'; +import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module'; import { OrganizationController } from './organization.controller'; import { OrganizationService } from './organization.service'; @Module({ - imports: [CasesModule], + imports: [CasesModule, LabCaseCommentsModule], controllers: [OrganizationController], providers: [OrganizationService, PrismaService], }) diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index 6abb5b1..65b2184 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -11,6 +11,8 @@ import { createHash, randomBytes } from 'crypto'; import { PrismaService } from '../../../prisma/prisma.service'; import { ListLabCasesDto } from '../cases/dto/cases.dto'; import { CasesService } from '../cases/cases.service'; +import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service'; +import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto'; import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto'; import { CreateConnectionRequestDto } from './dto/create-connection-request.dto'; import { InviteOrganizationDto } from './dto/invite-organization.dto'; @@ -33,6 +35,7 @@ export class OrganizationService { constructor( private readonly prisma: PrismaService, private readonly casesService: CasesService, + private readonly commentsService: LabCaseCommentsService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { @@ -405,6 +408,55 @@ export class OrganizationService { }; } + async listConnectionCaseComments( + userId: string, + organizationId: string, + connectionId: string, + caseId: string, + ) { + const { clinicOrganizationId } = await this.resolveClinicConnection( + userId, + organizationId, + connectionId, + ); + return this.commentsService.listForClinic(caseId, clinicOrganizationId); + } + + async addConnectionCaseComment( + userId: string, + organizationId: string, + connectionId: string, + caseId: string, + dto: CreateLabCaseCommentDto, + ) { + const { clinicOrganizationId } = await this.resolveClinicConnection( + userId, + organizationId, + connectionId, + ); + return this.commentsService.addForClinic(caseId, clinicOrganizationId, userId, dto); + } + + /** + * Clinic comment surfaces require the actor to belong to the clinic side of the connection. + * Only clinic-side members may read/reply to case comments from the connection history. + */ + private async resolveClinicConnection( + userId: string, + organizationId: string, + connectionId: string, + ) { + const actor = await this.getActorMembership(userId, organizationId); + if (!actor || !this.canEditOrganizations(actor)) { + throw new ForbiddenException('You do not have permission to manage organizations'); + } + const parties = await this.resolveActiveConnectionParties(connectionId, organizationId, actor); + if (parties.clinicOrganizationId !== organizationId) { + throw new ForbiddenException('Only the clinic can comment on this case'); + } + return parties; + } + /** Re-issue a shareable URL for a pending invitation (rotates token; previous URL stops working). */ async getInvitationLink(userId: string, organizationId: string, invitationId: string) { const actor = await this.getActorMembership(userId, organizationId); diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts index 1b5539d..9898378 100644 --- a/backend/src/modules/tasks/dto/tasks.dto.ts +++ b/backend/src/modules/tasks/dto/tasks.dto.ts @@ -1,13 +1,71 @@ -import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator'; +import { + IsBoolean, + IsDateString, + IsEnum, + IsIn, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + Min, +} from 'class-validator'; import { Transform } from 'class-transformer'; import { LabTaskStatus } from '@prisma/client'; +const toBoolean = ({ value }: { value: unknown }) => { + if (typeof value === 'boolean') return value; + if (value === 'true' || value === '1') return true; + if (value === 'false' || value === '0') return false; + return value; +}; + export class UpdateLabTaskDto { @IsEnum(LabTaskStatus) status: LabTaskStatus; } +export type TaskSortField = 'date' | 'status' | 'clinic' | 'patient' | 'important'; + export class ListLabTasksDto { + @IsOptional() + @IsString() + q?: string; + + @IsOptional() + @IsUUID() + clinicOrganizationId?: string; + + @IsOptional() + @IsEnum(LabTaskStatus) + status?: LabTaskStatus; + + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + completed?: boolean; + + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + important?: boolean; + + @IsOptional() + @IsDateString() + sentFrom?: string; + + @IsOptional() + @IsDateString() + sentTo?: string; + + @IsOptional() + @IsIn(['date', 'status', 'clinic', 'patient', 'important']) + sortBy?: TaskSortField; + + @IsOptional() + @IsIn(['asc', 'desc']) + sortDir?: 'asc' | 'desc'; + @IsOptional() @Transform(({ value }) => Number(value)) @IsInt() diff --git a/backend/src/modules/tasks/tasks.controller.ts b/backend/src/modules/tasks/tasks.controller.ts index 0cce614..279d86c 100644 --- a/backend/src/modules/tasks/tasks.controller.ts +++ b/backend/src/modules/tasks/tasks.controller.ts @@ -13,7 +13,7 @@ export class TasksController { constructor(private readonly tasksService: TasksService) {} @Get() - @ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' }) + @ApiOperation({ summary: 'List lab tasks' }) list(@Query() query: ListLabTasksDto, @Req() req) { const organizationId = this.tasksService.getOrganizationIdFromUser(req.user); return this.tasksService.list(organizationId, req.user.id, query, req.user.language); diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 66fd7c4..9c15b51 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -6,14 +6,16 @@ import { } from '@nestjs/common'; import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; +import { normalizeMobile } from '../../common/phone'; import { CatalogLabelService, normalizeCatalogLocale, } from '../catalog/catalog-label.service'; +import { normalizeTaskTeeth } from '../cases/lab-case-task.util'; import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto'; const taskListInclude = { - assignee: { select: { id: true, name: true, email: true } }, + lastStatusChangedBy: { select: { id: true, name: true } }, labCase: { include: { treatment: { @@ -48,35 +50,17 @@ export class TasksService { ) { await this.assertCanReadTasks(actorUserId, labOrganizationId); - const membership = await this.getMembership(actorUserId, labOrganizationId); - if (!membership) { - throw new ForbiddenException('You are not a member of this organization'); - } - const page = query.page ?? 1; const limit = Math.min(Math.max(query.limit ?? 50, 1), 100); const skip = (page - 1) * limit; - const where: Prisma.LabCaseTaskWhereInput = { - labCase: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, - ...(membership.isOwner ? {} : { assigneeUserId: actorUserId }), - }; + const where = this.buildListWhere(labOrganizationId, query); const [items, total] = await Promise.all([ this.prisma.labCaseTask.findMany({ where, include: taskListInclude, - orderBy: [ - { assignedAt: { sort: 'desc', nulls: 'first' } }, - { createdAt: 'desc' }, - { labCaseId: 'asc' }, - { priority: 'desc' }, - { stepOrder: 'asc' }, - { id: 'asc' }, - ], + orderBy: this.buildOrderBy(query), skip, take: limit, }), @@ -114,11 +98,6 @@ export class TasksService { ) { await this.assertCanEditTasks(actorUserId, labOrganizationId); - const membership = await this.getMembership(actorUserId, labOrganizationId); - if (!membership) { - throw new ForbiddenException('You are not a member of this organization'); - } - const task = await this.prisma.labCaseTask.findFirst({ where: { id: taskId, @@ -134,14 +113,29 @@ export class TasksService { throw new NotFoundException('Task not found'); } - if (!membership.isOwner && task.assigneeUserId !== actorUserId) { - throw new ForbiddenException('You can only update tasks assigned to you'); - } + const updated = await this.prisma.$transaction(async (tx) => { + const result = await tx.labCaseTask.update({ + where: { id: taskId }, + data: { + status: dto.status, + lastStatusChangedByUserId: actorUserId, + lastStatusChangedAt: new Date(), + }, + include: taskListInclude, + }); - const updated = await this.prisma.labCaseTask.update({ - where: { id: taskId }, - data: { status: dto.status }, - include: taskListInclude, + if (task.status !== dto.status) { + await tx.labCaseTaskStatusEvent.create({ + data: { + taskId, + fromStatus: task.status, + toStatus: dto.status, + changedByUserId: actorUserId, + }, + }); + } + + return result; }); const locale = normalizeCatalogLocale(localeInput); @@ -154,6 +148,100 @@ export class TasksService { return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) }; } + private buildListWhere( + labOrganizationId: string, + query: ListLabTasksDto, + ): Prisma.LabCaseTaskWhereInput { + const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null }; + + if (query.sentFrom) { + const from = new Date(query.sentFrom); + if (Number.isNaN(from.getTime())) { + throw new BadRequestException('Invalid sentFrom date'); + } + sentAtFilter.gte = from; + } + if (query.sentTo) { + const to = new Date(query.sentTo); + if (Number.isNaN(to.getTime())) { + throw new BadRequestException('Invalid sentTo date'); + } + to.setHours(23, 59, 59, 999); + sentAtFilter.lte = to; + } + + // Status: explicit status wins; completed=true/false narrows; otherwise no status filter. + let status: LabTaskStatus | undefined; + if (query.status) { + status = query.status; + } else if (query.completed === true) { + status = LabTaskStatus.COMPLETED; + } else if (query.completed === false) { + status = LabTaskStatus.IN_PROGRESS; + } + + return { + labCase: { + sentAt: sentAtFilter, + sends: { some: { organizationId: labOrganizationId } }, + ...(query.clinicOrganizationId + ? { treatment: { organizationId: query.clinicOrganizationId } } + : {}), + ...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}), + }, + ...(status !== undefined ? { status } : {}), + ...(query.important !== undefined ? { isImportant: query.important } : {}), + }; + } + + private buildSearchWhere(q: string): Prisma.TreatmentWhereInput { + const orConditions: Prisma.PatientWhereInput[] = [ + { firstName: { contains: q, mode: 'insensitive' } }, + { lastName: { contains: q, mode: 'insensitive' } }, + ]; + const normalized = normalizeMobile(q); + if (normalized) { + orConditions.push({ mobile: normalized }); + } + return { + OR: [ + { patient: { OR: orConditions } }, + { organization: { name: { contains: q, mode: 'insensitive' } } }, + ], + }; + } + + private buildOrderBy(query: ListLabTasksDto): Prisma.LabCaseTaskOrderByWithRelationInput[] { + const dir = query.sortDir ?? 'desc'; + switch (query.sortBy) { + case 'status': + return [{ status: dir }, { createdAt: 'desc' }, { id: 'asc' }]; + case 'clinic': + return [ + { labCase: { treatment: { organization: { name: dir } } } }, + { createdAt: 'desc' }, + { id: 'asc' }, + ]; + case 'patient': + return [ + { labCase: { treatment: { patient: { lastName: dir } } } }, + { labCase: { treatment: { patient: { firstName: dir } } } }, + { id: 'asc' }, + ]; + case 'important': + return [{ isImportant: dir }, { createdAt: 'desc' }, { id: 'asc' }]; + case 'date': + default: + return [ + { labCase: { sentAt: dir } }, + { labCaseId: 'asc' }, + { treatmentDetailId: 'asc' }, + { stepOrder: 'asc' }, + { id: 'asc' }, + ]; + } + } + private mapTaskListItem( task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>, prosthesisLabels: Map, @@ -161,21 +249,22 @@ export class TasksService { return { id: task.id, labCaseId: task.labCaseId, - tooth: task.tooth, + treatmentDetailId: task.treatmentDetailId, + teeth: normalizeTaskTeeth(task.teeth), treatmentType: task.treatmentType, prosthesisTypeCode: task.prosthesisTypeCode, prosthesisTypeLabel: prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode, + workflowStepCode: task.workflowStepCode, stepOrder: task.stepOrder, stepLabel: task.stepLabel, status: task.status, - priority: task.priority, - assignedAt: task.assignedAt?.toISOString() ?? null, - createdAt: task.createdAt.toISOString(), - assigneeUserId: task.assigneeUserId, - assignee: task.assignee - ? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email } + isImportant: task.isImportant, + lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null, + lastStatusChangedBy: task.lastStatusChangedBy + ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name } : null, + createdAt: task.createdAt.toISOString(), clinic: task.labCase.treatment.organization, patient: { id: task.labCase.treatment.patient.id, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 93c9679..c1eafb9 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -319,7 +319,7 @@ }, "cases": { "title": "Cases", - "subtitle": "Lab cases sent from linked clinics. Assign tasks and track progress by tooth.", + "subtitle": "Lab cases sent from linked clinics. Track progress and flag important tasks.", "searchPlaceholder": "Search by patient name or mobile…", "emptyList": "No cases received yet.", "selectCaseHint": "Select a case from the list to view tasks.", @@ -329,13 +329,17 @@ "taskProgressShort": "{progress} tasks", "treatmentDetails": "Treatment details", "teethLabel": "Teeth", - "tasksByTooth": "Tasks by tooth", - "toothGroupTitle": "Tooth {tooth} · {prosthesis} · {type}", + "tasksByTooth": "Tasks", + "toothGroupTitle": "Teeth {teeth} · {prosthesis}", "noTasks": "No tasks were generated for this case.", - "unassigned": "Unassigned", - "statusPending": "Pending", "statusInProgress": "In progress", "statusCompleted": "Completed", + "importantLabel": "Important", + "markImportant": "Mark as important", + "lastUpdatedBy": "Updated by {name}", + "lastUpdatedUnknown": "Not started yet", + "timelineTitle": "History", + "timelineEntry": "{status} · {name} · {date}", "errorLoadList": "Failed to load cases.", "errorLoadDetail": "Failed to load case details.", "errorUpdateTask": "Failed to update task.", @@ -351,32 +355,63 @@ "prevPage": "Previous", "nextPage": "Next", "pageSummary": "Page {page} of {totalPages} ({total} cases)", - "priorityLabel": "Priority", "statusLabel": "Status" }, "tasks": { "title": "Tasks", - "subtitle": "Your assigned lab tasks. Update status as you work through each step.", - "subtitleOwner": "All lab tasks in the organization. Assign tasks from Cases; update status on your own assignments here.", + "subtitle": "All lab tasks from connected clinics. Filter, sort, and update the status of each step.", + "subtitleOwner": "All lab tasks from connected clinics. Filter, sort, and update the status of each step.", "loading": "Loading tasks…", - "emptyList": "No tasks assigned to you yet.", - "emptyListOwner": "No tasks in the lab inbox yet.", + "emptyList": "No tasks match the current filters.", + "emptyListOwner": "No tasks match the current filters.", "noPermissionTitle": "Tasks", "noPermissionBody": "You do not have permission to view tasks for this organization.", "fromClinic": "From {name}", "patientLabel": "Patient", "taskDate": "{date}", - "priorityLabel": "Priority {n}", - "toothLabel": "Tooth {tooth}", - "unassigned": "Unassigned", - "assignedTo": "Assigned to {name}", - "statusPending": "Pending", + "teethLabel": "Teeth {teeth}", + "importantBadge": "Important", + "lastUpdatedBy": "Updated by {name}", "statusInProgress": "In progress", "statusCompleted": "Completed", + "searchPlaceholder": "Search patient or clinic…", + "filterClinic": "Clinic", + "filterClinicAll": "All clinics", + "filterStatus": "Status", + "filterStatusAll": "All statuses", + "showCompleted": "Show completed", + "importantOnly": "Important only", + "filterSentFrom": "From", + "filterSentTo": "To", + "sortBy": "Sort by", + "sortDate": "Date", + "sortStatus": "Status", + "sortClinic": "Clinic", + "sortPatient": "Patient", + "sortImportant": "Important", + "clearFilters": "Clear filters", + "commentsButton": "Comments", "errorLoadList": "Failed to load tasks.", "errorUpdateTask": "Failed to update task.", "pageSummary": "Page {page} of {totalPages} ({total} tasks)" }, + "caseComments": { + "title": "Comments", + "placeholder": "Write a comment…", + "reply": "Reply…", + "post": "Post", + "empty": "No comments yet.", + "visibleToClinicToggle": "Visible to clinic", + "clinicCanSee": "Clinic can see this", + "hiddenFromClinic": "Hidden from clinic", + "makeVisible": "Make visible to clinic", + "makeHidden": "Hide from clinic", + "labAuthor": "Lab", + "clinicAuthor": "Clinic", + "errorLoad": "Failed to load comments.", + "errorPost": "Failed to post comment.", + "errorToggle": "Failed to update comment visibility." + }, "appointments": { "title": "Appointments", "subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 388c300..5c78203 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -319,7 +319,7 @@ }, "cases": { "title": "پرونده‌ها", - "subtitle": "پرونده‌های ارسالی از کلینیک‌های متصل. وظایف را تخصیص دهید و پیشرفت هر دندان را پیگیری کنید.", + "subtitle": "پرونده‌های ارسالی از کلینیک‌های متصل. پیشرفت را پیگیری کنید و وظایف مهم را علامت بزنید.", "searchPlaceholder": "جستجو با نام یا موبایل بیمار…", "emptyList": "هنوز پرونده‌ای دریافت نشده است.", "selectCaseHint": "برای مشاهده وظایف، یک پرونده از فهرست انتخاب کنید.", @@ -329,13 +329,17 @@ "taskProgressShort": "{progress} وظیفه", "treatmentDetails": "جزئیات درمان", "teethLabel": "دندان‌ها", - "tasksByTooth": "وظایف به تفکیک دندان", - "toothGroupTitle": "دندان {tooth} · {prosthesis} · {type}", + "tasksByTooth": "وظایف", + "toothGroupTitle": "دندان‌های {teeth} · {prosthesis}", "noTasks": "برای این پرونده وظیفه‌ای ایجاد نشده است.", - "unassigned": "بدون مسئول", - "statusPending": "در انتظار", "statusInProgress": "در حال انجام", "statusCompleted": "انجام شده", + "importantLabel": "مهم", + "markImportant": "علامت‌گذاری به عنوان مهم", + "lastUpdatedBy": "به‌روزرسانی توسط {name}", + "lastUpdatedUnknown": "هنوز شروع نشده", + "timelineTitle": "تاریخچه", + "timelineEntry": "{status} · {name} · {date}", "errorLoadList": "بارگذاری پرونده‌ها ناموفق بود.", "errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.", "errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود.", @@ -351,32 +355,63 @@ "prevPage": "قبلی", "nextPage": "بعدی", "pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)", - "priorityLabel": "اولویت", "statusLabel": "وضعیت" }, "tasks": { "title": "وظایف", - "subtitle": "وظایف لاب اختصاص‌یافته به شما. وضعیت را در حین انجام هر مرحله به‌روز کنید.", - "subtitleOwner": "همه وظایف لاب در سازمان. تخصیص از بخش پرونده‌ها؛ به‌روزرسانی وضعیت برای وظایف خودتان اینجا.", + "subtitle": "همه وظایف لاب از کلینیک‌های متصل. فیلتر، مرتب‌سازی و به‌روزرسانی وضعیت هر مرحله.", + "subtitleOwner": "همه وظایف لاب از کلینیک‌های متصل. فیلتر، مرتب‌سازی و به‌روزرسانی وضعیت هر مرحله.", "loading": "در حال بارگذاری وظایف…", - "emptyList": "هنوز وظیفه‌ای به شما اختصاص داده نشده است.", - "emptyListOwner": "هنوز وظیفه‌ای در صندوق ورودی لاب وجود ندارد.", + "emptyList": "هیچ وظیفه‌ای با فیلترهای فعلی مطابقت ندارد.", + "emptyListOwner": "هیچ وظیفه‌ای با فیلترهای فعلی مطابقت ندارد.", "noPermissionTitle": "وظایف", "noPermissionBody": "شما مجوز مشاهده وظایف برای این سازمان را ندارید.", "fromClinic": "از {name}", "patientLabel": "بیمار", "taskDate": "{date}", - "priorityLabel": "اولویت {n}", - "toothLabel": "دندان {tooth}", - "unassigned": "اختصاص داده نشده", - "assignedTo": "اختصاص به {name}", - "statusPending": "در انتظار", + "teethLabel": "دندان‌های {teeth}", + "importantBadge": "مهم", + "lastUpdatedBy": "به‌روزرسانی توسط {name}", "statusInProgress": "در حال انجام", "statusCompleted": "تکمیل‌شده", + "searchPlaceholder": "جستجوی بیمار یا کلینیک…", + "filterClinic": "کلینیک", + "filterClinicAll": "همه کلینیک‌ها", + "filterStatus": "وضعیت", + "filterStatusAll": "همه وضعیت‌ها", + "showCompleted": "نمایش تکمیل‌شده‌ها", + "importantOnly": "فقط مهم‌ها", + "filterSentFrom": "از", + "filterSentTo": "تا", + "sortBy": "مرتب‌سازی بر اساس", + "sortDate": "تاریخ", + "sortStatus": "وضعیت", + "sortClinic": "کلینیک", + "sortPatient": "بیمار", + "sortImportant": "مهم", + "clearFilters": "پاک کردن فیلترها", + "commentsButton": "نظرات", "errorLoadList": "بارگذاری وظایف ناموفق بود.", "errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود.", "pageSummary": "صفحه {page} از {totalPages} ({total} وظیفه)" }, + "caseComments": { + "title": "نظرات", + "placeholder": "یک نظر بنویسید…", + "reply": "پاسخ…", + "post": "ثبت", + "empty": "هنوز نظری ثبت نشده است.", + "visibleToClinicToggle": "قابل مشاهده برای کلینیک", + "clinicCanSee": "کلینیک می‌تواند ببیند", + "hiddenFromClinic": "پنهان از کلینیک", + "makeVisible": "نمایش به کلینیک", + "makeHidden": "پنهان از کلینیک", + "labAuthor": "آزمایشگاه", + "clinicAuthor": "کلینیک", + "errorLoad": "بارگذاری نظرات ناموفق بود.", + "errorPost": "ثبت نظر ناموفق بود.", + "errorToggle": "به‌روزرسانی وضعیت نمایش نظر ناموفق بود." + }, "appointments": { "title": "نوبت‌ها", "subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائه‌دهنده کلیک کنید تا رزرو کنید.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index ab69c7a..6fa45d2 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -319,7 +319,7 @@ }, "cases": { "title": "Dossiers", - "subtitle": "Labdossiers van gekoppelde klinieken. Wijs taken toe en volg de voortgang per tand.", + "subtitle": "Labdossiers van gekoppelde klinieken. Volg de voortgang en markeer belangrijke taken.", "searchPlaceholder": "Zoeken op patiëntnaam of mobiel…", "emptyList": "Nog geen dossiers ontvangen.", "selectCaseHint": "Selecteer een dossier uit de lijst om taken te bekijken.", @@ -329,13 +329,17 @@ "taskProgressShort": "{progress} taken", "treatmentDetails": "Behandeldetails", "teethLabel": "Tanden", - "tasksByTooth": "Taken per tand", - "toothGroupTitle": "Tand {tooth} · {prosthesis} · {type}", + "tasksByTooth": "Taken", + "toothGroupTitle": "Tanden {teeth} · {prosthesis}", "noTasks": "Er zijn geen taken gegenereerd voor dit dossier.", - "unassigned": "Niet toegewezen", - "statusPending": "In afwachting", "statusInProgress": "Bezig", "statusCompleted": "Voltooid", + "importantLabel": "Belangrijk", + "markImportant": "Markeren als belangrijk", + "lastUpdatedBy": "Bijgewerkt door {name}", + "lastUpdatedUnknown": "Nog niet gestart", + "timelineTitle": "Geschiedenis", + "timelineEntry": "{status} · {name} · {date}", "errorLoadList": "Dossiers laden mislukt.", "errorLoadDetail": "Dossierdetails laden mislukt.", "errorUpdateTask": "Taak bijwerken mislukt.", @@ -351,32 +355,63 @@ "prevPage": "Vorige", "nextPage": "Volgende", "pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)", - "priorityLabel": "Prioriteit", "statusLabel": "Status" }, "tasks": { "title": "Taken", - "subtitle": "Uw toegewezen labtaken. Werk de status bij terwijl u elke stap uitvoert.", - "subtitleOwner": "Alle labtaken in de organisatie. Wijs toe via Dossiers; werk hier de status bij voor uw eigen taken.", + "subtitle": "Alle labtaken van gekoppelde klinieken. Filter, sorteer en werk de status van elke stap bij.", + "subtitleOwner": "Alle labtaken van gekoppelde klinieken. Filter, sorteer en werk de status van elke stap bij.", "loading": "Taken laden…", - "emptyList": "Nog geen taken aan u toegewezen.", - "emptyListOwner": "Nog geen taken in de lab-inbox.", + "emptyList": "Geen taken komen overeen met de huidige filters.", + "emptyListOwner": "Geen taken komen overeen met de huidige filters.", "noPermissionTitle": "Taken", "noPermissionBody": "U heeft geen toestemming om taken voor deze organisatie te bekijken.", "fromClinic": "Van {name}", "patientLabel": "Patiënt", "taskDate": "{date}", - "priorityLabel": "Prioriteit {n}", - "toothLabel": "Tand {tooth}", - "unassigned": "Niet toegewezen", - "assignedTo": "Toegewezen aan {name}", - "statusPending": "In afwachting", + "teethLabel": "Tanden {teeth}", + "importantBadge": "Belangrijk", + "lastUpdatedBy": "Bijgewerkt door {name}", "statusInProgress": "Bezig", "statusCompleted": "Voltooid", + "searchPlaceholder": "Zoek patiënt of kliniek…", + "filterClinic": "Kliniek", + "filterClinicAll": "Alle klinieken", + "filterStatus": "Status", + "filterStatusAll": "Alle statussen", + "showCompleted": "Voltooide tonen", + "importantOnly": "Alleen belangrijk", + "filterSentFrom": "Vanaf", + "filterSentTo": "Tot", + "sortBy": "Sorteren op", + "sortDate": "Datum", + "sortStatus": "Status", + "sortClinic": "Kliniek", + "sortPatient": "Patiënt", + "sortImportant": "Belangrijk", + "clearFilters": "Filters wissen", + "commentsButton": "Opmerkingen", "errorLoadList": "Taken laden mislukt.", "errorUpdateTask": "Taak bijwerken mislukt.", "pageSummary": "Pagina {page} van {totalPages} ({total} taken)" }, + "caseComments": { + "title": "Opmerkingen", + "placeholder": "Schrijf een opmerking…", + "reply": "Antwoorden…", + "post": "Plaatsen", + "empty": "Nog geen opmerkingen.", + "visibleToClinicToggle": "Zichtbaar voor kliniek", + "clinicCanSee": "Kliniek kan dit zien", + "hiddenFromClinic": "Verborgen voor kliniek", + "makeVisible": "Zichtbaar maken voor kliniek", + "makeHidden": "Verbergen voor kliniek", + "labAuthor": "Lab", + "clinicAuthor": "Kliniek", + "errorLoad": "Opmerkingen laden mislukt.", + "errorPost": "Opmerking plaatsen mislukt.", + "errorToggle": "Zichtbaarheid bijwerken mislukt." + }, "appointments": { "title": "Afspraken", "subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.", diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx index 31bf060..c97b264 100644 --- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -17,16 +17,18 @@ import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { - AssignableMember, CasesFilterOptions, LabCaseDetail, LabCaseListItem, LabTaskStatus, PaginatedLabCases, } from '@/types/cases'; +import { + formatToothList, + prosthesisTypeBadgeStyle, +} from '@/components/ui/treatment/prosthesisTypeDisplay'; const PAGE_SIZE = 20; -const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const; function taskStatusVariant(status: LabTaskStatus): BadgeVariant { switch (status) { @@ -100,7 +102,6 @@ export default function CasesPage() { const [selectedCaseId, setSelectedCaseId] = useState(null); const [selectedCase, setSelectedCase] = useState(null); - const [members, setMembers] = useState([]); const [loadingList, setLoadingList] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false); const [updatingTaskId, setUpdatingTaskId] = useState(null); @@ -115,7 +116,6 @@ export default function CasesPage() { const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( () => [ - { value: 'PENDING', label: t('statusPending') }, { value: 'IN_PROGRESS', label: t('statusInProgress') }, { value: 'COMPLETED', label: t('statusCompleted') }, ], @@ -171,7 +171,6 @@ export default function CasesPage() { useEffect(() => { void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {}); - void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {}); void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch }, []); @@ -216,25 +215,14 @@ export default function CasesPage() { setPage(1); } - async function handleTaskUpdate( - taskId: string, - payload: { assigneeUserId?: string | null; priority?: number }, - ) { + async function handleImportantToggle(taskId: string, isImportant: boolean) { if (!selectedCaseId || !canEdit) return; setUpdatingTaskId(taskId); toast.setError(''); try { - await casesApi.updateTask(selectedCaseId, taskId, payload); + await casesApi.setTaskImportant(selectedCaseId, taskId, isImportant); await loadDetail(selectedCaseId); - await loadCases({ - q: search, - clinicOrganizationId: clinicId, - treatmentType, - sentFrom, - sentTo, - page, - }); } catch (error: unknown) { toast.showError(formatApiErrorMessage(error, t('errorUpdateTask'))); } finally { @@ -476,65 +464,65 @@ export default function CasesPage() { {selectedCase.tasksByTooth.length === 0 ? (

{t('noTasks')}

) : ( - selectedCase.tasksByTooth.map((group) => ( + selectedCase.tasksByTooth.map((group, groupIndex) => (
-
- {t('toothGroupTitle', { - tooth: group.tooth, - prosthesis: group.prosthesisTypeLabel, - type: treatmentLabel(group.treatmentType), - })} +
+ + {group.prosthesisTypeLabel} + + + {t('toothGroupTitle', { + teeth: formatToothList(group.teeth), + prosthesis: group.prosthesisTypeLabel, + })} +
    {group.tasks.map((task) => (
  • - - {task.stepOrder}. {task.stepLabel} - - - {statusOptions.find((opt) => opt.value === task.status)?.label ?? - task.status} - - - +
    + + {task.stepOrder}. {task.stepLabel} + + + {statusOptions.find((opt) => opt.value === task.status)?.label ?? + task.status} + + {canEdit ? ( + + ) : task.isImportant ? ( + + {t('importantLabel')} + + ) : null} +
    +

    + {task.lastStatusChangedBy + ? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name }) + : t('lastUpdatedUnknown')} + {task.lastStatusChangedAt + ? ` · ${formatDateTime(task.lastStatusChangedAt, locale)}` + : ''} +

  • ))}
diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx index 9ef5763..1a9a7e4 100644 --- a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx @@ -1,12 +1,19 @@ 'use client'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; +import { MessageSquare } from 'lucide-react'; import { ToastStack } from '@/components/ui/shared/Toast'; import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge'; import { Button } from '@/components/ui/shared/Button'; import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; +import { SearchBar } from '@/components/ui/shared/SearchBar'; import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; +import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; +import { + formatToothList, + prosthesisTypeBadgeStyle, +} from '@/components/ui/treatment/prosthesisTypeDisplay'; import { formatApiErrorMessage } from '@/components/shared/formatApiError'; import { canEditTasks, canViewTasks } from '@/components/shared/permissions'; import { useAuth } from '@/lib/hooks/useAuth'; @@ -14,20 +21,19 @@ import { useToast } from '@/lib/hooks/useToast'; import { tasksApi } from '@/lib/api/tasks'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; -import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases'; +import type { + LabTaskListItem, + LabTaskStatus, + ListLabTasksParams, + PaginatedLabTasks, + TaskSortField, +} from '@/types/cases'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; const PAGE_SIZE = 50; function taskStatusVariant(status: LabTaskStatus): BadgeVariant { - switch (status) { - case 'COMPLETED': - return 'success'; - case 'IN_PROGRESS': - return 'default'; - default: - return 'warning'; - } + return status === 'COMPLETED' ? 'success' : 'default'; } function formatPatientName(patient: { firstName: string; lastName: string }) { @@ -50,64 +56,94 @@ export default function TasksPage() { const [loading, setLoading] = useState(false); const [updatingTaskId, setUpdatingTaskId] = useState(null); const [treatmentCatalog, setTreatmentCatalog] = useState([]); + const [expandedCommentsCaseId, setExpandedCommentsCaseId] = useState(null); + + const [search, setSearch] = useState(''); + const [clinicId, setClinicId] = useState(''); + const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>(''); + const [showCompleted, setShowCompleted] = useState(false); + const [importantOnly, setImportantOnly] = useState(false); + const [sentFrom, setSentFrom] = useState(''); + const [sentTo, setSentTo] = useState(''); + const [sortBy, setSortBy] = useState('date'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); const canView = canViewTasks(currentOrganization); const canEdit = canEditTasks(currentOrganization); const locale = user?.language ?? 'en'; - const isOwner = Boolean(currentOrganization?.isOwner); const tRef = useRef(t); tRef.current = t; 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 listParams = useMemo((): ListLabTasksParams => { + const params: ListLabTasksParams = { + page, + limit: PAGE_SIZE, + sortBy, + sortDir, + }; + if (search.trim()) params.q = search.trim(); + if (clinicId) params.clinicOrganizationId = clinicId; + if (statusFilter) { + params.status = statusFilter; + } else if (showCompleted) { + params.completed = undefined; + } else { + params.completed = false; + } + if (importantOnly) params.important = true; + if (sentFrom) params.sentFrom = sentFrom; + if (sentTo) params.sentTo = sentTo; + return params; + }, [page, search, clinicId, statusFilter, showCompleted, importantOnly, sentFrom, sentTo, sortBy, sortDir]); + + const clinicOptions = useMemo(() => { + const map = new Map(); + for (const task of tasks) { + map.set(task.clinic.id, task.clinic.name); + } + return [...map.entries()].map(([id, name]) => ({ id, name })); + }, [tasks]); + + const loadTasks = useCallback(async () => { + setLoading(true); + setError(''); + try { + const response = await tasksApi.list(listParams); + setTasks(response.data.items); + setPagination(response.data.pagination); + } catch (error: unknown) { + showError(formatApiErrorMessage(error, tRef.current('errorLoadList'))); + } finally { + setLoading(false); + } + }, [listParams, showError, setError]); + useEffect(() => { void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); }, []); useEffect(() => { if (!canView) return; - - let cancelled = false; - - void (async () => { - setLoading(true); - setError(''); - try { - const response = await tasksApi.list({ page, limit: PAGE_SIZE }); - if (cancelled) return; - setTasks(response.data.items); - setPagination(response.data.pagination); - } catch (error: unknown) { - if (cancelled) return; - showError(formatApiErrorMessage(error, tRef.current('errorLoadList'))); - } finally { - if (!cancelled) setLoading(false); - } - })(); - - return () => { - cancelled = true; - }; - }, [canView, page, showError, setError]); + const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0); + return () => clearTimeout(timeout); + }, [canView, loadTasks, search]); async function handleStatusUpdate(taskId: string, status: LabTaskStatus) { if (!canEdit) return; - setUpdatingTaskId(taskId); setError(''); try { await tasksApi.updateStatus(taskId, status); - const response = await tasksApi.list({ page, limit: PAGE_SIZE }); - setTasks(response.data.items); - setPagination(response.data.pagination); + await loadTasks(); } catch (error: unknown) { showError(formatApiErrorMessage(error, t('errorUpdateTask'))); } finally { @@ -123,9 +159,7 @@ export default function TasksPage() { }).format(new Date(value)); } - function sortDateForTask(task: LabTaskListItem) { - return task.assignedAt ?? task.createdAt; - } + const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`; if (!isAuthReady) { return
{t('loading')}
; @@ -144,89 +178,229 @@ export default function TasksPage() {

{t('title')}

-

- {isOwner ? t('subtitleOwner') : t('subtitle')} -

+

{t('subtitle')}

+
+ { + setSearch(v); + setPage(1); + }} + placeholder={t('searchPlaceholder')} + /> +
+ + + + +
+
+ + +
+
+
{loading && tasks.length === 0 ? (

{t('loading')}

) : tasks.length === 0 ? ( -

- {isOwner ? t('emptyListOwner') : t('emptyList')} -

+

{t('emptyList')}

) : (
    - {tasks.map((task) => { - const statusEditable = - canEdit && (isOwner || task.assigneeUserId === user?.id); + {tasks.map((task, index) => { + const commentsOpen = expandedCommentsCaseId === task.labCaseId; return ( -
  • -
    -

    - {task.stepOrder}. {task.stepLabel} -

    -

    - {t('fromClinic', { name: task.clinic.name })} ·{' '} - {formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })} - {task.prosthesisTypeLabel ? ` · ${task.prosthesisTypeLabel}` : ''} -

    -

    - {t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })} - {isOwner && ( - <> - · - - {task.assignee - ? t('assignedTo', { name: task.assignee.name }) - : t('unassigned')} +

  • +
    +
    +
    +

    + {task.stepOrder}. {task.stepLabel} +

    + {task.isImportant ? ( + + {t('importantBadge')} - + ) : null} + + {task.prosthesisTypeLabel} + +
    +

    + {t('fromClinic', { name: task.clinic.name })} ·{' '} + {formatPatientName(task.patient)} ·{' '} + {t('teethLabel', { teeth: formatToothList(task.teeth) })} +

    +

    + {t('taskDate', { date: formatTaskDate(task.createdAt) })} + {task.lastStatusChangedBy ? ( + <> + · + + {t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })} + + + ) : null} +

    +
    + +
    + {canEdit ? ( + + ) : ( + + {statusOptions.find((opt) => opt.value === task.status)?.label ?? + task.status} + )} -

    +
    + +
    + {canEdit ? ( + + ) : null} + +
    -
    - {statusEditable ? ( - - ) : ( - - {statusOptions.find((opt) => opt.value === task.status)?.label ?? - task.status} - - )} -
    - -
    - - {t('priorityLabel', { n: task.priority })} - - -
    + {commentsOpen && canEdit ? ( +
    + { + const r = await tasksApi.listComments(task.labCaseId); + return r.data; + }} + onPost={async (body, visibleToClinic) => { + const r = await tasksApi.addComment(task.labCaseId, { + body, + visibleToClinic, + }); + return r.data; + }} + onToggleVisibility={async (commentId, visible) => { + const r = await tasksApi.setCommentVisibility(commentId, visible); + return r.data; + }} + onError={showError} + /> +
    + ) : null}
  • ); })} diff --git a/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx new file mode 100644 index 0000000..7e7bccc --- /dev/null +++ b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Eye, EyeOff } from 'lucide-react'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { Button } from '@/components/ui/shared/Button'; +import type { LabCaseComment } from '@/types/cases'; + +interface LabCaseCommentsPanelProps { + caseId: string; + canPost: boolean; + canToggleVisibility: boolean; + loadComments: () => Promise; + onPost: (body: string, visibleToClinic?: boolean) => Promise; + onToggleVisibility?: (commentId: string, visible: boolean) => Promise; + onError?: (message: string) => void; +} + +export function LabCaseCommentsPanel({ + caseId, + canPost, + canToggleVisibility, + loadComments, + onPost, + onToggleVisibility, + onError, +}: LabCaseCommentsPanelProps) { + const t = useTranslations('caseComments'); + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(false); + const [posting, setPosting] = useState(false); + const [body, setBody] = useState(''); + const [visibleToClinic, setVisibleToClinic] = useState(false); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const items = await loadComments(); + setComments(items); + } catch (error: unknown) { + onError?.(formatApiErrorMessage(error, t('errorLoad'))); + } finally { + setLoading(false); + } + }, [loadComments, onError, t]); + + useEffect(() => { + void refresh(); + }, [caseId, refresh]); + + async function handlePost() { + const trimmed = body.trim(); + if (!trimmed || !canPost) return; + setPosting(true); + try { + const created = await onPost(trimmed, visibleToClinic); + setComments((prev) => [...prev, created]); + setBody(''); + setVisibleToClinic(false); + } catch (error: unknown) { + onError?.(formatApiErrorMessage(error, t('errorPost'))); + } finally { + setPosting(false); + } + } + + async function handleToggle(comment: LabCaseComment) { + if (!onToggleVisibility || !canToggleVisibility) return; + try { + const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic); + setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); + } catch (error: unknown) { + onError?.(formatApiErrorMessage(error, t('errorToggle'))); + } + } + + return ( +
    +

    {t('title')}

    + + {loading ? ( +

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

    {t('empty')}

    + ) : ( +
      + {comments.map((comment) => ( +
    • +
      +
      +
      + + {comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')} + {comment.authorName ? ` · ${comment.authorName}` : ''} + + {comment.visibleToClinic ? ( + {t('clinicCanSee')} + ) : ( + {t('hiddenFromClinic')} + )} +
      +

      {comment.body}

      +
      + {canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? ( + + ) : null} +
      +
    • + ))} +
    + )} + + {canPost ? ( +
    +