feature/cases #55
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
60
backend/prisma/regenerate-lab-tasks.ts
Normal file
60
backend/prisma/regenerate-lab-tasks.ts
Normal 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();
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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[],
|
||||||
|
|||||||
11
backend/src/modules/cases/lab-case-task.util.ts
Normal file
11
backend/src/modules/cases/lab-case-task.util.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (task.status !== dto.status) {
|
||||||
|
await tx.labCaseTaskStatusEvent.create({
|
||||||
|
data: {
|
||||||
|
taskId,
|
||||||
|
fromStatus: task.status,
|
||||||
|
toStatus: dto.status,
|
||||||
|
changedByUserId: actorUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.prisma.labCaseTask.update({
|
return result;
|
||||||
where: { id: taskId },
|
|
||||||
data: { status: dto.status },
|
|
||||||
include: taskListInclude,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائهدهنده کلیک کنید تا رزرو کنید.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium border"
|
||||||
|
style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
|
||||||
|
>
|
||||||
|
{group.prosthesisTypeLabel}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-text-primary">
|
||||||
{t('toothGroupTitle', {
|
{t('toothGroupTitle', {
|
||||||
tooth: group.tooth,
|
teeth: formatToothList(group.teeth),
|
||||||
prosthesis: group.prosthesisTypeLabel,
|
prosthesis: group.prosthesisTypeLabel,
|
||||||
type: treatmentLabel(group.treatmentType),
|
|
||||||
})}
|
})}
|
||||||
|
</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">
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
{task.stepOrder}. {task.stepLabel}
|
{task.stepOrder}. {task.stepLabel}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
|
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
|
||||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||||
task.status}
|
task.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
<select
|
{canEdit ? (
|
||||||
value={task.priority}
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer shrink-0">
|
||||||
disabled={!canEdit || updatingTaskId === task.id}
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={task.isImportant}
|
||||||
|
disabled={updatingTaskId === task.id}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
void handleTaskUpdate(task.id, {
|
void handleImportantToggle(task.id, e.target.checked)
|
||||||
priority: Number(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
className={FORM_SELECT_CLASS}
|
/>
|
||||||
aria-label={t('priorityLabel')}
|
{t('importantLabel')}
|
||||||
>
|
</label>
|
||||||
{PRIORITY_OPTIONS.map((value) => (
|
) : task.isImportant ? (
|
||||||
<option key={value} value={value}>
|
<Badge variant="warning" fixedWidth={false}>
|
||||||
{value}
|
{t('importantLabel')}
|
||||||
</option>
|
</Badge>
|
||||||
))}
|
) : null}
|
||||||
</select>
|
</div>
|
||||||
<select
|
<p className="text-[11px] text-text-muted">
|
||||||
value={task.assigneeUserId ?? ''}
|
{task.lastStatusChangedBy
|
||||||
disabled={!canEdit || updatingTaskId === task.id}
|
? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })
|
||||||
onChange={(e) =>
|
: t('lastUpdatedUnknown')}
|
||||||
void handleTaskUpdate(task.id, {
|
{task.lastStatusChangedAt
|
||||||
assigneeUserId: e.target.value || null,
|
? ` · ${formatDateTime(task.lastStatusChangedAt, locale)}`
|
||||||
})
|
: ''}
|
||||||
}
|
</p>
|
||||||
className={FORM_SELECT_CLASS}
|
|
||||||
>
|
|
||||||
<option value="">{t('unassigned')}</option>
|
|
||||||
{members.map((member) => (
|
|
||||||
<option key={member.userId} value={member.userId}>
|
|
||||||
{member.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -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,58 +178,158 @@ 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="min-w-0">
|
||||||
<p className="text-sm font-medium text-text-primary truncate">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<p className="text-sm font-medium text-text-primary">
|
||||||
{task.stepOrder}. {task.stepLabel}
|
{task.stepOrder}. {task.stepLabel}
|
||||||
</p>
|
</p>
|
||||||
|
{task.isImportant ? (
|
||||||
|
<Badge variant="warning" fixedWidth={false}>
|
||||||
|
{t('importantBadge')}
|
||||||
|
</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">
|
<p className="text-[11px] text-text-secondary truncate">
|
||||||
{t('fromClinic', { name: task.clinic.name })} ·{' '}
|
{t('fromClinic', { name: task.clinic.name })} ·{' '}
|
||||||
{formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })}
|
{formatPatientName(task.patient)} ·{' '}
|
||||||
{task.prosthesisTypeLabel ? ` · ${task.prosthesisTypeLabel}` : ''}
|
{t('teethLabel', { teeth: formatToothList(task.teeth) })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-text-muted truncate flex flex-wrap items-center gap-x-1 gap-y-0.5">
|
<p className="text-[11px] text-text-muted truncate">
|
||||||
<span>{t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}</span>
|
<span>{t('taskDate', { date: formatTaskDate(task.createdAt) })}</span>
|
||||||
{isOwner && (
|
{task.lastStatusChangedBy ? (
|
||||||
<>
|
<>
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden> · </span>
|
||||||
<Badge
|
<span>
|
||||||
variant={task.assignee ? 'success' : 'danger'}
|
{t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}
|
||||||
fixedWidth={false}
|
</span>
|
||||||
>
|
|
||||||
{task.assignee
|
|
||||||
? t('assignedTo', { name: task.assignee.name })
|
|
||||||
: t('unassigned')}
|
|
||||||
</Badge>
|
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
{statusEditable ? (
|
{canEdit ? (
|
||||||
<select
|
<select
|
||||||
value={task.status}
|
value={task.status}
|
||||||
disabled={updatingTaskId === task.id}
|
disabled={updatingTaskId === task.id}
|
||||||
@@ -219,14 +353,54 @@ export default function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0 justify-end">
|
<div className="flex items-center gap-1.5 shrink-0 justify-end">
|
||||||
<Badge variant="default" fixedWidth={false}>
|
{canEdit ? (
|
||||||
{t('priorityLabel', { n: task.priority })}
|
<button
|
||||||
</Badge>
|
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
|
<TreatmentTypeBadge
|
||||||
type={task.treatmentType}
|
type={task.treatmentType}
|
||||||
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
|
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{commentsOpen && canEdit ? (
|
||||||
|
<div className="px-3 pb-3 border-t border-border/50">
|
||||||
|
<LabCaseCommentsPanel
|
||||||
|
caseId={task.labCaseId}
|
||||||
|
canPost
|
||||||
|
canToggleVisibility
|
||||||
|
loadComments={async () => {
|
||||||
|
const r = await tasksApi.listComments(task.labCaseId);
|
||||||
|
return r.data;
|
||||||
|
}}
|
||||||
|
onPost={async (body, visibleToClinic) => {
|
||||||
|
const r = await tasksApi.addComment(task.labCaseId, {
|
||||||
|
body,
|
||||||
|
visibleToClinic,
|
||||||
|
});
|
||||||
|
return r.data;
|
||||||
|
}}
|
||||||
|
onToggleVisibility={async (commentId, visible) => {
|
||||||
|
const r = await tasksApi.setCommentVisibility(commentId, visible);
|
||||||
|
return r.data;
|
||||||
|
}}
|
||||||
|
onError={showError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
164
frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx
Normal file
164
frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium border"
|
||||||
|
style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
|
||||||
|
>
|
||||||
|
{group.prosthesisTypeLabel}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-text-primary">
|
||||||
{tCases('toothGroupTitle', {
|
{tCases('toothGroupTitle', {
|
||||||
tooth: group.tooth,
|
teeth: formatToothList(group.teeth),
|
||||||
prosthesis: group.prosthesisTypeLabel,
|
prosthesis: group.prosthesisTypeLabel,
|
||||||
type: treatmentLabel(group.treatmentType),
|
|
||||||
})}
|
})}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(', ');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user