improvement: tasks and cases feature updated based on the new prosthesis types and their steps. the whole assignment proccess removed from the flow.

This commit is contained in:
2026-07-07 15:31:09 +03:30
parent ed7e7b1d8f
commit cb63ced4e3
35 changed files with 1819 additions and 454 deletions

View File

@@ -22,7 +22,8 @@
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy", "prisma:deploy": "prisma migrate deploy",
"prisma:seed": "prisma db seed", "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": { "prisma": {
"seed": "ts-node prisma/seed.ts" "seed": "ts-node prisma/seed.ts"

View File

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

View File

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

View File

@@ -21,6 +21,8 @@ const prisma = new PrismaClient();
// FK-safe order: children before parents. // FK-safe order: children before parents.
const TABLES_IN_ORDER = [ const TABLES_IN_ORDER = [
'lab_case_task_status_events',
'lab_case_comments',
'lab_case_tasks', 'lab_case_tasks',
'lab_case_sends', 'lab_case_sends',
'lab_case_tooth_prosthesis', 'lab_case_tooth_prosthesis',

View File

@@ -23,7 +23,9 @@ model User {
sessions Session[] // 👈 ADD THIS - opposite relation for Session sessions Session[] // 👈 ADD THIS - opposite relation for Session
sentStaffInvites StaffInvitation[] sentStaffInvites StaffInvitation[]
sentOrganizationInvitations OrganizationInvitation[] sentOrganizationInvitations OrganizationInvitation[]
assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee") statusChangedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskLastStatusChangedBy")
labCaseTaskStatusEvents LabCaseTaskStatusEvent[]
labCaseComments LabCaseComment[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -65,6 +67,7 @@ model Organization {
appointments Appointment[] appointments Appointment[]
treatments Treatment[] treatments Treatment[]
labCaseSends LabCaseSend[] labCaseSends LabCaseSend[]
labCaseComments LabCaseComment[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -115,11 +118,15 @@ model Appointment {
} }
enum LabTaskStatus { enum LabTaskStatus {
PENDING
IN_PROGRESS IN_PROGRESS
COMPLETED COMPLETED
} }
enum LabCaseCommentSide {
LAB
CLINIC
}
model Treatment { model Treatment {
id String @id @default(uuid()) id String @id @default(uuid())
organizationId String organizationId String
@@ -194,6 +201,7 @@ model LabCase {
sends LabCaseSend[] sends LabCaseSend[]
tasks LabCaseTask[] tasks LabCaseTask[]
toothProsthesis LabCaseToothProsthesis[] toothProsthesis LabCaseToothProsthesis[]
comments LabCaseComment[]
@@index([treatmentId, sortOrder]) @@index([treatmentId, sortOrder])
@@map("lab_cases") @@map("lab_cases")
@@ -306,31 +314,66 @@ model LabCaseTask {
id String @id @default(uuid()) id String @id @default(uuid())
labCaseId String labCaseId String
treatmentDetailId String treatmentDetailId String
tooth String teeth Json
treatmentType String treatmentType String
prosthesisTypeCode String prosthesisTypeCode String
workflowStepCode String workflowStepCode String
stepOrder Int stepOrder Int
stepLabel String stepLabel String
assigneeUserId String? isImportant Boolean @default(false)
assignedAt DateTime? status LabTaskStatus @default(IN_PROGRESS)
priority Int @default(3) lastStatusChangedByUserId String?
status LabTaskStatus @default(PENDING) lastStatusChangedAt DateTime?
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
detail TreatmentDetail @relation(fields: [treatmentDetailId], 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([labCaseId, treatmentDetailId, tooth, stepOrder]) @@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder])
@@index([labCaseId, status]) @@index([labCaseId, status])
@@index([assigneeUserId, priority, createdAt]) @@index([labCaseId, isImportant])
@@index([assignedAt, labCaseId, priority])
@@map("lab_case_tasks") @@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 { model Plan {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise" name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"

View File

@@ -16,6 +16,7 @@ import { TasksModule } from './modules/tasks/tasks.module';
import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module'; import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module';
import { CatalogModule } from './modules/catalog/catalog.module'; import { CatalogModule } from './modules/catalog/catalog.module';
import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module'; import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module';
import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
@Module({ @Module({
imports: [ imports: [
@@ -33,6 +34,7 @@ import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis
TreatmentsModule, TreatmentsModule,
CasesModule, CasesModule,
TasksModule, TasksModule,
LabCaseCommentsModule,
StaffModule, StaffModule,
OrganizationModule, OrganizationModule,
AdminModule.forRoot(), AdminModule.forRoot(),

View File

@@ -35,13 +35,6 @@ export class CasesController {
return this.casesService.listFilterOptions(organizationId, req.user.id); 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') @Get(':id')
@ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' }) @ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' })
getOne(@Param('id') id: string, @Req() req) { getOne(@Param('id') id: string, @Req() req) {
@@ -50,7 +43,7 @@ export class CasesController {
} }
@Patch(':id/tasks/:taskId') @Patch(':id/tasks/:taskId')
@ApiOperation({ summary: 'Update task assignee or priority' }) @ApiOperation({ summary: 'Toggle task important flag' })
updateTask( updateTask(
@Param('id') id: string, @Param('id') id: string,
@Param('taskId') taskId: string, @Param('taskId') taskId: string,

View File

@@ -14,6 +14,7 @@ import {
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { normalizeTeeth } from '../treatments/treatment.utils'; import { normalizeTeeth } from '../treatments/treatment.utils';
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto'; import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
import { normalizeTaskTeeth } from './lab-case-task.util';
const labCaseListInclude = { const labCaseListInclude = {
treatment: { treatment: {
@@ -41,16 +42,29 @@ const labCaseListInclude = {
}, },
tasks: { tasks: {
orderBy: [ orderBy: [
{ tooth: 'asc' as const }, { treatmentDetailId: 'asc' as const },
{ treatmentType: 'asc' as const }, { prosthesisTypeCode: 'asc' as const },
{ stepOrder: 'asc' as const }, { stepOrder: 'asc' as const },
], ],
include: { 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; } satisfies Prisma.LabCaseInclude;
type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
include: {
lastStatusChangedBy: { select: { id: true; name: true } };
statusEvents: {
include: { changedBy: { select: { id: true; name: true } } };
};
};
}>;
@Injectable() @Injectable()
export class CasesService { export class CasesService {
constructor( constructor(
@@ -294,23 +308,15 @@ export class CasesService {
throw new NotFoundException('Task not found'); 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({ const updated = await this.prisma.labCaseTask.update({
where: { id: taskId }, where: { id: taskId },
data: { data: { isImportant: dto.isImportant },
...(dto.assigneeUserId !== undefined
? {
assigneeUserId: dto.assigneeUserId,
assignedAt: dto.assigneeUserId === null ? null : new Date(),
}
: {}),
...(dto.priority !== undefined ? { priority: dto.priority } : {}),
},
include: { 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) }; 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( private buildListWhere(
labOrganizationId: string, labOrganizationId: string,
query: ListLabCasesDto, query: ListLabCasesDto,
@@ -465,7 +442,7 @@ export class CasesService {
prosthesisCodes, prosthesisCodes,
locale, locale,
); );
const tasksByTooth = this.groupTasksByTooth(lc.tasks, prosthesisLabels); const tasksByTooth = this.groupTasks(lc.tasks, prosthesisLabels);
return { return {
id: lc.id, id: lc.id,
@@ -495,27 +472,15 @@ export class CasesService {
}; };
} }
private groupTasksByTooth( private groupTasks(
tasks: Array<{ tasks: LabCaseTaskWithRelations[],
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;
}>,
prosthesisLabels: Map<string, string>, prosthesisLabels: Map<string, string>,
) { ) {
const groups = new Map< const groups = new Map<
string, string,
{ {
tooth: string; treatmentDetailId: string;
teeth: string[];
treatmentType: string; treatmentType: string;
prosthesisTypeCode: string; prosthesisTypeCode: string;
prosthesisTypeLabel: string; prosthesisTypeLabel: string;
@@ -524,9 +489,10 @@ export class CasesService {
>(); >();
for (const task of tasks) { for (const task of tasks) {
const key = `${task.tooth}:${task.treatmentType}:${task.prosthesisTypeCode}`; const key = `${task.treatmentDetailId}:${task.prosthesisTypeCode}`;
const entry = groups.get(key) ?? { const entry = groups.get(key) ?? {
tooth: task.tooth, treatmentDetailId: task.treatmentDetailId,
teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType, treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode, prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel: prosthesisTypeLabel:
@@ -541,26 +507,13 @@ export class CasesService {
} }
private mapTask( private mapTask(
task: { task: LabCaseTaskWithRelations,
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;
},
prosthesisLabels: Map<string, string>, prosthesisLabels: Map<string, string>,
) { ) {
return { return {
id: task.id, id: task.id,
tooth: task.tooth, treatmentDetailId: task.treatmentDetailId,
teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType, treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode, prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel: prosthesisTypeLabel:
@@ -569,32 +522,24 @@ export class CasesService {
stepOrder: task.stepOrder, stepOrder: task.stepOrder,
stepLabel: task.stepLabel, stepLabel: task.stepLabel,
status: task.status, status: task.status,
priority: task.priority, isImportant: task.isImportant,
assignedAt: task.assignedAt?.toISOString() ?? null,
createdAt: task.createdAt.toISOString(), createdAt: task.createdAt.toISOString(),
assigneeUserId: task.assigneeUserId, lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
assignee: task.assignee lastStatusChangedBy: task.lastStatusChangedBy
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email } ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
: null, : 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) { private async assertCanReadCases(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId); const m = await this.getMembership(userId, organizationId);
if (!m) { if (!m) {

View File

@@ -1,18 +1,9 @@
import { Transform } from 'class-transformer'; 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 { export class UpdateLabCaseTaskDto {
@IsOptional() @IsBoolean()
@ValidateIf((_, value) => value !== null) isImportant: boolean;
@IsUUID()
assigneeUserId?: string | null;
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
@Max(5)
priority?: number;
} }
export class ListLabCasesDto { export class ListLabCasesDto {

View File

@@ -101,10 +101,11 @@ describe('generateLabCaseTasks', () => {
expect(count).toBe(pfmSteps.length); expect(count).toBe(pfmSteps.length);
expect(created).toHaveLength(pfmSteps.length); expect(created).toHaveLength(pfmSteps.length);
expect(created[0]).toMatchObject({ expect(created[0]).toMatchObject({
tooth: '14', teeth: ['14'],
prosthesisTypeCode: 'pfm_crown', prosthesisTypeCode: 'pfm_crown',
workflowStepCode: 'intraoral_scan', workflowStepCode: 'intraoral_scan',
stepLabel: 'Intraoral Scan', stepLabel: 'Intraoral Scan',
status: 'IN_PROGRESS',
}); });
const stepCodes = (created as Array<{ workflowStepCode: string }>).map( const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode, (row) => row.workflowStepCode,
@@ -115,6 +116,35 @@ describe('generateLabCaseTasks', () => {
expect(stepCodes).toContain('milling_wet'); 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 () => { it('omits packing and shipping for smile_design', async () => {
const smileSteps = stepsFromSeed('smile_design'); const smileSteps = stepsFromSeed('smile_design');
const { tx, created } = buildMockTx({ const { tx, created } = buildMockTx({

View File

@@ -58,25 +58,46 @@ export async function generateLabCaseTasks(
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale); 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) { 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) { if (typeSteps.length === 0) {
continue; continue;
} }
const teeth = sortTeeth(group.teeth);
for (const step of typeSteps) { for (const step of typeSteps) {
taskRows.push({ taskRows.push({
labCaseId, labCaseId,
treatmentDetailId: row.treatmentDetailId, treatmentDetailId: group.treatmentDetailId,
tooth: row.tooth, teeth,
treatmentType: row.detail.treatmentType, treatmentType: group.treatmentType,
prosthesisTypeCode: row.prosthesisTypeCode, prosthesisTypeCode: group.prosthesisTypeCode,
workflowStepCode: step.workflowStepCode, workflowStepCode: step.workflowStepCode,
stepOrder: step.stepOrder, stepOrder: step.stepOrder,
stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode, 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; 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( async function resolveStepLabels(
tx: TransactionClient, tx: TransactionClient,
stepCodes: string[], stepCodes: string[],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import { PreviewOrganizationInviteDto } from './dto/preview-organization-invite.
import { RespondConnectionRequestDto } from './dto/respond-connection-request.dto'; import { RespondConnectionRequestDto } from './dto/respond-connection-request.dto';
import { OrganizationService } from './organization.service'; import { OrganizationService } from './organization.service';
import { ListLabCasesDto } from '../cases/dto/cases.dto'; import { ListLabCasesDto } from '../cases/dto/cases.dto';
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
/** /**
* Counterpart orgs (clinic↔lab). * 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') @Post('invitations/:invitationId/link')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get a shareable invite link for a pending invitation' }) @ApiOperation({ summary: 'Get a shareable invite link for a pending invitation' })

View File

@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { CasesModule } from '../cases/cases.module'; import { CasesModule } from '../cases/cases.module';
import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
import { OrganizationController } from './organization.controller'; import { OrganizationController } from './organization.controller';
import { OrganizationService } from './organization.service'; import { OrganizationService } from './organization.service';
@Module({ @Module({
imports: [CasesModule], imports: [CasesModule, LabCaseCommentsModule],
controllers: [OrganizationController], controllers: [OrganizationController],
providers: [OrganizationService, PrismaService], providers: [OrganizationService, PrismaService],
}) })

View File

@@ -11,6 +11,8 @@ import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { ListLabCasesDto } from '../cases/dto/cases.dto'; import { ListLabCasesDto } from '../cases/dto/cases.dto';
import { CasesService } from '../cases/cases.service'; 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 { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
import { CreateConnectionRequestDto } from './dto/create-connection-request.dto'; import { CreateConnectionRequestDto } from './dto/create-connection-request.dto';
import { InviteOrganizationDto } from './dto/invite-organization.dto'; import { InviteOrganizationDto } from './dto/invite-organization.dto';
@@ -33,6 +35,7 @@ export class OrganizationService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly casesService: CasesService, private readonly casesService: CasesService,
private readonly commentsService: LabCaseCommentsService,
) {} ) {}
getOrganizationIdFromUser(user: { organizationId?: string }) { 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). */ /** Re-issue a shareable URL for a pending invitation (rotates token; previous URL stops working). */
async getInvitationLink(userId: string, organizationId: string, invitationId: string) { async getInvitationLink(userId: string, organizationId: string, invitationId: string) {
const actor = await this.getActorMembership(userId, organizationId); const actor = await this.getActorMembership(userId, organizationId);

View File

@@ -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 { Transform } from 'class-transformer';
import { LabTaskStatus } from '@prisma/client'; 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 { export class UpdateLabTaskDto {
@IsEnum(LabTaskStatus) @IsEnum(LabTaskStatus)
status: LabTaskStatus; status: LabTaskStatus;
} }
export type TaskSortField = 'date' | 'status' | 'clinic' | 'patient' | 'important';
export class ListLabTasksDto { 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() @IsOptional()
@Transform(({ value }) => Number(value)) @Transform(({ value }) => Number(value))
@IsInt() @IsInt()

View File

@@ -13,7 +13,7 @@ export class TasksController {
constructor(private readonly tasksService: TasksService) {} constructor(private readonly tasksService: TasksService) {}
@Get() @Get()
@ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' }) @ApiOperation({ summary: 'List lab tasks' })
list(@Query() query: ListLabTasksDto, @Req() req) { list(@Query() query: ListLabTasksDto, @Req() req) {
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user); const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
return this.tasksService.list(organizationId, req.user.id, query, req.user.language); return this.tasksService.list(organizationId, req.user.id, query, req.user.language);

View File

@@ -6,14 +6,16 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client'; import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
import { import {
CatalogLabelService, CatalogLabelService,
normalizeCatalogLocale, normalizeCatalogLocale,
} from '../catalog/catalog-label.service'; } from '../catalog/catalog-label.service';
import { normalizeTaskTeeth } from '../cases/lab-case-task.util';
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto'; import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
const taskListInclude = { const taskListInclude = {
assignee: { select: { id: true, name: true, email: true } }, lastStatusChangedBy: { select: { id: true, name: true } },
labCase: { labCase: {
include: { include: {
treatment: { treatment: {
@@ -48,35 +50,17 @@ export class TasksService {
) { ) {
await this.assertCanReadTasks(actorUserId, labOrganizationId); 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 page = query.page ?? 1;
const limit = Math.min(Math.max(query.limit ?? 50, 1), 100); const limit = Math.min(Math.max(query.limit ?? 50, 1), 100);
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const where: Prisma.LabCaseTaskWhereInput = { const where = this.buildListWhere(labOrganizationId, query);
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
...(membership.isOwner ? {} : { assigneeUserId: actorUserId }),
};
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.labCaseTask.findMany({ this.prisma.labCaseTask.findMany({
where, where,
include: taskListInclude, include: taskListInclude,
orderBy: [ orderBy: this.buildOrderBy(query),
{ assignedAt: { sort: 'desc', nulls: 'first' } },
{ createdAt: 'desc' },
{ labCaseId: 'asc' },
{ priority: 'desc' },
{ stepOrder: 'asc' },
{ id: 'asc' },
],
skip, skip,
take: limit, take: limit,
}), }),
@@ -114,11 +98,6 @@ export class TasksService {
) { ) {
await this.assertCanEditTasks(actorUserId, labOrganizationId); 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({ const task = await this.prisma.labCaseTask.findFirst({
where: { where: {
id: taskId, id: taskId,
@@ -134,14 +113,29 @@ export class TasksService {
throw new NotFoundException('Task not found'); throw new NotFoundException('Task not found');
} }
if (!membership.isOwner && task.assigneeUserId !== actorUserId) { const updated = await this.prisma.$transaction(async (tx) => {
throw new ForbiddenException('You can only update tasks assigned to you'); 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({ if (task.status !== dto.status) {
where: { id: taskId }, await tx.labCaseTaskStatusEvent.create({
data: { status: dto.status }, data: {
include: taskListInclude, taskId,
fromStatus: task.status,
toStatus: dto.status,
changedByUserId: actorUserId,
},
});
}
return result;
}); });
const locale = normalizeCatalogLocale(localeInput); const locale = normalizeCatalogLocale(localeInput);
@@ -154,6 +148,100 @@ export class TasksService {
return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) }; 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( private mapTaskListItem(
task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>, task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
prosthesisLabels: Map<string, string>, prosthesisLabels: Map<string, string>,
@@ -161,21 +249,22 @@ export class TasksService {
return { return {
id: task.id, id: task.id,
labCaseId: task.labCaseId, labCaseId: task.labCaseId,
tooth: task.tooth, treatmentDetailId: task.treatmentDetailId,
teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType, treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode, prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel: prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode, prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
workflowStepCode: task.workflowStepCode,
stepOrder: task.stepOrder, stepOrder: task.stepOrder,
stepLabel: task.stepLabel, stepLabel: task.stepLabel,
status: task.status, status: task.status,
priority: task.priority, isImportant: task.isImportant,
assignedAt: task.assignedAt?.toISOString() ?? null, lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
createdAt: task.createdAt.toISOString(), lastStatusChangedBy: task.lastStatusChangedBy
assigneeUserId: task.assigneeUserId, ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
assignee: task.assignee
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
: null, : null,
createdAt: task.createdAt.toISOString(),
clinic: task.labCase.treatment.organization, clinic: task.labCase.treatment.organization,
patient: { patient: {
id: task.labCase.treatment.patient.id, id: task.labCase.treatment.patient.id,

View File

@@ -319,7 +319,7 @@
}, },
"cases": { "cases": {
"title": "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…", "searchPlaceholder": "Search by patient name or mobile…",
"emptyList": "No cases received yet.", "emptyList": "No cases received yet.",
"selectCaseHint": "Select a case from the list to view tasks.", "selectCaseHint": "Select a case from the list to view tasks.",
@@ -329,13 +329,17 @@
"taskProgressShort": "{progress} tasks", "taskProgressShort": "{progress} tasks",
"treatmentDetails": "Treatment details", "treatmentDetails": "Treatment details",
"teethLabel": "Teeth", "teethLabel": "Teeth",
"tasksByTooth": "Tasks by tooth", "tasksByTooth": "Tasks",
"toothGroupTitle": "Tooth {tooth} · {prosthesis} · {type}", "toothGroupTitle": "Teeth {teeth} · {prosthesis}",
"noTasks": "No tasks were generated for this case.", "noTasks": "No tasks were generated for this case.",
"unassigned": "Unassigned",
"statusPending": "Pending",
"statusInProgress": "In progress", "statusInProgress": "In progress",
"statusCompleted": "Completed", "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.", "errorLoadList": "Failed to load cases.",
"errorLoadDetail": "Failed to load case details.", "errorLoadDetail": "Failed to load case details.",
"errorUpdateTask": "Failed to update task.", "errorUpdateTask": "Failed to update task.",
@@ -351,32 +355,63 @@
"prevPage": "Previous", "prevPage": "Previous",
"nextPage": "Next", "nextPage": "Next",
"pageSummary": "Page {page} of {totalPages} ({total} cases)", "pageSummary": "Page {page} of {totalPages} ({total} cases)",
"priorityLabel": "Priority",
"statusLabel": "Status" "statusLabel": "Status"
}, },
"tasks": { "tasks": {
"title": "Tasks", "title": "Tasks",
"subtitle": "Your assigned lab tasks. Update status as you work through each step.", "subtitle": "All lab tasks from connected clinics. Filter, sort, and update the status of each step.",
"subtitleOwner": "All lab tasks in the organization. Assign tasks from Cases; update status on your own assignments here.", "subtitleOwner": "All lab tasks from connected clinics. Filter, sort, and update the status of each step.",
"loading": "Loading tasks…", "loading": "Loading tasks…",
"emptyList": "No tasks assigned to you yet.", "emptyList": "No tasks match the current filters.",
"emptyListOwner": "No tasks in the lab inbox yet.", "emptyListOwner": "No tasks match the current filters.",
"noPermissionTitle": "Tasks", "noPermissionTitle": "Tasks",
"noPermissionBody": "You do not have permission to view tasks for this organization.", "noPermissionBody": "You do not have permission to view tasks for this organization.",
"fromClinic": "From {name}", "fromClinic": "From {name}",
"patientLabel": "Patient", "patientLabel": "Patient",
"taskDate": "{date}", "taskDate": "{date}",
"priorityLabel": "Priority {n}", "teethLabel": "Teeth {teeth}",
"toothLabel": "Tooth {tooth}", "importantBadge": "Important",
"unassigned": "Unassigned", "lastUpdatedBy": "Updated by {name}",
"assignedTo": "Assigned to {name}",
"statusPending": "Pending",
"statusInProgress": "In progress", "statusInProgress": "In progress",
"statusCompleted": "Completed", "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.", "errorLoadList": "Failed to load tasks.",
"errorUpdateTask": "Failed to update task.", "errorUpdateTask": "Failed to update task.",
"pageSummary": "Page {page} of {totalPages} ({total} tasks)" "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": { "appointments": {
"title": "Appointments", "title": "Appointments",
"subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.", "subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.",

View File

@@ -319,7 +319,7 @@
}, },
"cases": { "cases": {
"title": "پرونده‌ها", "title": "پرونده‌ها",
"subtitle": "پرونده‌های ارسالی از کلینیک‌های متصل. وظایف را تخصیص دهید و پیشرفت هر دندان را پیگیری کنید.", "subtitle": "پرونده‌های ارسالی از کلینیک‌های متصل. پیشرفت را پیگیری کنید و وظایف مهم را علامت بزنید.",
"searchPlaceholder": "جستجو با نام یا موبایل بیمار…", "searchPlaceholder": "جستجو با نام یا موبایل بیمار…",
"emptyList": "هنوز پرونده‌ای دریافت نشده است.", "emptyList": "هنوز پرونده‌ای دریافت نشده است.",
"selectCaseHint": "برای مشاهده وظایف، یک پرونده از فهرست انتخاب کنید.", "selectCaseHint": "برای مشاهده وظایف، یک پرونده از فهرست انتخاب کنید.",
@@ -329,13 +329,17 @@
"taskProgressShort": "{progress} وظیفه", "taskProgressShort": "{progress} وظیفه",
"treatmentDetails": "جزئیات درمان", "treatmentDetails": "جزئیات درمان",
"teethLabel": "دندان‌ها", "teethLabel": "دندان‌ها",
"tasksByTooth": "وظایف به تفکیک دندان", "tasksByTooth": "وظایف",
"toothGroupTitle": "دندان {tooth} · {prosthesis} · {type}", "toothGroupTitle": "دندان‌های {teeth} · {prosthesis}",
"noTasks": "برای این پرونده وظیفه‌ای ایجاد نشده است.", "noTasks": "برای این پرونده وظیفه‌ای ایجاد نشده است.",
"unassigned": "بدون مسئول",
"statusPending": "در انتظار",
"statusInProgress": "در حال انجام", "statusInProgress": "در حال انجام",
"statusCompleted": "انجام شده", "statusCompleted": "انجام شده",
"importantLabel": "مهم",
"markImportant": "علامت‌گذاری به عنوان مهم",
"lastUpdatedBy": "به‌روزرسانی توسط {name}",
"lastUpdatedUnknown": "هنوز شروع نشده",
"timelineTitle": "تاریخچه",
"timelineEntry": "{status} · {name} · {date}",
"errorLoadList": "بارگذاری پرونده‌ها ناموفق بود.", "errorLoadList": "بارگذاری پرونده‌ها ناموفق بود.",
"errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.", "errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.",
"errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود.", "errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود.",
@@ -351,32 +355,63 @@
"prevPage": "قبلی", "prevPage": "قبلی",
"nextPage": "بعدی", "nextPage": "بعدی",
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)", "pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
"priorityLabel": "اولویت",
"statusLabel": "وضعیت" "statusLabel": "وضعیت"
}, },
"tasks": { "tasks": {
"title": "وظایف", "title": "وظایف",
"subtitle": "وظایف لاب اختصاص‌یافته به شما. وضعیت را در حین انجام هر مرحله به‌روز کنید.", "subtitle": "همه وظایف لاب از کلینیک‌های متصل. فیلتر، مرتب‌سازی و به‌روزرسانی وضعیت هر مرحله.",
"subtitleOwner": "همه وظایف لاب در سازمان. تخصیص از بخش پرونده‌ها؛ به‌روزرسانی وضعیت برای وظایف خودتان اینجا.", "subtitleOwner": "همه وظایف لاب از کلینیک‌های متصل. فیلتر، مرتب‌سازی و به‌روزرسانی وضعیت هر مرحله.",
"loading": "در حال بارگذاری وظایف…", "loading": "در حال بارگذاری وظایف…",
"emptyList": "هنوز وظیفه‌ای به شما اختصاص داده نشده است.", "emptyList": "هیچ وظیفه‌ای با فیلترهای فعلی مطابقت ندارد.",
"emptyListOwner": "هنوز وظیفه‌ای در صندوق ورودی لاب وجود ندارد.", "emptyListOwner": "هیچ وظیفه‌ای با فیلترهای فعلی مطابقت ندارد.",
"noPermissionTitle": "وظایف", "noPermissionTitle": "وظایف",
"noPermissionBody": "شما مجوز مشاهده وظایف برای این سازمان را ندارید.", "noPermissionBody": "شما مجوز مشاهده وظایف برای این سازمان را ندارید.",
"fromClinic": "از {name}", "fromClinic": "از {name}",
"patientLabel": "بیمار", "patientLabel": "بیمار",
"taskDate": "{date}", "taskDate": "{date}",
"priorityLabel": "اولویت {n}", "teethLabel": "دندان‌های {teeth}",
"toothLabel": "دندان {tooth}", "importantBadge": "مهم",
"unassigned": "اختصاص داده نشده", "lastUpdatedBy": "به‌روزرسانی توسط {name}",
"assignedTo": "اختصاص به {name}",
"statusPending": "در انتظار",
"statusInProgress": "در حال انجام", "statusInProgress": "در حال انجام",
"statusCompleted": "تکمیل‌شده", "statusCompleted": "تکمیل‌شده",
"searchPlaceholder": "جستجوی بیمار یا کلینیک…",
"filterClinic": "کلینیک",
"filterClinicAll": "همه کلینیک‌ها",
"filterStatus": "وضعیت",
"filterStatusAll": "همه وضعیت‌ها",
"showCompleted": "نمایش تکمیل‌شده‌ها",
"importantOnly": "فقط مهم‌ها",
"filterSentFrom": "از",
"filterSentTo": "تا",
"sortBy": "مرتب‌سازی بر اساس",
"sortDate": "تاریخ",
"sortStatus": "وضعیت",
"sortClinic": "کلینیک",
"sortPatient": "بیمار",
"sortImportant": "مهم",
"clearFilters": "پاک کردن فیلترها",
"commentsButton": "نظرات",
"errorLoadList": "بارگذاری وظایف ناموفق بود.", "errorLoadList": "بارگذاری وظایف ناموفق بود.",
"errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود.", "errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود.",
"pageSummary": "صفحه {page} از {totalPages} ({total} وظیفه)" "pageSummary": "صفحه {page} از {totalPages} ({total} وظیفه)"
}, },
"caseComments": {
"title": "نظرات",
"placeholder": "یک نظر بنویسید…",
"reply": "پاسخ…",
"post": "ثبت",
"empty": "هنوز نظری ثبت نشده است.",
"visibleToClinicToggle": "قابل مشاهده برای کلینیک",
"clinicCanSee": "کلینیک می‌تواند ببیند",
"hiddenFromClinic": "پنهان از کلینیک",
"makeVisible": "نمایش به کلینیک",
"makeHidden": "پنهان از کلینیک",
"labAuthor": "آزمایشگاه",
"clinicAuthor": "کلینیک",
"errorLoad": "بارگذاری نظرات ناموفق بود.",
"errorPost": "ثبت نظر ناموفق بود.",
"errorToggle": "به‌روزرسانی وضعیت نمایش نظر ناموفق بود."
},
"appointments": { "appointments": {
"title": "نوبت‌ها", "title": "نوبت‌ها",
"subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائه‌دهنده کلیک کنید تا رزرو کنید.", "subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائه‌دهنده کلیک کنید تا رزرو کنید.",

View File

@@ -319,7 +319,7 @@
}, },
"cases": { "cases": {
"title": "Dossiers", "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…", "searchPlaceholder": "Zoeken op patiëntnaam of mobiel…",
"emptyList": "Nog geen dossiers ontvangen.", "emptyList": "Nog geen dossiers ontvangen.",
"selectCaseHint": "Selecteer een dossier uit de lijst om taken te bekijken.", "selectCaseHint": "Selecteer een dossier uit de lijst om taken te bekijken.",
@@ -329,13 +329,17 @@
"taskProgressShort": "{progress} taken", "taskProgressShort": "{progress} taken",
"treatmentDetails": "Behandeldetails", "treatmentDetails": "Behandeldetails",
"teethLabel": "Tanden", "teethLabel": "Tanden",
"tasksByTooth": "Taken per tand", "tasksByTooth": "Taken",
"toothGroupTitle": "Tand {tooth} · {prosthesis} · {type}", "toothGroupTitle": "Tanden {teeth} · {prosthesis}",
"noTasks": "Er zijn geen taken gegenereerd voor dit dossier.", "noTasks": "Er zijn geen taken gegenereerd voor dit dossier.",
"unassigned": "Niet toegewezen",
"statusPending": "In afwachting",
"statusInProgress": "Bezig", "statusInProgress": "Bezig",
"statusCompleted": "Voltooid", "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.", "errorLoadList": "Dossiers laden mislukt.",
"errorLoadDetail": "Dossierdetails laden mislukt.", "errorLoadDetail": "Dossierdetails laden mislukt.",
"errorUpdateTask": "Taak bijwerken mislukt.", "errorUpdateTask": "Taak bijwerken mislukt.",
@@ -351,32 +355,63 @@
"prevPage": "Vorige", "prevPage": "Vorige",
"nextPage": "Volgende", "nextPage": "Volgende",
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)", "pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
"priorityLabel": "Prioriteit",
"statusLabel": "Status" "statusLabel": "Status"
}, },
"tasks": { "tasks": {
"title": "Taken", "title": "Taken",
"subtitle": "Uw toegewezen labtaken. Werk de status bij terwijl u elke stap uitvoert.", "subtitle": "Alle labtaken van gekoppelde klinieken. Filter, sorteer en werk de status van elke stap bij.",
"subtitleOwner": "Alle labtaken in de organisatie. Wijs toe via Dossiers; werk hier de status bij voor uw eigen taken.", "subtitleOwner": "Alle labtaken van gekoppelde klinieken. Filter, sorteer en werk de status van elke stap bij.",
"loading": "Taken laden…", "loading": "Taken laden…",
"emptyList": "Nog geen taken aan u toegewezen.", "emptyList": "Geen taken komen overeen met de huidige filters.",
"emptyListOwner": "Nog geen taken in de lab-inbox.", "emptyListOwner": "Geen taken komen overeen met de huidige filters.",
"noPermissionTitle": "Taken", "noPermissionTitle": "Taken",
"noPermissionBody": "U heeft geen toestemming om taken voor deze organisatie te bekijken.", "noPermissionBody": "U heeft geen toestemming om taken voor deze organisatie te bekijken.",
"fromClinic": "Van {name}", "fromClinic": "Van {name}",
"patientLabel": "Patiënt", "patientLabel": "Patiënt",
"taskDate": "{date}", "taskDate": "{date}",
"priorityLabel": "Prioriteit {n}", "teethLabel": "Tanden {teeth}",
"toothLabel": "Tand {tooth}", "importantBadge": "Belangrijk",
"unassigned": "Niet toegewezen", "lastUpdatedBy": "Bijgewerkt door {name}",
"assignedTo": "Toegewezen aan {name}",
"statusPending": "In afwachting",
"statusInProgress": "Bezig", "statusInProgress": "Bezig",
"statusCompleted": "Voltooid", "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.", "errorLoadList": "Taken laden mislukt.",
"errorUpdateTask": "Taak bijwerken mislukt.", "errorUpdateTask": "Taak bijwerken mislukt.",
"pageSummary": "Pagina {page} van {totalPages} ({total} taken)" "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": { "appointments": {
"title": "Afspraken", "title": "Afspraken",
"subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.", "subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.",

View File

@@ -17,16 +17,18 @@ import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar'; import { SearchBar } from '@/components/ui/shared/SearchBar';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { import type {
AssignableMember,
CasesFilterOptions, CasesFilterOptions,
LabCaseDetail, LabCaseDetail,
LabCaseListItem, LabCaseListItem,
LabTaskStatus, LabTaskStatus,
PaginatedLabCases, PaginatedLabCases,
} from '@/types/cases'; } from '@/types/cases';
import {
formatToothList,
prosthesisTypeBadgeStyle,
} from '@/components/ui/treatment/prosthesisTypeDisplay';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const;
function taskStatusVariant(status: LabTaskStatus): BadgeVariant { function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
switch (status) { switch (status) {
@@ -100,7 +102,6 @@ export default function CasesPage() {
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null); const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null); const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
const [members, setMembers] = useState<AssignableMember[]>([]);
const [loadingList, setLoadingList] = useState(false); const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null); const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
@@ -115,7 +116,6 @@ export default function CasesPage() {
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [ () => [
{ value: 'PENDING', label: t('statusPending') },
{ value: 'IN_PROGRESS', label: t('statusInProgress') }, { value: 'IN_PROGRESS', label: t('statusInProgress') },
{ value: 'COMPLETED', label: t('statusCompleted') }, { value: 'COMPLETED', label: t('statusCompleted') },
], ],
@@ -171,7 +171,6 @@ export default function CasesPage() {
useEffect(() => { useEffect(() => {
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {}); 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(() => {}); void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
}, []); }, []);
@@ -216,25 +215,14 @@ export default function CasesPage() {
setPage(1); setPage(1);
} }
async function handleTaskUpdate( async function handleImportantToggle(taskId: string, isImportant: boolean) {
taskId: string,
payload: { assigneeUserId?: string | null; priority?: number },
) {
if (!selectedCaseId || !canEdit) return; if (!selectedCaseId || !canEdit) return;
setUpdatingTaskId(taskId); setUpdatingTaskId(taskId);
toast.setError(''); toast.setError('');
try { try {
await casesApi.updateTask(selectedCaseId, taskId, payload); await casesApi.setTaskImportant(selectedCaseId, taskId, isImportant);
await loadDetail(selectedCaseId); await loadDetail(selectedCaseId);
await loadCases({
q: search,
clinicOrganizationId: clinicId,
treatmentType,
sentFrom,
sentTo,
page,
});
} catch (error: unknown) { } catch (error: unknown) {
toast.showError(formatApiErrorMessage(error, t('errorUpdateTask'))); toast.showError(formatApiErrorMessage(error, t('errorUpdateTask')));
} finally { } finally {
@@ -476,65 +464,65 @@ export default function CasesPage() {
{selectedCase.tasksByTooth.length === 0 ? ( {selectedCase.tasksByTooth.length === 0 ? (
<p className="text-sm text-text-muted">{t('noTasks')}</p> <p className="text-sm text-text-muted">{t('noTasks')}</p>
) : ( ) : (
selectedCase.tasksByTooth.map((group) => ( selectedCase.tasksByTooth.map((group, groupIndex) => (
<div <div
key={`${group.tooth}-${group.treatmentType}`} key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
className="rounded-md border border-border p-3 space-y-2" className="rounded-md border border-border p-3 space-y-2"
> >
<div className="text-sm font-medium text-text-primary"> <div className="flex flex-wrap items-center gap-2">
{t('toothGroupTitle', { <span
tooth: group.tooth, className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium border"
prosthesis: group.prosthesisTypeLabel, style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
type: treatmentLabel(group.treatmentType), >
})} {group.prosthesisTypeLabel}
</span>
<span className="text-sm font-medium text-text-primary">
{t('toothGroupTitle', {
teeth: formatToothList(group.teeth),
prosthesis: group.prosthesisTypeLabel,
})}
</span>
</div> </div>
<ul className="space-y-2"> <ul className="space-y-2">
{group.tasks.map((task) => ( {group.tasks.map((task) => (
<li <li
key={task.id} key={task.id}
className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto_88px_180px] items-center text-sm rounded bg-background p-2" className="rounded bg-background p-2 text-sm space-y-1"
> >
<span> <div className="flex flex-wrap items-center gap-2">
{task.stepOrder}. {task.stepLabel} <span className="min-w-0 flex-1">
</span> {task.stepOrder}. {task.stepLabel}
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}> </span>
{statusOptions.find((opt) => opt.value === task.status)?.label ?? <Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
task.status} {statusOptions.find((opt) => opt.value === task.status)?.label ??
</Badge> task.status}
<select </Badge>
value={task.priority} {canEdit ? (
disabled={!canEdit || updatingTaskId === task.id} <label className="flex items-center gap-1.5 text-xs cursor-pointer shrink-0">
onChange={(e) => <input
void handleTaskUpdate(task.id, { type="checkbox"
priority: Number(e.target.value), checked={task.isImportant}
}) disabled={updatingTaskId === task.id}
} onChange={(e) =>
className={FORM_SELECT_CLASS} void handleImportantToggle(task.id, e.target.checked)
aria-label={t('priorityLabel')} }
> />
{PRIORITY_OPTIONS.map((value) => ( {t('importantLabel')}
<option key={value} value={value}> </label>
{value} ) : task.isImportant ? (
</option> <Badge variant="warning" fixedWidth={false}>
))} {t('importantLabel')}
</select> </Badge>
<select ) : null}
value={task.assigneeUserId ?? ''} </div>
disabled={!canEdit || updatingTaskId === task.id} <p className="text-[11px] text-text-muted">
onChange={(e) => {task.lastStatusChangedBy
void handleTaskUpdate(task.id, { ? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })
assigneeUserId: e.target.value || null, : t('lastUpdatedUnknown')}
}) {task.lastStatusChangedAt
} ? ` · ${formatDateTime(task.lastStatusChangedAt, locale)}`
className={FORM_SELECT_CLASS} : ''}
> </p>
<option value="">{t('unassigned')}</option>
{members.map((member) => (
<option key={member.userId} value={member.userId}>
{member.name}
</option>
))}
</select>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -1,12 +1,19 @@
'use client'; 'use client';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { MessageSquare } from 'lucide-react';
import { ToastStack } from '@/components/ui/shared/Toast'; import { ToastStack } from '@/components/ui/shared/Toast';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge'; import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; 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 { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { canEditTasks, canViewTasks } from '@/components/shared/permissions'; import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
@@ -14,20 +21,19 @@ import { useToast } from '@/lib/hooks/useToast';
import { tasksApi } from '@/lib/api/tasks'; import { tasksApi } from '@/lib/api/tasks';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; 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'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
function taskStatusVariant(status: LabTaskStatus): BadgeVariant { function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
switch (status) { return status === 'COMPLETED' ? 'success' : 'default';
case 'COMPLETED':
return 'success';
case 'IN_PROGRESS':
return 'default';
default:
return 'warning';
}
} }
function formatPatientName(patient: { firstName: string; lastName: string }) { function formatPatientName(patient: { firstName: string; lastName: string }) {
@@ -50,64 +56,94 @@ export default function TasksPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null); const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]); const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
const [expandedCommentsCaseId, setExpandedCommentsCaseId] = useState<string | null>(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<TaskSortField>('date');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const canView = canViewTasks(currentOrganization); const canView = canViewTasks(currentOrganization);
const canEdit = canEditTasks(currentOrganization); const canEdit = canEditTasks(currentOrganization);
const locale = user?.language ?? 'en'; const locale = user?.language ?? 'en';
const isOwner = Boolean(currentOrganization?.isOwner);
const tRef = useRef(t); const tRef = useRef(t);
tRef.current = t; tRef.current = t;
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [ () => [
{ value: 'PENDING', label: t('statusPending') },
{ value: 'IN_PROGRESS', label: t('statusInProgress') }, { value: 'IN_PROGRESS', label: t('statusInProgress') },
{ value: 'COMPLETED', label: t('statusCompleted') }, { value: 'COMPLETED', label: t('statusCompleted') },
], ],
[t], [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<string, string>();
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(() => { useEffect(() => {
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!canView) return; if (!canView) return;
const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0);
let cancelled = false; return () => clearTimeout(timeout);
}, [canView, loadTasks, search]);
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]);
async function handleStatusUpdate(taskId: string, status: LabTaskStatus) { async function handleStatusUpdate(taskId: string, status: LabTaskStatus) {
if (!canEdit) return; if (!canEdit) return;
setUpdatingTaskId(taskId); setUpdatingTaskId(taskId);
setError(''); setError('');
try { try {
await tasksApi.updateStatus(taskId, status); await tasksApi.updateStatus(taskId, status);
const response = await tasksApi.list({ page, limit: PAGE_SIZE }); await loadTasks();
setTasks(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) { } catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorUpdateTask'))); showError(formatApiErrorMessage(error, t('errorUpdateTask')));
} finally { } finally {
@@ -123,9 +159,7 @@ export default function TasksPage() {
}).format(new Date(value)); }).format(new Date(value));
} }
function sortDateForTask(task: LabTaskListItem) { const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`;
return task.assignedAt ?? task.createdAt;
}
if (!isAuthReady) { if (!isAuthReady) {
return <div className="text-sm text-text-muted">{t('loading')}</div>; return <div className="text-sm text-text-muted">{t('loading')}</div>;
@@ -144,89 +178,229 @@ export default function TasksPage() {
<div className="space-y-4"> <div className="space-y-4">
<header className="space-y-1"> <header className="space-y-1">
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1> <h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">{t('subtitle')}</p>
{isOwner ? t('subtitleOwner') : t('subtitle')}
</p>
</header> </header>
<section className="surface-card p-3 space-y-3">
<SearchBar
embedded
value={search}
onChange={(v) => {
setSearch(v);
setPage(1);
}}
placeholder={t('searchPlaceholder')}
/>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
<select
value={clinicId}
onChange={(e) => {
setClinicId(e.target.value);
setPage(1);
}}
className={filterSelectClass}
>
<option value="">{t('filterClinicAll')}</option>
{clinicOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('filterStatus')}</span>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value as '' | LabTaskStatus);
setPage(1);
}}
className={filterSelectClass}
>
<option value="">{t('filterStatusAll')}</option>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-text-muted">{t('sortBy')}</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as TaskSortField)}
className={filterSelectClass}
>
<option value="date">{t('sortDate')}</option>
<option value="status">{t('sortStatus')}</option>
<option value="clinic">{t('sortClinic')}</option>
<option value="patient">{t('sortPatient')}</option>
<option value="important">{t('sortImportant')}</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-text-muted"> </span>
<select
value={sortDir}
onChange={(e) => setSortDir(e.target.value as 'asc' | 'desc')}
className={filterSelectClass}
>
<option value="desc"></option>
<option value="asc"></option>
</select>
</label>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showCompleted}
onChange={(e) => {
setShowCompleted(e.target.checked);
setPage(1);
}}
/>
{t('showCompleted')}
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={importantOnly}
onChange={(e) => {
setImportantOnly(e.target.checked);
setPage(1);
}}
/>
{t('importantOnly')}
</label>
</div>
</section>
<section className="surface-card min-h-[280px]"> <section className="surface-card min-h-[280px]">
{loading && tasks.length === 0 ? ( {loading && tasks.length === 0 ? (
<p className="p-3 text-sm text-text-muted">{t('loading')}</p> <p className="p-3 text-sm text-text-muted">{t('loading')}</p>
) : tasks.length === 0 ? ( ) : tasks.length === 0 ? (
<p className="p-3 text-sm text-text-muted"> <p className="p-3 text-sm text-text-muted">{t('emptyList')}</p>
{isOwner ? t('emptyListOwner') : t('emptyList')}
</p>
) : ( ) : (
<ul className="divide-y divide-border"> <ul className="divide-y divide-border">
{tasks.map((task) => { {tasks.map((task, index) => {
const statusEditable = const commentsOpen = expandedCommentsCaseId === task.labCaseId;
canEdit && (isOwner || task.assigneeUserId === user?.id);
return ( return (
<li <li key={task.id}>
key={task.id} <div className="grid grid-cols-[minmax(0,1fr)_132px_auto] items-center gap-x-3 gap-y-0.5 px-3 py-2">
className="grid grid-cols-[minmax(0,1fr)_132px_auto] items-center gap-x-3 gap-y-0.5 px-3 py-2" <div className="min-w-0">
> <div className="flex flex-wrap items-center gap-1.5">
<div className="min-w-0"> <p className="text-sm font-medium text-text-primary">
<p className="text-sm font-medium text-text-primary truncate"> {task.stepOrder}. {task.stepLabel}
{task.stepOrder}. {task.stepLabel} </p>
</p> {task.isImportant ? (
<p className="text-[11px] text-text-secondary truncate"> <Badge variant="warning" fixedWidth={false}>
{t('fromClinic', { name: task.clinic.name })} ·{' '} {t('importantBadge')}
{formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })}
{task.prosthesisTypeLabel ? ` · ${task.prosthesisTypeLabel}` : ''}
</p>
<p className="text-[11px] text-text-muted truncate flex flex-wrap items-center gap-x-1 gap-y-0.5">
<span>{t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}</span>
{isOwner && (
<>
<span aria-hidden>·</span>
<Badge
variant={task.assignee ? 'success' : 'danger'}
fixedWidth={false}
>
{task.assignee
? t('assignedTo', { name: task.assignee.name })
: t('unassigned')}
</Badge> </Badge>
</> ) : null}
<span
className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border"
style={prosthesisTypeBadgeStyle(task.prosthesisTypeCode, index)}
>
{task.prosthesisTypeLabel}
</span>
</div>
<p className="text-[11px] text-text-secondary truncate">
{t('fromClinic', { name: task.clinic.name })} ·{' '}
{formatPatientName(task.patient)} ·{' '}
{t('teethLabel', { teeth: formatToothList(task.teeth) })}
</p>
<p className="text-[11px] text-text-muted truncate">
<span>{t('taskDate', { date: formatTaskDate(task.createdAt) })}</span>
{task.lastStatusChangedBy ? (
<>
<span aria-hidden> · </span>
<span>
{t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}
</span>
</>
) : null}
</p>
</div>
<div className="flex justify-center">
{canEdit ? (
<select
value={task.status}
disabled={updatingTaskId === task.id}
onChange={(e) =>
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
}
className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`}
>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
)} )}
</p> </div>
<div className="flex items-center gap-1.5 shrink-0 justify-end">
{canEdit ? (
<button
type="button"
onClick={() =>
setExpandedCommentsCaseId(commentsOpen ? null : task.labCaseId)
}
className={`p-1.5 rounded border ${
commentsOpen
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-text-muted hover:border-primary/40'
}`}
title={t('commentsButton')}
>
<MessageSquare className="h-4 w-4" />
</button>
) : null}
<TreatmentTypeBadge
type={task.treatmentType}
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
/>
</div>
</div> </div>
<div className="flex justify-center"> {commentsOpen && canEdit ? (
{statusEditable ? ( <div className="px-3 pb-3 border-t border-border/50">
<select <LabCaseCommentsPanel
value={task.status} caseId={task.labCaseId}
disabled={updatingTaskId === task.id} canPost
onChange={(e) => canToggleVisibility
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus) loadComments={async () => {
} const r = await tasksApi.listComments(task.labCaseId);
className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`} return r.data;
> }}
{statusOptions.map((opt) => ( onPost={async (body, visibleToClinic) => {
<option key={opt.value} value={opt.value}> const r = await tasksApi.addComment(task.labCaseId, {
{opt.label} body,
</option> visibleToClinic,
))} });
</select> return r.data;
) : ( }}
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}> onToggleVisibility={async (commentId, visible) => {
{statusOptions.find((opt) => opt.value === task.status)?.label ?? const r = await tasksApi.setCommentVisibility(commentId, visible);
task.status} return r.data;
</Badge> }}
)} onError={showError}
</div> />
</div>
<div className="flex items-center gap-1.5 shrink-0 justify-end"> ) : null}
<Badge variant="default" fixedWidth={false}>
{t('priorityLabel', { n: task.priority })}
</Badge>
<TreatmentTypeBadge
type={task.treatmentType}
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
/>
</div>
</li> </li>
); );
})} })}

View File

@@ -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<LabCaseComment[]>;
onPost: (body: string, visibleToClinic?: boolean) => Promise<LabCaseComment>;
onToggleVisibility?: (commentId: string, visible: boolean) => Promise<LabCaseComment>;
onError?: (message: string) => void;
}
export function LabCaseCommentsPanel({
caseId,
canPost,
canToggleVisibility,
loadComments,
onPost,
onToggleVisibility,
onError,
}: LabCaseCommentsPanelProps) {
const t = useTranslations('caseComments');
const [comments, setComments] = useState<LabCaseComment[]>([]);
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 (
<div className="space-y-3">
<h4 className="text-sm font-medium text-text-primary">{t('title')}</h4>
{loading ? (
<p className="text-xs text-text-muted"></p>
) : comments.length === 0 ? (
<p className="text-xs text-text-muted">{t('empty')}</p>
) : (
<ul className="space-y-2 max-h-48 overflow-y-auto">
{comments.map((comment) => (
<li
key={comment.id}
className="rounded-md border border-border bg-background p-2 text-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-text-muted">
<span className="font-medium text-text-secondary">
{comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
{comment.authorName ? ` · ${comment.authorName}` : ''}
</span>
{comment.visibleToClinic ? (
<span className="text-primary">{t('clinicCanSee')}</span>
) : (
<span>{t('hiddenFromClinic')}</span>
)}
</div>
<p className="mt-1 text-text-primary whitespace-pre-wrap">{comment.body}</p>
</div>
{canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? (
<button
type="button"
onClick={() => void handleToggle(comment)}
className="shrink-0 p-1 rounded hover:bg-border text-text-muted"
title={
comment.visibleToClinic ? t('makeHidden') : t('makeVisible')
}
aria-label={
comment.visibleToClinic ? t('makeHidden') : t('makeVisible')
}
>
{comment.visibleToClinic ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</button>
) : null}
</div>
</li>
))}
</ul>
)}
{canPost ? (
<div className="space-y-2 border-t border-border pt-2">
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={t('placeholder')}
rows={2}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm resize-none"
/>
{canToggleVisibility ? (
<label className="flex items-center gap-2 text-xs text-text-muted cursor-pointer">
<input
type="checkbox"
checked={visibleToClinic}
onChange={(e) => setVisibleToClinic(e.target.checked)}
/>
{t('visibleToClinicToggle')}
</label>
) : null}
<Button
type="button"
size="sm"
disabled={posting || !body.trim()}
onClick={() => void handlePost()}
>
{t('post')}
</Button>
</div>
) : null}
</div>
);
}

View File

@@ -12,6 +12,11 @@ import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { SearchBar } from '@/components/ui/shared/SearchBar'; import { SearchBar } from '@/components/ui/shared/SearchBar';
import { ToastStack } from '@/components/ui/shared/Toast'; import { ToastStack } from '@/components/ui/shared/Toast';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import {
formatToothList,
prosthesisTypeBadgeStyle,
} from '@/components/ui/treatment/prosthesisTypeDisplay';
import type { CounterpartItemDto } from '@/lib/api/organization'; import type { CounterpartItemDto } from '@/lib/api/organization';
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases'; import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
@@ -109,7 +114,6 @@ export function ConnectionCaseHistoryContent({
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [ () => [
{ value: 'PENDING', label: tCases('statusPending') },
{ value: 'IN_PROGRESS', label: tCases('statusInProgress') }, { value: 'IN_PROGRESS', label: tCases('statusInProgress') },
{ value: 'COMPLETED', label: tCases('statusCompleted') }, { value: 'COMPLETED', label: tCases('statusCompleted') },
], ],
@@ -363,17 +367,24 @@ export function ConnectionCaseHistoryContent({
{selectedCase.tasksByTooth.length === 0 ? ( {selectedCase.tasksByTooth.length === 0 ? (
<p className="text-sm text-text-muted">{tCases('noTasks')}</p> <p className="text-sm text-text-muted">{tCases('noTasks')}</p>
) : ( ) : (
selectedCase.tasksByTooth.map((group) => ( selectedCase.tasksByTooth.map((group, groupIndex) => (
<div <div
key={`${group.tooth}-${group.treatmentType}`} key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
className="rounded-md border border-border p-3 space-y-2" className="rounded-md border border-border p-3 space-y-2"
> >
<div className="text-sm font-medium text-text-primary"> <div className="flex flex-wrap items-center gap-2">
{tCases('toothGroupTitle', { <span
tooth: group.tooth, className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium border"
prosthesis: group.prosthesisTypeLabel, style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
type: treatmentLabel(group.treatmentType), >
})} {group.prosthesisTypeLabel}
</span>
<span className="text-sm font-medium text-text-primary">
{tCases('toothGroupTitle', {
teeth: formatToothList(group.teeth),
prosthesis: group.prosthesisTypeLabel,
})}
</span>
</div> </div>
<ul className="space-y-2"> <ul className="space-y-2">
{group.tasks.map((task) => ( {group.tasks.map((task) => (
@@ -388,6 +399,11 @@ export function ConnectionCaseHistoryContent({
{statusOptions.find((opt) => opt.value === task.status)?.label ?? {statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status} task.status}
</Badge> </Badge>
{task.lastStatusChangedBy ? (
<span className="text-[11px] text-text-muted">
{tCases('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}
</span>
) : null}
</li> </li>
))} ))}
</ul> </ul>
@@ -395,6 +411,30 @@ export function ConnectionCaseHistoryContent({
)) ))
)} )}
</div> </div>
{isClinic && selectedCaseId ? (
<LabCaseCommentsPanel
caseId={selectedCaseId}
canPost
canToggleVisibility={false}
loadComments={async () => {
const r = await organizationApi.listConnectionCaseComments(
connection.id,
selectedCaseId,
);
return r.data;
}}
onPost={async (body) => {
const r = await organizationApi.addConnectionCaseComment(
connection.id,
selectedCaseId,
body,
);
return r.data;
}}
onError={showError}
/>
) : null}
</div> </div>
)} )}
</section> </section>

View File

@@ -69,6 +69,27 @@ function labCaseDraftsToPast(
})); }));
} }
function enrichDetailsWithLabSendState(
details: TreatmentDetailDraft[],
labCaseDrafts: LabCaseDraft[],
): TreatmentDetailDraft[] {
return details.map((detail) => {
const sentLabCase = labCaseDrafts.find(
(lc) => lc.sentAt && lc.detailClientIds.includes(detail.clientId),
);
if (!sentLabCase) return detail;
return {
...detail,
labCaseId: sentLabCase.id ?? detail.labCaseId,
sentAt: sentLabCase.sentAt ?? detail.sentAt,
sends: sentLabCase.sends ?? detail.sends,
sendToOrganizationIds: sentLabCase.destinationOrganizationId
? [sentLabCase.destinationOrganizationId]
: detail.sendToOrganizationIds,
};
});
}
function buildWorkspaceSnapshot( function buildWorkspaceSnapshot(
appointment: TreatmentAppointment, appointment: TreatmentAppointment,
details: TreatmentDetailDraft[], details: TreatmentDetailDraft[],
@@ -76,8 +97,9 @@ function buildWorkspaceSnapshot(
title: string, title: string,
id?: string, id?: string,
): PastTreatment { ): PastTreatment {
const detailsForPreview = enrichDetailsWithLabSendState(details, labCaseDrafts);
return { return {
...detailsToPreviewTreatment(details, { ...detailsToPreviewTreatment(detailsForPreview, {
id: id ?? `preview-${appointment.id}`, id: id ?? `preview-${appointment.id}`,
title, title,
patientId: appointment.patientId, patientId: appointment.patientId,
@@ -844,6 +866,22 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const response = await treatmentsApi.sendLabCase(refreshedLabCase.id); const response = await treatmentsApi.sendLabCase(refreshedLabCase.id);
const sentDetailClientIds = new Set(labCase.detailClientIds);
setDetails((prev) =>
prev.map((detail) => {
if (!sentDetailClientIds.has(detail.clientId)) return detail;
return {
...detail,
labCaseId: response.data.id,
sentAt: response.data.sentAt,
sends: response.data.sends,
sendToOrganizationIds: response.data.destinationOrganizationId
? [response.data.destinationOrganizationId]
: detail.sendToOrganizationIds,
};
}),
);
setLabCaseDrafts((prev) => setLabCaseDrafts((prev) =>
prev.map((lc) => prev.map((lc) =>
lc.clientId === labCase.clientId lc.clientId === labCase.clientId

View File

@@ -0,0 +1,72 @@
import type { CSSProperties } from 'react';
/**
* Prosthesis-type colors for lab-facing surfaces (Tasks list, Cases detail group
* headers / badges). Grouped by material family, loosely inspired by exocad's
* material color conventions:
* - Zirconia family → pale green/cream
* - PFM / full metal → steel gray
* - Glass-ceramic / IPS (press & CAD) → warm amber
* - Resin / PMMA / PEEK / temporary → mint/teal
* - Abutments / screw-retained → slate blue
* - Smile design / mockup → lavender/pink
*
* Clinic-facing dispatch flows intentionally do NOT use these colors.
*/
const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
// Zirconia family
monolithic_zirconia: '#d9f2e6',
pfz_crown: '#c7ede0',
veneer_zirconia: '#b8e6d5',
zirconia_abutment: '#a7dcc8',
zirconia_overlay: '#cdeede',
// PFM / metal
pfm_crown: '#cbd5e1',
full_metal_crown: '#b8c2cf',
// Glass-ceramic / IPS
glass_ceramic_crown: '#fde3a7',
veneer_ips_press: '#fcd88f',
veneer_ips_cad: '#f9cf9c',
ips_overlay: '#fbe0b0',
// Resin / PMMA / PEEK / temporary
temporary_resin_crown: '#bfeaf0',
pmma: '#a9e2ea',
peek_crown: '#b7e4dd',
soft_structure: '#d4eef0',
// Abutments / screw-retained
customized_abutment: '#aec6e8',
prefabricated_abutment: '#9db8e0',
ti_base_abutment: '#c0d0ec',
multi_unit_abutment: '#b4c4e6',
screw_retained: '#a8bce2',
// Design / mockup
smile_design: '#e9d5ff',
mockup: '#f5d0fe',
};
const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d', '#fbcfe8'];
/** Dark ink that stays readable on every pastel in the palette. */
const BADGE_INK = '#14253d';
export function prosthesisTypeColor(code: string, index = 0): string {
return PROSTHESIS_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
}
/** Filled swatch (small indicator dots). */
export function prosthesisTypeSwatchStyle(code: string, index = 0): CSSProperties {
return { backgroundColor: prosthesisTypeColor(code, index), borderColor: 'rgba(0, 0, 0, 0.18)' };
}
/** Pastel pill / banner fill with readable dark text (group headers, badges). */
export function prosthesisTypeBadgeStyle(code: string, index = 0): CSSProperties {
return {
backgroundColor: prosthesisTypeColor(code, index),
borderColor: 'rgba(0, 0, 0, 0.16)',
color: BADGE_INK,
};
}
export function formatToothList(teeth: string[]): string {
return teeth.join(', ');
}

View File

@@ -1,6 +1,5 @@
import { apiClient } from './client'; import { apiClient } from './client';
import type { import type {
AssignableMember,
CasesFilterOptions, CasesFilterOptions,
LabCaseDetail, LabCaseDetail,
LabCaseTask, LabCaseTask,
@@ -21,22 +20,17 @@ export const casesApi = {
return response.data; return response.data;
}, },
listAssignableMembers: async (): Promise<{ success: boolean; data: AssignableMember[] }> => {
const response = await apiClient.get('/cases/assignable-members');
return response.data;
},
listFilterOptions: async (): Promise<{ success: boolean; data: CasesFilterOptions }> => { listFilterOptions: async (): Promise<{ success: boolean; data: CasesFilterOptions }> => {
const response = await apiClient.get('/cases/filter-options'); const response = await apiClient.get('/cases/filter-options');
return response.data; return response.data;
}, },
updateTask: async ( setTaskImportant: async (
caseId: string, caseId: string,
taskId: string, taskId: string,
payload: { assigneeUserId?: string | null; priority?: number }, isImportant: boolean,
): Promise<{ success: boolean; data: LabCaseTask }> => { ): Promise<{ success: boolean; data: LabCaseTask }> => {
const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, payload); const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, { isImportant });
return response.data; return response.data;
}, },
}; };

View File

@@ -1,5 +1,6 @@
import { apiClient } from './client'; import { apiClient } from './client';
import type { import type {
LabCaseComment,
LabCaseDetail, LabCaseDetail,
ListLabCasesParams, ListLabCasesParams,
PaginatedLabCases, PaginatedLabCases,
@@ -161,4 +162,26 @@ export const organizationApi = {
); );
return response.data; return response.data;
}, },
listConnectionCaseComments: async (
connectionId: string,
caseId: string,
): Promise<{ success: boolean; data: LabCaseComment[] }> => {
const response = await apiClient.get(
`/organizations/connections/${connectionId}/cases/${caseId}/comments`,
);
return response.data;
},
addConnectionCaseComment: async (
connectionId: string,
caseId: string,
body: string,
): Promise<{ success: boolean; data: LabCaseComment }> => {
const response = await apiClient.post(
`/organizations/connections/${connectionId}/cases/${caseId}/comments`,
{ body },
);
return response.data;
},
}; };

View File

@@ -1,11 +1,16 @@
import { apiClient } from './client'; import { apiClient } from './client';
import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases'; import type {
LabCaseComment,
LabTaskListItem,
LabTaskStatus,
ListLabTasksParams,
PaginatedLabTasks,
} from '@/types/cases';
export const tasksApi = { export const tasksApi = {
list: async (params: { page?: number; limit?: number } = {}): Promise<{ list: async (
success: boolean; params: ListLabTasksParams = {},
data: PaginatedLabTasks; ): Promise<{ success: boolean; data: PaginatedLabTasks }> => {
}> => {
const response = await apiClient.get('/tasks', { params }); const response = await apiClient.get('/tasks', { params });
return response.data; return response.data;
}, },
@@ -17,4 +22,29 @@ export const tasksApi = {
const response = await apiClient.patch(`/tasks/${taskId}`, { status }); const response = await apiClient.patch(`/tasks/${taskId}`, { status });
return response.data; return response.data;
}, },
listComments: async (
caseId: string,
): Promise<{ success: boolean; data: LabCaseComment[] }> => {
const response = await apiClient.get(`/case-comments/${caseId}`);
return response.data;
},
addComment: async (
caseId: string,
payload: { body: string; visibleToClinic?: boolean },
): Promise<{ success: boolean; data: LabCaseComment }> => {
const response = await apiClient.post(`/case-comments/${caseId}`, payload);
return response.data;
},
setCommentVisibility: async (
commentId: string,
visibleToClinic: boolean,
): Promise<{ success: boolean; data: LabCaseComment }> => {
const response = await apiClient.patch(`/case-comments/item/${commentId}/visibility`, {
visibleToClinic,
});
return response.data;
},
}; };

View File

@@ -1,4 +1,4 @@
export type LabTaskStatus = 'PENDING' | 'IN_PROGRESS' | 'COMPLETED'; export type LabTaskStatus = 'IN_PROGRESS' | 'COMPLETED';
export interface LabCaseListItem { export interface LabCaseListItem {
id: string; id: string;
@@ -14,9 +14,23 @@ export interface LabCaseListItem {
taskProgress: { completed: number; total: number }; taskProgress: { completed: number; total: number };
} }
export interface LabTaskUser {
id: string;
name: string;
}
export interface LabTaskTimelineEvent {
id: string;
fromStatus: LabTaskStatus | null;
toStatus: LabTaskStatus;
changedAt: string;
changedBy: LabTaskUser | null;
}
export interface LabCaseTask { export interface LabCaseTask {
id: string; id: string;
tooth: string; treatmentDetailId: string;
teeth: string[];
treatmentType: string; treatmentType: string;
prosthesisTypeCode: string; prosthesisTypeCode: string;
prosthesisTypeLabel: string; prosthesisTypeLabel: string;
@@ -24,21 +38,33 @@ export interface LabCaseTask {
stepOrder: number; stepOrder: number;
stepLabel: string; stepLabel: string;
status: LabTaskStatus; status: LabTaskStatus;
priority: number; isImportant: boolean;
assignedAt: string | null;
createdAt: string; createdAt: string;
assigneeUserId: string | null; lastStatusChangedAt: string | null;
assignee: { id: string; name: string; email: string } | null; lastStatusChangedBy: LabTaskUser | null;
timeline: LabTaskTimelineEvent[];
} }
export interface LabCaseTasksByTooth { export interface LabCaseTaskGroup {
tooth: string; treatmentDetailId: string;
teeth: string[];
treatmentType: string; treatmentType: string;
prosthesisTypeCode: string; prosthesisTypeCode: string;
prosthesisTypeLabel: string; prosthesisTypeLabel: string;
tasks: LabCaseTask[]; tasks: LabCaseTask[];
} }
export interface LabCaseComment {
id: string;
body: string;
authorSide: 'LAB' | 'CLINIC';
authorName: string | null;
authorOrganizationName: string | null;
visibleToClinic: boolean;
createdAt: string;
canToggleVisibility: boolean;
}
export interface LabCaseDetail { export interface LabCaseDetail {
id: string; id: string;
sentAt: string | null; sentAt: string | null;
@@ -64,17 +90,10 @@ export interface LabCaseDetail {
sentAt: string; sentAt: string;
}>; }>;
tasks: LabCaseTask[]; tasks: LabCaseTask[];
tasksByTooth: LabCaseTasksByTooth[]; tasksByTooth: LabCaseTaskGroup[];
taskProgress: { completed: number; total: number }; taskProgress: { completed: number; total: number };
} }
export interface AssignableMember {
userId: string;
name: string;
email: string;
isOwner: boolean;
}
export interface ListLabCasesParams { export interface ListLabCasesParams {
q?: string; q?: string;
page?: number; page?: number;
@@ -100,21 +119,38 @@ export interface PaginatedLabCases {
}; };
} }
export type TaskSortField = 'date' | 'status' | 'clinic' | 'patient' | 'important';
export interface ListLabTasksParams {
q?: string;
clinicOrganizationId?: string;
status?: LabTaskStatus;
completed?: boolean;
important?: boolean;
sentFrom?: string;
sentTo?: string;
sortBy?: TaskSortField;
sortDir?: 'asc' | 'desc';
page?: number;
limit?: number;
}
export interface LabTaskListItem { export interface LabTaskListItem {
id: string; id: string;
labCaseId: string; labCaseId: string;
tooth: string; treatmentDetailId: string;
teeth: string[];
treatmentType: string; treatmentType: string;
prosthesisTypeCode: string; prosthesisTypeCode: string;
prosthesisTypeLabel: string; prosthesisTypeLabel: string;
workflowStepCode: string;
stepOrder: number; stepOrder: number;
stepLabel: string; stepLabel: string;
status: LabTaskStatus; status: LabTaskStatus;
priority: number; isImportant: boolean;
assignedAt: string | null; lastStatusChangedAt: string | null;
lastStatusChangedBy: LabTaskUser | null;
createdAt: string; createdAt: string;
assigneeUserId: string | null;
assignee: { id: string; name: string; email: string } | null;
clinic: { id: string; name: string }; clinic: { id: string; name: string };
patient: { id: string; firstName: string; lastName: string }; patient: { id: string; firstName: string; lastName: string };
} }