improvement: tasks and cases feature updated based on the new prosthesis types and their steps. the whole assignment proccess removed from the flow.
This commit is contained in:
@@ -22,7 +22,8 @@
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:deploy": "prisma migrate deploy",
|
||||
"prisma:seed": "prisma db seed",
|
||||
"prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts"
|
||||
"prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts",
|
||||
"prisma:regenerate-tasks": "ts-node prisma/regenerate-lab-tasks.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
|
||||
@@ -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.
|
||||
const TABLES_IN_ORDER = [
|
||||
'lab_case_task_status_events',
|
||||
'lab_case_comments',
|
||||
'lab_case_tasks',
|
||||
'lab_case_sends',
|
||||
'lab_case_tooth_prosthesis',
|
||||
|
||||
@@ -23,7 +23,9 @@ model User {
|
||||
sessions Session[] // 👈 ADD THIS - opposite relation for Session
|
||||
sentStaffInvites StaffInvitation[]
|
||||
sentOrganizationInvitations OrganizationInvitation[]
|
||||
assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee")
|
||||
statusChangedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskLastStatusChangedBy")
|
||||
labCaseTaskStatusEvents LabCaseTaskStatusEvent[]
|
||||
labCaseComments LabCaseComment[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -65,6 +67,7 @@ model Organization {
|
||||
appointments Appointment[]
|
||||
treatments Treatment[]
|
||||
labCaseSends LabCaseSend[]
|
||||
labCaseComments LabCaseComment[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -115,11 +118,15 @@ model Appointment {
|
||||
}
|
||||
|
||||
enum LabTaskStatus {
|
||||
PENDING
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
enum LabCaseCommentSide {
|
||||
LAB
|
||||
CLINIC
|
||||
}
|
||||
|
||||
model Treatment {
|
||||
id String @id @default(uuid())
|
||||
organizationId String
|
||||
@@ -194,6 +201,7 @@ model LabCase {
|
||||
sends LabCaseSend[]
|
||||
tasks LabCaseTask[]
|
||||
toothProsthesis LabCaseToothProsthesis[]
|
||||
comments LabCaseComment[]
|
||||
|
||||
@@index([treatmentId, sortOrder])
|
||||
@@map("lab_cases")
|
||||
@@ -306,31 +314,66 @@ model LabCaseTask {
|
||||
id String @id @default(uuid())
|
||||
labCaseId String
|
||||
treatmentDetailId String
|
||||
tooth String
|
||||
teeth Json
|
||||
treatmentType String
|
||||
prosthesisTypeCode String
|
||||
workflowStepCode String
|
||||
stepOrder Int
|
||||
stepLabel String
|
||||
assigneeUserId String?
|
||||
assignedAt DateTime?
|
||||
priority Int @default(3)
|
||||
status LabTaskStatus @default(PENDING)
|
||||
isImportant Boolean @default(false)
|
||||
status LabTaskStatus @default(IN_PROGRESS)
|
||||
lastStatusChangedByUserId String?
|
||||
lastStatusChangedAt DateTime?
|
||||
|
||||
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
||||
detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
|
||||
assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull)
|
||||
lastStatusChangedBy User? @relation("LabCaseTaskLastStatusChangedBy", fields: [lastStatusChangedByUserId], references: [id], onDelete: SetNull)
|
||||
statusEvents LabCaseTaskStatusEvent[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([labCaseId, treatmentDetailId, tooth, stepOrder])
|
||||
@@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder])
|
||||
@@index([labCaseId, status])
|
||||
@@index([assigneeUserId, priority, createdAt])
|
||||
@@index([assignedAt, labCaseId, priority])
|
||||
@@index([labCaseId, isImportant])
|
||||
@@map("lab_case_tasks")
|
||||
}
|
||||
|
||||
model LabCaseTaskStatusEvent {
|
||||
id String @id @default(uuid())
|
||||
taskId String
|
||||
fromStatus LabTaskStatus?
|
||||
toStatus LabTaskStatus
|
||||
changedByUserId String?
|
||||
changedAt DateTime @default(now())
|
||||
|
||||
task LabCaseTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
changedBy User? @relation(fields: [changedByUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([taskId, changedAt])
|
||||
@@map("lab_case_task_status_events")
|
||||
}
|
||||
|
||||
model LabCaseComment {
|
||||
id String @id @default(uuid())
|
||||
labCaseId String
|
||||
authorUserId String?
|
||||
authorOrganizationId String?
|
||||
authorSide LabCaseCommentSide
|
||||
body String
|
||||
visibleToClinic Boolean @default(false)
|
||||
|
||||
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
||||
authorUser User? @relation(fields: [authorUserId], references: [id], onDelete: SetNull)
|
||||
authorOrganization Organization? @relation(fields: [authorOrganizationId], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([labCaseId, createdAt])
|
||||
@@map("lab_case_comments")
|
||||
}
|
||||
|
||||
model Plan {
|
||||
id String @id @default(uuid())
|
||||
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"
|
||||
|
||||
@@ -16,6 +16,7 @@ import { TasksModule } from './modules/tasks/tasks.module';
|
||||
import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module';
|
||||
import { CatalogModule } from './modules/catalog/catalog.module';
|
||||
import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis-catalog.module';
|
||||
import { LabCaseCommentsModule } from './modules/lab-case-comments/lab-case-comments.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -33,6 +34,7 @@ import { ProsthesisCatalogModule } from './modules/prosthesis-catalog/prosthesis
|
||||
TreatmentsModule,
|
||||
CasesModule,
|
||||
TasksModule,
|
||||
LabCaseCommentsModule,
|
||||
StaffModule,
|
||||
OrganizationModule,
|
||||
AdminModule.forRoot(),
|
||||
|
||||
@@ -35,13 +35,6 @@ export class CasesController {
|
||||
return this.casesService.listFilterOptions(organizationId, req.user.id);
|
||||
}
|
||||
|
||||
@Get('assignable-members')
|
||||
@ApiOperation({ summary: 'List lab staff who can be assigned to tasks' })
|
||||
listAssignableMembers(@Req() req) {
|
||||
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
||||
return this.casesService.listAssignableMembers(organizationId, req.user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' })
|
||||
getOne(@Param('id') id: string, @Req() req) {
|
||||
@@ -50,7 +43,7 @@ export class CasesController {
|
||||
}
|
||||
|
||||
@Patch(':id/tasks/:taskId')
|
||||
@ApiOperation({ summary: 'Update task assignee or priority' })
|
||||
@ApiOperation({ summary: 'Toggle task important flag' })
|
||||
updateTask(
|
||||
@Param('id') id: string,
|
||||
@Param('taskId') taskId: string,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||
import { normalizeTeeth } from '../treatments/treatment.utils';
|
||||
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
|
||||
import { normalizeTaskTeeth } from './lab-case-task.util';
|
||||
|
||||
const labCaseListInclude = {
|
||||
treatment: {
|
||||
@@ -41,16 +42,29 @@ const labCaseListInclude = {
|
||||
},
|
||||
tasks: {
|
||||
orderBy: [
|
||||
{ tooth: 'asc' as const },
|
||||
{ treatmentType: 'asc' as const },
|
||||
{ treatmentDetailId: 'asc' as const },
|
||||
{ prosthesisTypeCode: 'asc' as const },
|
||||
{ stepOrder: 'asc' as const },
|
||||
],
|
||||
include: {
|
||||
assignee: { select: { id: true, name: true, email: true } },
|
||||
lastStatusChangedBy: { select: { id: true, name: true } },
|
||||
statusEvents: {
|
||||
orderBy: { changedAt: 'asc' as const },
|
||||
include: { changedBy: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.LabCaseInclude;
|
||||
|
||||
type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
|
||||
include: {
|
||||
lastStatusChangedBy: { select: { id: true; name: true } };
|
||||
statusEvents: {
|
||||
include: { changedBy: { select: { id: true; name: true } } };
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class CasesService {
|
||||
constructor(
|
||||
@@ -294,23 +308,15 @@ export class CasesService {
|
||||
throw new NotFoundException('Task not found');
|
||||
}
|
||||
|
||||
if (dto.assigneeUserId !== undefined && dto.assigneeUserId !== null) {
|
||||
await this.ensureAssignableMember(dto.assigneeUserId, labOrganizationId);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.labCaseTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
...(dto.assigneeUserId !== undefined
|
||||
? {
|
||||
assigneeUserId: dto.assigneeUserId,
|
||||
assignedAt: dto.assigneeUserId === null ? null : new Date(),
|
||||
}
|
||||
: {}),
|
||||
...(dto.priority !== undefined ? { priority: dto.priority } : {}),
|
||||
},
|
||||
data: { isImportant: dto.isImportant },
|
||||
include: {
|
||||
assignee: { select: { id: true, name: true, email: true } },
|
||||
lastStatusChangedBy: { select: { id: true, name: true } },
|
||||
statusEvents: {
|
||||
orderBy: { changedAt: 'asc' },
|
||||
include: { changedBy: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -324,35 +330,6 @@ export class CasesService {
|
||||
return { success: true, data: this.mapTask(updated, prosthesisLabels) };
|
||||
}
|
||||
|
||||
async listAssignableMembers(labOrganizationId: string, actorUserId: string) {
|
||||
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
||||
|
||||
const memberships = await this.prisma.membership.findMany({
|
||||
where: { organizationId: labOrganizationId, isActive: true },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
permissions: { include: { permission: true } },
|
||||
},
|
||||
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: memberships
|
||||
.filter((m) => {
|
||||
if (m.isOwner) return true;
|
||||
const names = m.permissions.map((p) => p.permission.name);
|
||||
return names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT');
|
||||
})
|
||||
.map((m) => ({
|
||||
userId: m.user.id,
|
||||
name: m.user.name,
|
||||
email: m.user.email,
|
||||
isOwner: m.isOwner,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private buildListWhere(
|
||||
labOrganizationId: string,
|
||||
query: ListLabCasesDto,
|
||||
@@ -465,7 +442,7 @@ export class CasesService {
|
||||
prosthesisCodes,
|
||||
locale,
|
||||
);
|
||||
const tasksByTooth = this.groupTasksByTooth(lc.tasks, prosthesisLabels);
|
||||
const tasksByTooth = this.groupTasks(lc.tasks, prosthesisLabels);
|
||||
|
||||
return {
|
||||
id: lc.id,
|
||||
@@ -495,27 +472,15 @@ export class CasesService {
|
||||
};
|
||||
}
|
||||
|
||||
private groupTasksByTooth(
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
tooth: string;
|
||||
treatmentType: string;
|
||||
prosthesisTypeCode: string;
|
||||
stepOrder: number;
|
||||
stepLabel: string;
|
||||
status: LabTaskStatus;
|
||||
priority: number;
|
||||
assigneeUserId: string | null;
|
||||
assignedAt: Date | null;
|
||||
createdAt: Date;
|
||||
assignee: { id: string; name: string; email: string } | null;
|
||||
}>,
|
||||
private groupTasks(
|
||||
tasks: LabCaseTaskWithRelations[],
|
||||
prosthesisLabels: Map<string, string>,
|
||||
) {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
tooth: string;
|
||||
treatmentDetailId: string;
|
||||
teeth: string[];
|
||||
treatmentType: string;
|
||||
prosthesisTypeCode: string;
|
||||
prosthesisTypeLabel: string;
|
||||
@@ -524,9 +489,10 @@ export class CasesService {
|
||||
>();
|
||||
|
||||
for (const task of tasks) {
|
||||
const key = `${task.tooth}:${task.treatmentType}:${task.prosthesisTypeCode}`;
|
||||
const key = `${task.treatmentDetailId}:${task.prosthesisTypeCode}`;
|
||||
const entry = groups.get(key) ?? {
|
||||
tooth: task.tooth,
|
||||
treatmentDetailId: task.treatmentDetailId,
|
||||
teeth: normalizeTaskTeeth(task.teeth),
|
||||
treatmentType: task.treatmentType,
|
||||
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||
prosthesisTypeLabel:
|
||||
@@ -541,26 +507,13 @@ export class CasesService {
|
||||
}
|
||||
|
||||
private mapTask(
|
||||
task: {
|
||||
id: string;
|
||||
tooth: string;
|
||||
treatmentType: string;
|
||||
prosthesisTypeCode: string;
|
||||
workflowStepCode?: string;
|
||||
stepOrder: number;
|
||||
stepLabel: string;
|
||||
status: LabTaskStatus;
|
||||
priority: number;
|
||||
assigneeUserId: string | null;
|
||||
assignedAt: Date | null;
|
||||
createdAt: Date;
|
||||
assignee: { id: string; name: string; email: string } | null;
|
||||
},
|
||||
task: LabCaseTaskWithRelations,
|
||||
prosthesisLabels: Map<string, string>,
|
||||
) {
|
||||
return {
|
||||
id: task.id,
|
||||
tooth: task.tooth,
|
||||
treatmentDetailId: task.treatmentDetailId,
|
||||
teeth: normalizeTaskTeeth(task.teeth),
|
||||
treatmentType: task.treatmentType,
|
||||
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||
prosthesisTypeLabel:
|
||||
@@ -569,32 +522,24 @@ export class CasesService {
|
||||
stepOrder: task.stepOrder,
|
||||
stepLabel: task.stepLabel,
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
assignedAt: task.assignedAt?.toISOString() ?? null,
|
||||
isImportant: task.isImportant,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
assigneeUserId: task.assigneeUserId,
|
||||
assignee: task.assignee
|
||||
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
|
||||
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
|
||||
lastStatusChangedBy: task.lastStatusChangedBy
|
||||
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
|
||||
: null,
|
||||
timeline: task.statusEvents.map((event) => ({
|
||||
id: event.id,
|
||||
fromStatus: event.fromStatus,
|
||||
toStatus: event.toStatus,
|
||||
changedAt: event.changedAt.toISOString(),
|
||||
changedBy: event.changedBy
|
||||
? { id: event.changedBy.id, name: event.changedBy.name }
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureAssignableMember(userId: string, labOrganizationId: string) {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId: labOrganizationId, isActive: true },
|
||||
include: { permissions: { include: { permission: true } } },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new BadRequestException('Assignee must be an active member of this lab');
|
||||
}
|
||||
if (membership.isOwner) return;
|
||||
const names = membership.permissions.map((p) => p.permission.name);
|
||||
if (names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new BadRequestException('Assignee must have access to the Tasks tab');
|
||||
}
|
||||
|
||||
private async assertCanReadCases(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator';
|
||||
import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
|
||||
export class UpdateLabCaseTaskDto {
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value !== null)
|
||||
@IsUUID()
|
||||
assigneeUserId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
priority?: number;
|
||||
@IsBoolean()
|
||||
isImportant: boolean;
|
||||
}
|
||||
|
||||
export class ListLabCasesDto {
|
||||
|
||||
@@ -101,10 +101,11 @@ describe('generateLabCaseTasks', () => {
|
||||
expect(count).toBe(pfmSteps.length);
|
||||
expect(created).toHaveLength(pfmSteps.length);
|
||||
expect(created[0]).toMatchObject({
|
||||
tooth: '14',
|
||||
teeth: ['14'],
|
||||
prosthesisTypeCode: 'pfm_crown',
|
||||
workflowStepCode: 'intraoral_scan',
|
||||
stepLabel: 'Intraoral Scan',
|
||||
status: 'IN_PROGRESS',
|
||||
});
|
||||
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
|
||||
(row) => row.workflowStepCode,
|
||||
@@ -115,6 +116,35 @@ describe('generateLabCaseTasks', () => {
|
||||
expect(stepCodes).toContain('milling_wet');
|
||||
});
|
||||
|
||||
it('groups teeth sharing a prosthesis in one detail, and keeps other prosthesis separate', async () => {
|
||||
const pfmSteps = stepsFromSeed('pfm_crown');
|
||||
const zirconiaSteps = stepsFromSeed('monolithic_zirconia');
|
||||
const { tx, created } = buildMockTx({
|
||||
toothProsthesisRows: [
|
||||
{ treatmentDetailId: 'detail-1', tooth: '15', prosthesisTypeCode: 'pfm_crown' },
|
||||
{ treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'pfm_crown' },
|
||||
{ treatmentDetailId: 'detail-1', tooth: '16', prosthesisTypeCode: 'monolithic_zirconia' },
|
||||
],
|
||||
prosthesisTypes: [
|
||||
{ code: 'pfm_crown', steps: pfmSteps },
|
||||
{ code: 'monolithic_zirconia', steps: zirconiaSteps },
|
||||
],
|
||||
});
|
||||
|
||||
const count = await generateLabCaseTasks(tx as never, 'lab-case-group', 'en');
|
||||
|
||||
expect(count).toBe(pfmSteps.length + zirconiaSteps.length);
|
||||
const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>;
|
||||
const pfmRows = rows.filter((r) => r.prosthesisTypeCode === 'pfm_crown');
|
||||
const zirconiaRows = rows.filter((r) => r.prosthesisTypeCode === 'monolithic_zirconia');
|
||||
|
||||
expect(pfmRows).toHaveLength(pfmSteps.length);
|
||||
expect(zirconiaRows).toHaveLength(zirconiaSteps.length);
|
||||
// Teeth sharing the prosthesis in the same detail are merged and sorted.
|
||||
expect(pfmRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15']))).toBe(true);
|
||||
expect(zirconiaRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['16']))).toBe(true);
|
||||
});
|
||||
|
||||
it('omits packing and shipping for smile_design', async () => {
|
||||
const smileSteps = stepsFromSeed('smile_design');
|
||||
const { tx, created } = buildMockTx({
|
||||
|
||||
@@ -58,25 +58,46 @@ export async function generateLabCaseTasks(
|
||||
|
||||
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
|
||||
|
||||
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
|
||||
// Group teeth that share the same (treatment detail + prosthesis type): one task set
|
||||
// per group, with each step covering every tooth in that group.
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ treatmentDetailId: string; treatmentType: string; prosthesisTypeCode: string; teeth: string[] }
|
||||
>();
|
||||
|
||||
for (const row of toothProsthesisRows) {
|
||||
const typeSteps = stepsByProsthesisCode.get(row.prosthesisTypeCode) ?? [];
|
||||
const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`;
|
||||
const group = groups.get(key) ?? {
|
||||
treatmentDetailId: row.treatmentDetailId,
|
||||
treatmentType: row.detail.treatmentType,
|
||||
prosthesisTypeCode: row.prosthesisTypeCode,
|
||||
teeth: [],
|
||||
};
|
||||
group.teeth.push(row.tooth);
|
||||
groups.set(key, group);
|
||||
}
|
||||
|
||||
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
|
||||
|
||||
for (const group of groups.values()) {
|
||||
const typeSteps = stepsByProsthesisCode.get(group.prosthesisTypeCode) ?? [];
|
||||
if (typeSteps.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const teeth = sortTeeth(group.teeth);
|
||||
|
||||
for (const step of typeSteps) {
|
||||
taskRows.push({
|
||||
labCaseId,
|
||||
treatmentDetailId: row.treatmentDetailId,
|
||||
tooth: row.tooth,
|
||||
treatmentType: row.detail.treatmentType,
|
||||
prosthesisTypeCode: row.prosthesisTypeCode,
|
||||
treatmentDetailId: group.treatmentDetailId,
|
||||
teeth,
|
||||
treatmentType: group.treatmentType,
|
||||
prosthesisTypeCode: group.prosthesisTypeCode,
|
||||
workflowStepCode: step.workflowStepCode,
|
||||
stepOrder: step.stepOrder,
|
||||
stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode,
|
||||
status: LabTaskStatus.PENDING,
|
||||
status: LabTaskStatus.IN_PROGRESS,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -89,6 +110,15 @@ export async function generateLabCaseTasks(
|
||||
return taskRows.length;
|
||||
}
|
||||
|
||||
function sortTeeth(teeth: string[]): string[] {
|
||||
return [...new Set(teeth)].sort((a, b) => {
|
||||
const na = Number(a);
|
||||
const nb = Number(b);
|
||||
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveStepLabels(
|
||||
tx: TransactionClient,
|
||||
stepCodes: string[],
|
||||
|
||||
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 { OrganizationService } from './organization.service';
|
||||
import { ListLabCasesDto } from '../cases/dto/cases.dto';
|
||||
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
|
||||
|
||||
/**
|
||||
* Counterpart orgs (clinic↔lab).
|
||||
@@ -157,6 +158,42 @@ export class OrganizationController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get('connections/:connectionId/cases/:caseId/comments')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'List clinic-visible comments for a connection case' })
|
||||
listConnectionCaseComments(
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Param('connectionId') connectionId: string,
|
||||
@Param('caseId') caseId: string,
|
||||
) {
|
||||
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
|
||||
return this.organizationService.listConnectionCaseComments(
|
||||
req.user.id,
|
||||
organizationId,
|
||||
connectionId,
|
||||
caseId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('connections/:connectionId/cases/:caseId/comments')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Reply to a connection case as the clinic' })
|
||||
addConnectionCaseComment(
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Param('connectionId') connectionId: string,
|
||||
@Param('caseId') caseId: string,
|
||||
@Body() dto: CreateLabCaseCommentDto,
|
||||
) {
|
||||
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
|
||||
return this.organizationService.addConnectionCaseComment(
|
||||
req.user.id,
|
||||
organizationId,
|
||||
connectionId,
|
||||
caseId,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('invitations/:invitationId/link')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Get a shareable invite link for a pending invitation' })
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { CasesModule } from '../cases/cases.module';
|
||||
import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
|
||||
import { OrganizationController } from './organization.controller';
|
||||
import { OrganizationService } from './organization.service';
|
||||
|
||||
@Module({
|
||||
imports: [CasesModule],
|
||||
imports: [CasesModule, LabCaseCommentsModule],
|
||||
controllers: [OrganizationController],
|
||||
providers: [OrganizationService, PrismaService],
|
||||
})
|
||||
|
||||
@@ -11,6 +11,8 @@ import { createHash, randomBytes } from 'crypto';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { ListLabCasesDto } from '../cases/dto/cases.dto';
|
||||
import { CasesService } from '../cases/cases.service';
|
||||
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
|
||||
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
|
||||
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
|
||||
import { CreateConnectionRequestDto } from './dto/create-connection-request.dto';
|
||||
import { InviteOrganizationDto } from './dto/invite-organization.dto';
|
||||
@@ -33,6 +35,7 @@ export class OrganizationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly casesService: CasesService,
|
||||
private readonly commentsService: LabCaseCommentsService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
@@ -405,6 +408,55 @@ export class OrganizationService {
|
||||
};
|
||||
}
|
||||
|
||||
async listConnectionCaseComments(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
caseId: string,
|
||||
) {
|
||||
const { clinicOrganizationId } = await this.resolveClinicConnection(
|
||||
userId,
|
||||
organizationId,
|
||||
connectionId,
|
||||
);
|
||||
return this.commentsService.listForClinic(caseId, clinicOrganizationId);
|
||||
}
|
||||
|
||||
async addConnectionCaseComment(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
caseId: string,
|
||||
dto: CreateLabCaseCommentDto,
|
||||
) {
|
||||
const { clinicOrganizationId } = await this.resolveClinicConnection(
|
||||
userId,
|
||||
organizationId,
|
||||
connectionId,
|
||||
);
|
||||
return this.commentsService.addForClinic(caseId, clinicOrganizationId, userId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clinic comment surfaces require the actor to belong to the clinic side of the connection.
|
||||
* Only clinic-side members may read/reply to case comments from the connection history.
|
||||
*/
|
||||
private async resolveClinicConnection(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditOrganizations(actor)) {
|
||||
throw new ForbiddenException('You do not have permission to manage organizations');
|
||||
}
|
||||
const parties = await this.resolveActiveConnectionParties(connectionId, organizationId, actor);
|
||||
if (parties.clinicOrganizationId !== organizationId) {
|
||||
throw new ForbiddenException('Only the clinic can comment on this case');
|
||||
}
|
||||
return parties;
|
||||
}
|
||||
|
||||
/** Re-issue a shareable URL for a pending invitation (rotates token; previous URL stops working). */
|
||||
async getInvitationLink(userId: string, organizationId: string, invitationId: string) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
|
||||
@@ -1,13 +1,71 @@
|
||||
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { LabTaskStatus } from '@prisma/client';
|
||||
|
||||
const toBoolean = ({ value }: { value: unknown }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true' || value === '1') return true;
|
||||
if (value === 'false' || value === '0') return false;
|
||||
return value;
|
||||
};
|
||||
|
||||
export class UpdateLabTaskDto {
|
||||
@IsEnum(LabTaskStatus)
|
||||
status: LabTaskStatus;
|
||||
}
|
||||
|
||||
export type TaskSortField = 'date' | 'status' | 'clinic' | 'patient' | 'important';
|
||||
|
||||
export class ListLabTasksDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
clinicOrganizationId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(LabTaskStatus)
|
||||
status?: LabTaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
completed?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
important?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
sentFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
sentTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['date', 'status', 'clinic', 'patient', 'important'])
|
||||
sortBy?: TaskSortField;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['asc', 'desc'])
|
||||
sortDir?: 'asc' | 'desc';
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsInt()
|
||||
|
||||
@@ -13,7 +13,7 @@ export class TasksController {
|
||||
constructor(private readonly tasksService: TasksService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' })
|
||||
@ApiOperation({ summary: 'List lab tasks' })
|
||||
list(@Query() query: ListLabTasksDto, @Req() req) {
|
||||
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
|
||||
return this.tasksService.list(organizationId, req.user.id, query, req.user.language);
|
||||
|
||||
@@ -6,14 +6,16 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { normalizeMobile } from '../../common/phone';
|
||||
import {
|
||||
CatalogLabelService,
|
||||
normalizeCatalogLocale,
|
||||
} from '../catalog/catalog-label.service';
|
||||
import { normalizeTaskTeeth } from '../cases/lab-case-task.util';
|
||||
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
|
||||
|
||||
const taskListInclude = {
|
||||
assignee: { select: { id: true, name: true, email: true } },
|
||||
lastStatusChangedBy: { select: { id: true, name: true } },
|
||||
labCase: {
|
||||
include: {
|
||||
treatment: {
|
||||
@@ -48,35 +50,17 @@ export class TasksService {
|
||||
) {
|
||||
await this.assertCanReadTasks(actorUserId, labOrganizationId);
|
||||
|
||||
const membership = await this.getMembership(actorUserId, labOrganizationId);
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(Math.max(query.limit ?? 50, 1), 100);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: Prisma.LabCaseTaskWhereInput = {
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
...(membership.isOwner ? {} : { assigneeUserId: actorUserId }),
|
||||
};
|
||||
const where = this.buildListWhere(labOrganizationId, query);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.labCaseTask.findMany({
|
||||
where,
|
||||
include: taskListInclude,
|
||||
orderBy: [
|
||||
{ assignedAt: { sort: 'desc', nulls: 'first' } },
|
||||
{ createdAt: 'desc' },
|
||||
{ labCaseId: 'asc' },
|
||||
{ priority: 'desc' },
|
||||
{ stepOrder: 'asc' },
|
||||
{ id: 'asc' },
|
||||
],
|
||||
orderBy: this.buildOrderBy(query),
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
@@ -114,11 +98,6 @@ export class TasksService {
|
||||
) {
|
||||
await this.assertCanEditTasks(actorUserId, labOrganizationId);
|
||||
|
||||
const membership = await this.getMembership(actorUserId, labOrganizationId);
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
|
||||
const task = await this.prisma.labCaseTask.findFirst({
|
||||
where: {
|
||||
id: taskId,
|
||||
@@ -134,14 +113,29 @@ export class TasksService {
|
||||
throw new NotFoundException('Task not found');
|
||||
}
|
||||
|
||||
if (!membership.isOwner && task.assigneeUserId !== actorUserId) {
|
||||
throw new ForbiddenException('You can only update tasks assigned to you');
|
||||
}
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.labCaseTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: dto.status,
|
||||
lastStatusChangedByUserId: actorUserId,
|
||||
lastStatusChangedAt: new Date(),
|
||||
},
|
||||
include: taskListInclude,
|
||||
});
|
||||
|
||||
const updated = await this.prisma.labCaseTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: dto.status },
|
||||
include: taskListInclude,
|
||||
if (task.status !== dto.status) {
|
||||
await tx.labCaseTaskStatusEvent.create({
|
||||
data: {
|
||||
taskId,
|
||||
fromStatus: task.status,
|
||||
toStatus: dto.status,
|
||||
changedByUserId: actorUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const locale = normalizeCatalogLocale(localeInput);
|
||||
@@ -154,6 +148,100 @@ export class TasksService {
|
||||
return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) };
|
||||
}
|
||||
|
||||
private buildListWhere(
|
||||
labOrganizationId: string,
|
||||
query: ListLabTasksDto,
|
||||
): Prisma.LabCaseTaskWhereInput {
|
||||
const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null };
|
||||
|
||||
if (query.sentFrom) {
|
||||
const from = new Date(query.sentFrom);
|
||||
if (Number.isNaN(from.getTime())) {
|
||||
throw new BadRequestException('Invalid sentFrom date');
|
||||
}
|
||||
sentAtFilter.gte = from;
|
||||
}
|
||||
if (query.sentTo) {
|
||||
const to = new Date(query.sentTo);
|
||||
if (Number.isNaN(to.getTime())) {
|
||||
throw new BadRequestException('Invalid sentTo date');
|
||||
}
|
||||
to.setHours(23, 59, 59, 999);
|
||||
sentAtFilter.lte = to;
|
||||
}
|
||||
|
||||
// Status: explicit status wins; completed=true/false narrows; otherwise no status filter.
|
||||
let status: LabTaskStatus | undefined;
|
||||
if (query.status) {
|
||||
status = query.status;
|
||||
} else if (query.completed === true) {
|
||||
status = LabTaskStatus.COMPLETED;
|
||||
} else if (query.completed === false) {
|
||||
status = LabTaskStatus.IN_PROGRESS;
|
||||
}
|
||||
|
||||
return {
|
||||
labCase: {
|
||||
sentAt: sentAtFilter,
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
...(query.clinicOrganizationId
|
||||
? { treatment: { organizationId: query.clinicOrganizationId } }
|
||||
: {}),
|
||||
...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}),
|
||||
},
|
||||
...(status !== undefined ? { status } : {}),
|
||||
...(query.important !== undefined ? { isImportant: query.important } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private buildSearchWhere(q: string): Prisma.TreatmentWhereInput {
|
||||
const orConditions: Prisma.PatientWhereInput[] = [
|
||||
{ firstName: { contains: q, mode: 'insensitive' } },
|
||||
{ lastName: { contains: q, mode: 'insensitive' } },
|
||||
];
|
||||
const normalized = normalizeMobile(q);
|
||||
if (normalized) {
|
||||
orConditions.push({ mobile: normalized });
|
||||
}
|
||||
return {
|
||||
OR: [
|
||||
{ patient: { OR: orConditions } },
|
||||
{ organization: { name: { contains: q, mode: 'insensitive' } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private buildOrderBy(query: ListLabTasksDto): Prisma.LabCaseTaskOrderByWithRelationInput[] {
|
||||
const dir = query.sortDir ?? 'desc';
|
||||
switch (query.sortBy) {
|
||||
case 'status':
|
||||
return [{ status: dir }, { createdAt: 'desc' }, { id: 'asc' }];
|
||||
case 'clinic':
|
||||
return [
|
||||
{ labCase: { treatment: { organization: { name: dir } } } },
|
||||
{ createdAt: 'desc' },
|
||||
{ id: 'asc' },
|
||||
];
|
||||
case 'patient':
|
||||
return [
|
||||
{ labCase: { treatment: { patient: { lastName: dir } } } },
|
||||
{ labCase: { treatment: { patient: { firstName: dir } } } },
|
||||
{ id: 'asc' },
|
||||
];
|
||||
case 'important':
|
||||
return [{ isImportant: dir }, { createdAt: 'desc' }, { id: 'asc' }];
|
||||
case 'date':
|
||||
default:
|
||||
return [
|
||||
{ labCase: { sentAt: dir } },
|
||||
{ labCaseId: 'asc' },
|
||||
{ treatmentDetailId: 'asc' },
|
||||
{ stepOrder: 'asc' },
|
||||
{ id: 'asc' },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private mapTaskListItem(
|
||||
task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
|
||||
prosthesisLabels: Map<string, string>,
|
||||
@@ -161,21 +249,22 @@ export class TasksService {
|
||||
return {
|
||||
id: task.id,
|
||||
labCaseId: task.labCaseId,
|
||||
tooth: task.tooth,
|
||||
treatmentDetailId: task.treatmentDetailId,
|
||||
teeth: normalizeTaskTeeth(task.teeth),
|
||||
treatmentType: task.treatmentType,
|
||||
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||
prosthesisTypeLabel:
|
||||
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
|
||||
workflowStepCode: task.workflowStepCode,
|
||||
stepOrder: task.stepOrder,
|
||||
stepLabel: task.stepLabel,
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
assignedAt: task.assignedAt?.toISOString() ?? null,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
assigneeUserId: task.assigneeUserId,
|
||||
assignee: task.assignee
|
||||
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
|
||||
isImportant: task.isImportant,
|
||||
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
|
||||
lastStatusChangedBy: task.lastStatusChangedBy
|
||||
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
|
||||
: null,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
clinic: task.labCase.treatment.organization,
|
||||
patient: {
|
||||
id: task.labCase.treatment.patient.id,
|
||||
|
||||
@@ -319,7 +319,7 @@
|
||||
},
|
||||
"cases": {
|
||||
"title": "Cases",
|
||||
"subtitle": "Lab cases sent from linked clinics. Assign tasks and track progress by tooth.",
|
||||
"subtitle": "Lab cases sent from linked clinics. Track progress and flag important tasks.",
|
||||
"searchPlaceholder": "Search by patient name or mobile…",
|
||||
"emptyList": "No cases received yet.",
|
||||
"selectCaseHint": "Select a case from the list to view tasks.",
|
||||
@@ -329,13 +329,17 @@
|
||||
"taskProgressShort": "{progress} tasks",
|
||||
"treatmentDetails": "Treatment details",
|
||||
"teethLabel": "Teeth",
|
||||
"tasksByTooth": "Tasks by tooth",
|
||||
"toothGroupTitle": "Tooth {tooth} · {prosthesis} · {type}",
|
||||
"tasksByTooth": "Tasks",
|
||||
"toothGroupTitle": "Teeth {teeth} · {prosthesis}",
|
||||
"noTasks": "No tasks were generated for this case.",
|
||||
"unassigned": "Unassigned",
|
||||
"statusPending": "Pending",
|
||||
"statusInProgress": "In progress",
|
||||
"statusCompleted": "Completed",
|
||||
"importantLabel": "Important",
|
||||
"markImportant": "Mark as important",
|
||||
"lastUpdatedBy": "Updated by {name}",
|
||||
"lastUpdatedUnknown": "Not started yet",
|
||||
"timelineTitle": "History",
|
||||
"timelineEntry": "{status} · {name} · {date}",
|
||||
"errorLoadList": "Failed to load cases.",
|
||||
"errorLoadDetail": "Failed to load case details.",
|
||||
"errorUpdateTask": "Failed to update task.",
|
||||
@@ -351,32 +355,63 @@
|
||||
"prevPage": "Previous",
|
||||
"nextPage": "Next",
|
||||
"pageSummary": "Page {page} of {totalPages} ({total} cases)",
|
||||
"priorityLabel": "Priority",
|
||||
"statusLabel": "Status"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Tasks",
|
||||
"subtitle": "Your assigned lab tasks. Update status as you work through each step.",
|
||||
"subtitleOwner": "All lab tasks in the organization. Assign tasks from Cases; update status on your own assignments here.",
|
||||
"subtitle": "All lab tasks from connected clinics. Filter, sort, and update the status of each step.",
|
||||
"subtitleOwner": "All lab tasks from connected clinics. Filter, sort, and update the status of each step.",
|
||||
"loading": "Loading tasks…",
|
||||
"emptyList": "No tasks assigned to you yet.",
|
||||
"emptyListOwner": "No tasks in the lab inbox yet.",
|
||||
"emptyList": "No tasks match the current filters.",
|
||||
"emptyListOwner": "No tasks match the current filters.",
|
||||
"noPermissionTitle": "Tasks",
|
||||
"noPermissionBody": "You do not have permission to view tasks for this organization.",
|
||||
"fromClinic": "From {name}",
|
||||
"patientLabel": "Patient",
|
||||
"taskDate": "{date}",
|
||||
"priorityLabel": "Priority {n}",
|
||||
"toothLabel": "Tooth {tooth}",
|
||||
"unassigned": "Unassigned",
|
||||
"assignedTo": "Assigned to {name}",
|
||||
"statusPending": "Pending",
|
||||
"teethLabel": "Teeth {teeth}",
|
||||
"importantBadge": "Important",
|
||||
"lastUpdatedBy": "Updated by {name}",
|
||||
"statusInProgress": "In progress",
|
||||
"statusCompleted": "Completed",
|
||||
"searchPlaceholder": "Search patient or clinic…",
|
||||
"filterClinic": "Clinic",
|
||||
"filterClinicAll": "All clinics",
|
||||
"filterStatus": "Status",
|
||||
"filterStatusAll": "All statuses",
|
||||
"showCompleted": "Show completed",
|
||||
"importantOnly": "Important only",
|
||||
"filterSentFrom": "From",
|
||||
"filterSentTo": "To",
|
||||
"sortBy": "Sort by",
|
||||
"sortDate": "Date",
|
||||
"sortStatus": "Status",
|
||||
"sortClinic": "Clinic",
|
||||
"sortPatient": "Patient",
|
||||
"sortImportant": "Important",
|
||||
"clearFilters": "Clear filters",
|
||||
"commentsButton": "Comments",
|
||||
"errorLoadList": "Failed to load tasks.",
|
||||
"errorUpdateTask": "Failed to update task.",
|
||||
"pageSummary": "Page {page} of {totalPages} ({total} tasks)"
|
||||
},
|
||||
"caseComments": {
|
||||
"title": "Comments",
|
||||
"placeholder": "Write a comment…",
|
||||
"reply": "Reply…",
|
||||
"post": "Post",
|
||||
"empty": "No comments yet.",
|
||||
"visibleToClinicToggle": "Visible to clinic",
|
||||
"clinicCanSee": "Clinic can see this",
|
||||
"hiddenFromClinic": "Hidden from clinic",
|
||||
"makeVisible": "Make visible to clinic",
|
||||
"makeHidden": "Hide from clinic",
|
||||
"labAuthor": "Lab",
|
||||
"clinicAuthor": "Clinic",
|
||||
"errorLoad": "Failed to load comments.",
|
||||
"errorPost": "Failed to post comment.",
|
||||
"errorToggle": "Failed to update comment visibility."
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Appointments",
|
||||
"subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.",
|
||||
|
||||
@@ -319,7 +319,7 @@
|
||||
},
|
||||
"cases": {
|
||||
"title": "پروندهها",
|
||||
"subtitle": "پروندههای ارسالی از کلینیکهای متصل. وظایف را تخصیص دهید و پیشرفت هر دندان را پیگیری کنید.",
|
||||
"subtitle": "پروندههای ارسالی از کلینیکهای متصل. پیشرفت را پیگیری کنید و وظایف مهم را علامت بزنید.",
|
||||
"searchPlaceholder": "جستجو با نام یا موبایل بیمار…",
|
||||
"emptyList": "هنوز پروندهای دریافت نشده است.",
|
||||
"selectCaseHint": "برای مشاهده وظایف، یک پرونده از فهرست انتخاب کنید.",
|
||||
@@ -329,13 +329,17 @@
|
||||
"taskProgressShort": "{progress} وظیفه",
|
||||
"treatmentDetails": "جزئیات درمان",
|
||||
"teethLabel": "دندانها",
|
||||
"tasksByTooth": "وظایف به تفکیک دندان",
|
||||
"toothGroupTitle": "دندان {tooth} · {prosthesis} · {type}",
|
||||
"tasksByTooth": "وظایف",
|
||||
"toothGroupTitle": "دندانهای {teeth} · {prosthesis}",
|
||||
"noTasks": "برای این پرونده وظیفهای ایجاد نشده است.",
|
||||
"unassigned": "بدون مسئول",
|
||||
"statusPending": "در انتظار",
|
||||
"statusInProgress": "در حال انجام",
|
||||
"statusCompleted": "انجام شده",
|
||||
"importantLabel": "مهم",
|
||||
"markImportant": "علامتگذاری به عنوان مهم",
|
||||
"lastUpdatedBy": "بهروزرسانی توسط {name}",
|
||||
"lastUpdatedUnknown": "هنوز شروع نشده",
|
||||
"timelineTitle": "تاریخچه",
|
||||
"timelineEntry": "{status} · {name} · {date}",
|
||||
"errorLoadList": "بارگذاری پروندهها ناموفق بود.",
|
||||
"errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.",
|
||||
"errorUpdateTask": "بهروزرسانی وظیفه ناموفق بود.",
|
||||
@@ -351,32 +355,63 @@
|
||||
"prevPage": "قبلی",
|
||||
"nextPage": "بعدی",
|
||||
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
|
||||
"priorityLabel": "اولویت",
|
||||
"statusLabel": "وضعیت"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "وظایف",
|
||||
"subtitle": "وظایف لاب اختصاصیافته به شما. وضعیت را در حین انجام هر مرحله بهروز کنید.",
|
||||
"subtitleOwner": "همه وظایف لاب در سازمان. تخصیص از بخش پروندهها؛ بهروزرسانی وضعیت برای وظایف خودتان اینجا.",
|
||||
"subtitle": "همه وظایف لاب از کلینیکهای متصل. فیلتر، مرتبسازی و بهروزرسانی وضعیت هر مرحله.",
|
||||
"subtitleOwner": "همه وظایف لاب از کلینیکهای متصل. فیلتر، مرتبسازی و بهروزرسانی وضعیت هر مرحله.",
|
||||
"loading": "در حال بارگذاری وظایف…",
|
||||
"emptyList": "هنوز وظیفهای به شما اختصاص داده نشده است.",
|
||||
"emptyListOwner": "هنوز وظیفهای در صندوق ورودی لاب وجود ندارد.",
|
||||
"emptyList": "هیچ وظیفهای با فیلترهای فعلی مطابقت ندارد.",
|
||||
"emptyListOwner": "هیچ وظیفهای با فیلترهای فعلی مطابقت ندارد.",
|
||||
"noPermissionTitle": "وظایف",
|
||||
"noPermissionBody": "شما مجوز مشاهده وظایف برای این سازمان را ندارید.",
|
||||
"fromClinic": "از {name}",
|
||||
"patientLabel": "بیمار",
|
||||
"taskDate": "{date}",
|
||||
"priorityLabel": "اولویت {n}",
|
||||
"toothLabel": "دندان {tooth}",
|
||||
"unassigned": "اختصاص داده نشده",
|
||||
"assignedTo": "اختصاص به {name}",
|
||||
"statusPending": "در انتظار",
|
||||
"teethLabel": "دندانهای {teeth}",
|
||||
"importantBadge": "مهم",
|
||||
"lastUpdatedBy": "بهروزرسانی توسط {name}",
|
||||
"statusInProgress": "در حال انجام",
|
||||
"statusCompleted": "تکمیلشده",
|
||||
"searchPlaceholder": "جستجوی بیمار یا کلینیک…",
|
||||
"filterClinic": "کلینیک",
|
||||
"filterClinicAll": "همه کلینیکها",
|
||||
"filterStatus": "وضعیت",
|
||||
"filterStatusAll": "همه وضعیتها",
|
||||
"showCompleted": "نمایش تکمیلشدهها",
|
||||
"importantOnly": "فقط مهمها",
|
||||
"filterSentFrom": "از",
|
||||
"filterSentTo": "تا",
|
||||
"sortBy": "مرتبسازی بر اساس",
|
||||
"sortDate": "تاریخ",
|
||||
"sortStatus": "وضعیت",
|
||||
"sortClinic": "کلینیک",
|
||||
"sortPatient": "بیمار",
|
||||
"sortImportant": "مهم",
|
||||
"clearFilters": "پاک کردن فیلترها",
|
||||
"commentsButton": "نظرات",
|
||||
"errorLoadList": "بارگذاری وظایف ناموفق بود.",
|
||||
"errorUpdateTask": "بهروزرسانی وظیفه ناموفق بود.",
|
||||
"pageSummary": "صفحه {page} از {totalPages} ({total} وظیفه)"
|
||||
},
|
||||
"caseComments": {
|
||||
"title": "نظرات",
|
||||
"placeholder": "یک نظر بنویسید…",
|
||||
"reply": "پاسخ…",
|
||||
"post": "ثبت",
|
||||
"empty": "هنوز نظری ثبت نشده است.",
|
||||
"visibleToClinicToggle": "قابل مشاهده برای کلینیک",
|
||||
"clinicCanSee": "کلینیک میتواند ببیند",
|
||||
"hiddenFromClinic": "پنهان از کلینیک",
|
||||
"makeVisible": "نمایش به کلینیک",
|
||||
"makeHidden": "پنهان از کلینیک",
|
||||
"labAuthor": "آزمایشگاه",
|
||||
"clinicAuthor": "کلینیک",
|
||||
"errorLoad": "بارگذاری نظرات ناموفق بود.",
|
||||
"errorPost": "ثبت نظر ناموفق بود.",
|
||||
"errorToggle": "بهروزرسانی وضعیت نمایش نظر ناموفق بود."
|
||||
},
|
||||
"appointments": {
|
||||
"title": "نوبتها",
|
||||
"subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائهدهنده کلیک کنید تا رزرو کنید.",
|
||||
|
||||
@@ -319,7 +319,7 @@
|
||||
},
|
||||
"cases": {
|
||||
"title": "Dossiers",
|
||||
"subtitle": "Labdossiers van gekoppelde klinieken. Wijs taken toe en volg de voortgang per tand.",
|
||||
"subtitle": "Labdossiers van gekoppelde klinieken. Volg de voortgang en markeer belangrijke taken.",
|
||||
"searchPlaceholder": "Zoeken op patiëntnaam of mobiel…",
|
||||
"emptyList": "Nog geen dossiers ontvangen.",
|
||||
"selectCaseHint": "Selecteer een dossier uit de lijst om taken te bekijken.",
|
||||
@@ -329,13 +329,17 @@
|
||||
"taskProgressShort": "{progress} taken",
|
||||
"treatmentDetails": "Behandeldetails",
|
||||
"teethLabel": "Tanden",
|
||||
"tasksByTooth": "Taken per tand",
|
||||
"toothGroupTitle": "Tand {tooth} · {prosthesis} · {type}",
|
||||
"tasksByTooth": "Taken",
|
||||
"toothGroupTitle": "Tanden {teeth} · {prosthesis}",
|
||||
"noTasks": "Er zijn geen taken gegenereerd voor dit dossier.",
|
||||
"unassigned": "Niet toegewezen",
|
||||
"statusPending": "In afwachting",
|
||||
"statusInProgress": "Bezig",
|
||||
"statusCompleted": "Voltooid",
|
||||
"importantLabel": "Belangrijk",
|
||||
"markImportant": "Markeren als belangrijk",
|
||||
"lastUpdatedBy": "Bijgewerkt door {name}",
|
||||
"lastUpdatedUnknown": "Nog niet gestart",
|
||||
"timelineTitle": "Geschiedenis",
|
||||
"timelineEntry": "{status} · {name} · {date}",
|
||||
"errorLoadList": "Dossiers laden mislukt.",
|
||||
"errorLoadDetail": "Dossierdetails laden mislukt.",
|
||||
"errorUpdateTask": "Taak bijwerken mislukt.",
|
||||
@@ -351,32 +355,63 @@
|
||||
"prevPage": "Vorige",
|
||||
"nextPage": "Volgende",
|
||||
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
|
||||
"priorityLabel": "Prioriteit",
|
||||
"statusLabel": "Status"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Taken",
|
||||
"subtitle": "Uw toegewezen labtaken. Werk de status bij terwijl u elke stap uitvoert.",
|
||||
"subtitleOwner": "Alle labtaken in de organisatie. Wijs toe via Dossiers; werk hier de status bij voor uw eigen taken.",
|
||||
"subtitle": "Alle labtaken van gekoppelde klinieken. Filter, sorteer en werk de status van elke stap bij.",
|
||||
"subtitleOwner": "Alle labtaken van gekoppelde klinieken. Filter, sorteer en werk de status van elke stap bij.",
|
||||
"loading": "Taken laden…",
|
||||
"emptyList": "Nog geen taken aan u toegewezen.",
|
||||
"emptyListOwner": "Nog geen taken in de lab-inbox.",
|
||||
"emptyList": "Geen taken komen overeen met de huidige filters.",
|
||||
"emptyListOwner": "Geen taken komen overeen met de huidige filters.",
|
||||
"noPermissionTitle": "Taken",
|
||||
"noPermissionBody": "U heeft geen toestemming om taken voor deze organisatie te bekijken.",
|
||||
"fromClinic": "Van {name}",
|
||||
"patientLabel": "Patiënt",
|
||||
"taskDate": "{date}",
|
||||
"priorityLabel": "Prioriteit {n}",
|
||||
"toothLabel": "Tand {tooth}",
|
||||
"unassigned": "Niet toegewezen",
|
||||
"assignedTo": "Toegewezen aan {name}",
|
||||
"statusPending": "In afwachting",
|
||||
"teethLabel": "Tanden {teeth}",
|
||||
"importantBadge": "Belangrijk",
|
||||
"lastUpdatedBy": "Bijgewerkt door {name}",
|
||||
"statusInProgress": "Bezig",
|
||||
"statusCompleted": "Voltooid",
|
||||
"searchPlaceholder": "Zoek patiënt of kliniek…",
|
||||
"filterClinic": "Kliniek",
|
||||
"filterClinicAll": "Alle klinieken",
|
||||
"filterStatus": "Status",
|
||||
"filterStatusAll": "Alle statussen",
|
||||
"showCompleted": "Voltooide tonen",
|
||||
"importantOnly": "Alleen belangrijk",
|
||||
"filterSentFrom": "Vanaf",
|
||||
"filterSentTo": "Tot",
|
||||
"sortBy": "Sorteren op",
|
||||
"sortDate": "Datum",
|
||||
"sortStatus": "Status",
|
||||
"sortClinic": "Kliniek",
|
||||
"sortPatient": "Patiënt",
|
||||
"sortImportant": "Belangrijk",
|
||||
"clearFilters": "Filters wissen",
|
||||
"commentsButton": "Opmerkingen",
|
||||
"errorLoadList": "Taken laden mislukt.",
|
||||
"errorUpdateTask": "Taak bijwerken mislukt.",
|
||||
"pageSummary": "Pagina {page} van {totalPages} ({total} taken)"
|
||||
},
|
||||
"caseComments": {
|
||||
"title": "Opmerkingen",
|
||||
"placeholder": "Schrijf een opmerking…",
|
||||
"reply": "Antwoorden…",
|
||||
"post": "Plaatsen",
|
||||
"empty": "Nog geen opmerkingen.",
|
||||
"visibleToClinicToggle": "Zichtbaar voor kliniek",
|
||||
"clinicCanSee": "Kliniek kan dit zien",
|
||||
"hiddenFromClinic": "Verborgen voor kliniek",
|
||||
"makeVisible": "Zichtbaar maken voor kliniek",
|
||||
"makeHidden": "Verbergen voor kliniek",
|
||||
"labAuthor": "Lab",
|
||||
"clinicAuthor": "Kliniek",
|
||||
"errorLoad": "Opmerkingen laden mislukt.",
|
||||
"errorPost": "Opmerking plaatsen mislukt.",
|
||||
"errorToggle": "Zichtbaarheid bijwerken mislukt."
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Afspraken",
|
||||
"subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.",
|
||||
|
||||
@@ -17,16 +17,18 @@ import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type {
|
||||
AssignableMember,
|
||||
CasesFilterOptions,
|
||||
LabCaseDetail,
|
||||
LabCaseListItem,
|
||||
LabTaskStatus,
|
||||
PaginatedLabCases,
|
||||
} from '@/types/cases';
|
||||
import {
|
||||
formatToothList,
|
||||
prosthesisTypeBadgeStyle,
|
||||
} from '@/components/ui/treatment/prosthesisTypeDisplay';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const;
|
||||
|
||||
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
|
||||
switch (status) {
|
||||
@@ -100,7 +102,6 @@ export default function CasesPage() {
|
||||
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
|
||||
const [members, setMembers] = useState<AssignableMember[]>([]);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||
@@ -115,7 +116,6 @@ export default function CasesPage() {
|
||||
|
||||
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
|
||||
() => [
|
||||
{ value: 'PENDING', label: t('statusPending') },
|
||||
{ value: 'IN_PROGRESS', label: t('statusInProgress') },
|
||||
{ value: 'COMPLETED', label: t('statusCompleted') },
|
||||
],
|
||||
@@ -171,7 +171,6 @@ export default function CasesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
|
||||
void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {});
|
||||
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
|
||||
}, []);
|
||||
@@ -216,25 +215,14 @@ export default function CasesPage() {
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
async function handleTaskUpdate(
|
||||
taskId: string,
|
||||
payload: { assigneeUserId?: string | null; priority?: number },
|
||||
) {
|
||||
async function handleImportantToggle(taskId: string, isImportant: boolean) {
|
||||
if (!selectedCaseId || !canEdit) return;
|
||||
|
||||
setUpdatingTaskId(taskId);
|
||||
toast.setError('');
|
||||
try {
|
||||
await casesApi.updateTask(selectedCaseId, taskId, payload);
|
||||
await casesApi.setTaskImportant(selectedCaseId, taskId, isImportant);
|
||||
await loadDetail(selectedCaseId);
|
||||
await loadCases({
|
||||
q: search,
|
||||
clinicOrganizationId: clinicId,
|
||||
treatmentType,
|
||||
sentFrom,
|
||||
sentTo,
|
||||
page,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
toast.showError(formatApiErrorMessage(error, t('errorUpdateTask')));
|
||||
} finally {
|
||||
@@ -476,65 +464,65 @@ export default function CasesPage() {
|
||||
{selectedCase.tasksByTooth.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">{t('noTasks')}</p>
|
||||
) : (
|
||||
selectedCase.tasksByTooth.map((group) => (
|
||||
selectedCase.tasksByTooth.map((group, groupIndex) => (
|
||||
<div
|
||||
key={`${group.tooth}-${group.treatmentType}`}
|
||||
key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
|
||||
className="rounded-md border border-border p-3 space-y-2"
|
||||
>
|
||||
<div className="text-sm font-medium text-text-primary">
|
||||
{t('toothGroupTitle', {
|
||||
tooth: group.tooth,
|
||||
prosthesis: group.prosthesisTypeLabel,
|
||||
type: treatmentLabel(group.treatmentType),
|
||||
})}
|
||||
<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', {
|
||||
teeth: formatToothList(group.teeth),
|
||||
prosthesis: group.prosthesisTypeLabel,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{group.tasks.map((task) => (
|
||||
<li
|
||||
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>
|
||||
{task.stepOrder}. {task.stepLabel}
|
||||
</span>
|
||||
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
|
||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||
task.status}
|
||||
</Badge>
|
||||
<select
|
||||
value={task.priority}
|
||||
disabled={!canEdit || updatingTaskId === task.id}
|
||||
onChange={(e) =>
|
||||
void handleTaskUpdate(task.id, {
|
||||
priority: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className={FORM_SELECT_CLASS}
|
||||
aria-label={t('priorityLabel')}
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={task.assigneeUserId ?? ''}
|
||||
disabled={!canEdit || updatingTaskId === task.id}
|
||||
onChange={(e) =>
|
||||
void handleTaskUpdate(task.id, {
|
||||
assigneeUserId: e.target.value || null,
|
||||
})
|
||||
}
|
||||
className={FORM_SELECT_CLASS}
|
||||
>
|
||||
<option value="">{t('unassigned')}</option>
|
||||
{members.map((member) => (
|
||||
<option key={member.userId} value={member.userId}>
|
||||
{member.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
{task.stepOrder}. {task.stepLabel}
|
||||
</span>
|
||||
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
|
||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||
task.status}
|
||||
</Badge>
|
||||
{canEdit ? (
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.isImportant}
|
||||
disabled={updatingTaskId === task.id}
|
||||
onChange={(e) =>
|
||||
void handleImportantToggle(task.id, e.target.checked)
|
||||
}
|
||||
/>
|
||||
{t('importantLabel')}
|
||||
</label>
|
||||
) : task.isImportant ? (
|
||||
<Badge variant="warning" fixedWidth={false}>
|
||||
{t('importantLabel')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{task.lastStatusChangedBy
|
||||
? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })
|
||||
: t('lastUpdatedUnknown')}
|
||||
{task.lastStatusChangedAt
|
||||
? ` · ${formatDateTime(task.lastStatusChangedAt, locale)}`
|
||||
: ''}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
|
||||
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||
import {
|
||||
formatToothList,
|
||||
prosthesisTypeBadgeStyle,
|
||||
} from '@/components/ui/treatment/prosthesisTypeDisplay';
|
||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
@@ -14,20 +21,19 @@ import { useToast } from '@/lib/hooks/useToast';
|
||||
import { tasksApi } from '@/lib/api/tasks';
|
||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||
import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
|
||||
import type {
|
||||
LabTaskListItem,
|
||||
LabTaskStatus,
|
||||
ListLabTasksParams,
|
||||
PaginatedLabTasks,
|
||||
TaskSortField,
|
||||
} from '@/types/cases';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
|
||||
switch (status) {
|
||||
case 'COMPLETED':
|
||||
return 'success';
|
||||
case 'IN_PROGRESS':
|
||||
return 'default';
|
||||
default:
|
||||
return 'warning';
|
||||
}
|
||||
return status === 'COMPLETED' ? 'success' : 'default';
|
||||
}
|
||||
|
||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||
@@ -50,64 +56,94 @@ export default function TasksPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||
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 canEdit = canEditTasks(currentOrganization);
|
||||
const locale = user?.language ?? 'en';
|
||||
const isOwner = Boolean(currentOrganization?.isOwner);
|
||||
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
|
||||
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
|
||||
() => [
|
||||
{ value: 'PENDING', label: t('statusPending') },
|
||||
{ value: 'IN_PROGRESS', label: t('statusInProgress') },
|
||||
{ value: 'COMPLETED', label: t('statusCompleted') },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const listParams = useMemo((): ListLabTasksParams => {
|
||||
const params: ListLabTasksParams = {
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
sortBy,
|
||||
sortDir,
|
||||
};
|
||||
if (search.trim()) params.q = search.trim();
|
||||
if (clinicId) params.clinicOrganizationId = clinicId;
|
||||
if (statusFilter) {
|
||||
params.status = statusFilter;
|
||||
} else if (showCompleted) {
|
||||
params.completed = undefined;
|
||||
} else {
|
||||
params.completed = false;
|
||||
}
|
||||
if (importantOnly) params.important = true;
|
||||
if (sentFrom) params.sentFrom = sentFrom;
|
||||
if (sentTo) params.sentTo = sentTo;
|
||||
return params;
|
||||
}, [page, search, clinicId, statusFilter, showCompleted, importantOnly, sentFrom, sentTo, sortBy, sortDir]);
|
||||
|
||||
const clinicOptions = useMemo(() => {
|
||||
const map = new Map<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(() => {
|
||||
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canView) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await tasksApi.list({ page, limit: PAGE_SIZE });
|
||||
if (cancelled) return;
|
||||
setTasks(response.data.items);
|
||||
setPagination(response.data.pagination);
|
||||
} catch (error: unknown) {
|
||||
if (cancelled) return;
|
||||
showError(formatApiErrorMessage(error, tRef.current('errorLoadList')));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canView, page, showError, setError]);
|
||||
const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [canView, loadTasks, search]);
|
||||
|
||||
async function handleStatusUpdate(taskId: string, status: LabTaskStatus) {
|
||||
if (!canEdit) return;
|
||||
|
||||
setUpdatingTaskId(taskId);
|
||||
setError('');
|
||||
try {
|
||||
await tasksApi.updateStatus(taskId, status);
|
||||
const response = await tasksApi.list({ page, limit: PAGE_SIZE });
|
||||
setTasks(response.data.items);
|
||||
setPagination(response.data.pagination);
|
||||
await loadTasks();
|
||||
} catch (error: unknown) {
|
||||
showError(formatApiErrorMessage(error, t('errorUpdateTask')));
|
||||
} finally {
|
||||
@@ -123,9 +159,7 @@ export default function TasksPage() {
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function sortDateForTask(task: LabTaskListItem) {
|
||||
return task.assignedAt ?? task.createdAt;
|
||||
}
|
||||
const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`;
|
||||
|
||||
if (!isAuthReady) {
|
||||
return <div className="text-sm text-text-muted">{t('loading')}</div>;
|
||||
@@ -144,89 +178,229 @@ export default function TasksPage() {
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{isOwner ? t('subtitleOwner') : t('subtitle')}
|
||||
</p>
|
||||
<p className="text-sm text-text-secondary">{t('subtitle')}</p>
|
||||
</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]">
|
||||
{loading && tasks.length === 0 ? (
|
||||
<p className="p-3 text-sm text-text-muted">{t('loading')}</p>
|
||||
) : tasks.length === 0 ? (
|
||||
<p className="p-3 text-sm text-text-muted">
|
||||
{isOwner ? t('emptyListOwner') : t('emptyList')}
|
||||
</p>
|
||||
<p className="p-3 text-sm text-text-muted">{t('emptyList')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{tasks.map((task) => {
|
||||
const statusEditable =
|
||||
canEdit && (isOwner || task.assigneeUserId === user?.id);
|
||||
{tasks.map((task, index) => {
|
||||
const commentsOpen = expandedCommentsCaseId === task.labCaseId;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={task.id}
|
||||
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">
|
||||
<p className="text-sm font-medium text-text-primary truncate">
|
||||
{task.stepOrder}. {task.stepLabel}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-secondary truncate">
|
||||
{t('fromClinic', { name: task.clinic.name })} ·{' '}
|
||||
{formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })}
|
||||
{task.prosthesisTypeLabel ? ` · ${task.prosthesisTypeLabel}` : ''}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted truncate flex flex-wrap items-center gap-x-1 gap-y-0.5">
|
||||
<span>{t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}</span>
|
||||
{isOwner && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<Badge
|
||||
variant={task.assignee ? 'success' : 'danger'}
|
||||
fixedWidth={false}
|
||||
>
|
||||
{task.assignee
|
||||
? t('assignedTo', { name: task.assignee.name })
|
||||
: t('unassigned')}
|
||||
<li 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">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{task.stepOrder}. {task.stepLabel}
|
||||
</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">
|
||||
{t('fromClinic', { name: task.clinic.name })} ·{' '}
|
||||
{formatPatientName(task.patient)} ·{' '}
|
||||
{t('teethLabel', { teeth: formatToothList(task.teeth) })}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted truncate">
|
||||
<span>{t('taskDate', { date: formatTaskDate(task.createdAt) })}</span>
|
||||
{task.lastStatusChangedBy ? (
|
||||
<>
|
||||
<span aria-hidden> · </span>
|
||||
<span>
|
||||
{t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
{canEdit ? (
|
||||
<select
|
||||
value={task.status}
|
||||
disabled={updatingTaskId === task.id}
|
||||
onChange={(e) =>
|
||||
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
|
||||
}
|
||||
className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`}
|
||||
>
|
||||
{statusOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
|
||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||
task.status}
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0 justify-end">
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedCommentsCaseId(commentsOpen ? null : task.labCaseId)
|
||||
}
|
||||
className={`p-1.5 rounded border ${
|
||||
commentsOpen
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border text-text-muted hover:border-primary/40'
|
||||
}`}
|
||||
title={t('commentsButton')}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<TreatmentTypeBadge
|
||||
type={task.treatmentType}
|
||||
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
{statusEditable ? (
|
||||
<select
|
||||
value={task.status}
|
||||
disabled={updatingTaskId === task.id}
|
||||
onChange={(e) =>
|
||||
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
|
||||
}
|
||||
className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`}
|
||||
>
|
||||
{statusOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
|
||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||
task.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0 justify-end">
|
||||
<Badge variant="default" fixedWidth={false}>
|
||||
{t('priorityLabel', { n: task.priority })}
|
||||
</Badge>
|
||||
<TreatmentTypeBadge
|
||||
type={task.treatmentType}
|
||||
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
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 { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||
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 { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
@@ -109,7 +114,6 @@ export function ConnectionCaseHistoryContent({
|
||||
|
||||
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
|
||||
() => [
|
||||
{ value: 'PENDING', label: tCases('statusPending') },
|
||||
{ value: 'IN_PROGRESS', label: tCases('statusInProgress') },
|
||||
{ value: 'COMPLETED', label: tCases('statusCompleted') },
|
||||
],
|
||||
@@ -363,17 +367,24 @@ export function ConnectionCaseHistoryContent({
|
||||
{selectedCase.tasksByTooth.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">{tCases('noTasks')}</p>
|
||||
) : (
|
||||
selectedCase.tasksByTooth.map((group) => (
|
||||
selectedCase.tasksByTooth.map((group, groupIndex) => (
|
||||
<div
|
||||
key={`${group.tooth}-${group.treatmentType}`}
|
||||
key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
|
||||
className="rounded-md border border-border p-3 space-y-2"
|
||||
>
|
||||
<div className="text-sm font-medium text-text-primary">
|
||||
{tCases('toothGroupTitle', {
|
||||
tooth: group.tooth,
|
||||
prosthesis: group.prosthesisTypeLabel,
|
||||
type: treatmentLabel(group.treatmentType),
|
||||
})}
|
||||
<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', {
|
||||
teeth: formatToothList(group.teeth),
|
||||
prosthesis: group.prosthesisTypeLabel,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{group.tasks.map((task) => (
|
||||
@@ -388,6 +399,11 @@ export function ConnectionCaseHistoryContent({
|
||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||
task.status}
|
||||
</Badge>
|
||||
{task.lastStatusChangedBy ? (
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{tCases('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -395,6 +411,30 @@ export function ConnectionCaseHistoryContent({
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</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(
|
||||
appointment: TreatmentAppointment,
|
||||
details: TreatmentDetailDraft[],
|
||||
@@ -76,8 +97,9 @@ function buildWorkspaceSnapshot(
|
||||
title: string,
|
||||
id?: string,
|
||||
): PastTreatment {
|
||||
const detailsForPreview = enrichDetailsWithLabSendState(details, labCaseDrafts);
|
||||
return {
|
||||
...detailsToPreviewTreatment(details, {
|
||||
...detailsToPreviewTreatment(detailsForPreview, {
|
||||
id: id ?? `preview-${appointment.id}`,
|
||||
title,
|
||||
patientId: appointment.patientId,
|
||||
@@ -844,6 +866,22 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
|
||||
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) =>
|
||||
prev.map((lc) =>
|
||||
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 type {
|
||||
AssignableMember,
|
||||
CasesFilterOptions,
|
||||
LabCaseDetail,
|
||||
LabCaseTask,
|
||||
@@ -21,22 +20,17 @@ export const casesApi = {
|
||||
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 }> => {
|
||||
const response = await apiClient.get('/cases/filter-options');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTask: async (
|
||||
setTaskImportant: async (
|
||||
caseId: string,
|
||||
taskId: string,
|
||||
payload: { assigneeUserId?: string | null; priority?: number },
|
||||
isImportant: boolean,
|
||||
): 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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
LabCaseComment,
|
||||
LabCaseDetail,
|
||||
ListLabCasesParams,
|
||||
PaginatedLabCases,
|
||||
@@ -161,4 +162,26 @@ export const organizationApi = {
|
||||
);
|
||||
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 type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
|
||||
import type {
|
||||
LabCaseComment,
|
||||
LabTaskListItem,
|
||||
LabTaskStatus,
|
||||
ListLabTasksParams,
|
||||
PaginatedLabTasks,
|
||||
} from '@/types/cases';
|
||||
|
||||
export const tasksApi = {
|
||||
list: async (params: { page?: number; limit?: number } = {}): Promise<{
|
||||
success: boolean;
|
||||
data: PaginatedLabTasks;
|
||||
}> => {
|
||||
list: async (
|
||||
params: ListLabTasksParams = {},
|
||||
): Promise<{ success: boolean; data: PaginatedLabTasks }> => {
|
||||
const response = await apiClient.get('/tasks', { params });
|
||||
return response.data;
|
||||
},
|
||||
@@ -17,4 +22,29 @@ export const tasksApi = {
|
||||
const response = await apiClient.patch(`/tasks/${taskId}`, { status });
|
||||
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 {
|
||||
id: string;
|
||||
@@ -14,9 +14,23 @@ export interface LabCaseListItem {
|
||||
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 {
|
||||
id: string;
|
||||
tooth: string;
|
||||
treatmentDetailId: string;
|
||||
teeth: string[];
|
||||
treatmentType: string;
|
||||
prosthesisTypeCode: string;
|
||||
prosthesisTypeLabel: string;
|
||||
@@ -24,21 +38,33 @@ export interface LabCaseTask {
|
||||
stepOrder: number;
|
||||
stepLabel: string;
|
||||
status: LabTaskStatus;
|
||||
priority: number;
|
||||
assignedAt: string | null;
|
||||
isImportant: boolean;
|
||||
createdAt: string;
|
||||
assigneeUserId: string | null;
|
||||
assignee: { id: string; name: string; email: string } | null;
|
||||
lastStatusChangedAt: string | null;
|
||||
lastStatusChangedBy: LabTaskUser | null;
|
||||
timeline: LabTaskTimelineEvent[];
|
||||
}
|
||||
|
||||
export interface LabCaseTasksByTooth {
|
||||
tooth: string;
|
||||
export interface LabCaseTaskGroup {
|
||||
treatmentDetailId: string;
|
||||
teeth: string[];
|
||||
treatmentType: string;
|
||||
prosthesisTypeCode: string;
|
||||
prosthesisTypeLabel: string;
|
||||
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 {
|
||||
id: string;
|
||||
sentAt: string | null;
|
||||
@@ -64,17 +90,10 @@ export interface LabCaseDetail {
|
||||
sentAt: string;
|
||||
}>;
|
||||
tasks: LabCaseTask[];
|
||||
tasksByTooth: LabCaseTasksByTooth[];
|
||||
tasksByTooth: LabCaseTaskGroup[];
|
||||
taskProgress: { completed: number; total: number };
|
||||
}
|
||||
|
||||
export interface AssignableMember {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
export interface ListLabCasesParams {
|
||||
q?: string;
|
||||
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 {
|
||||
id: string;
|
||||
labCaseId: string;
|
||||
tooth: string;
|
||||
treatmentDetailId: string;
|
||||
teeth: string[];
|
||||
treatmentType: string;
|
||||
prosthesisTypeCode: string;
|
||||
prosthesisTypeLabel: string;
|
||||
workflowStepCode: string;
|
||||
stepOrder: number;
|
||||
stepLabel: string;
|
||||
status: LabTaskStatus;
|
||||
priority: number;
|
||||
assignedAt: string | null;
|
||||
isImportant: boolean;
|
||||
lastStatusChangedAt: string | null;
|
||||
lastStatusChangedBy: LabTaskUser | null;
|
||||
createdAt: string;
|
||||
assigneeUserId: string | null;
|
||||
assignee: { id: string; name: string; email: string } | null;
|
||||
clinic: { id: string; name: string };
|
||||
patient: { id: string; firstName: string; lastName: string };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user