feature/cases #55

Merged
rameen merged 18 commits from feature/cases into master 2026-07-10 18:26:56 +03:30
35 changed files with 1819 additions and 454 deletions
Showing only changes of commit cb63ced4e3 - Show all commits

View File

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

View File

@@ -0,0 +1,83 @@
-- Lab workflow refactor: remove task assignment/priority, group tasks by prosthesis,
-- add importance flag, status timeline, and per-case comments.
-- Local dev data only: existing tasks are truncated and regenerated on next dispatch/send.
-- 1. Clear existing task data (task shape changes: tooth -> teeth[]).
TRUNCATE TABLE "lab_case_tasks" CASCADE;
-- 2. Drop assignment / priority machinery.
ALTER TABLE "lab_case_tasks" DROP CONSTRAINT IF EXISTS "lab_case_tasks_assigneeUserId_fkey";
DROP INDEX IF EXISTS "lab_case_tasks_assigneeUserId_priority_createdAt_idx";
DROP INDEX IF EXISTS "lab_case_tasks_assignedAt_labCaseId_priority_idx";
DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_tooth_stepOrder_key";
ALTER TABLE "lab_case_tasks"
DROP COLUMN IF EXISTS "assigneeUserId",
DROP COLUMN IF EXISTS "assignedAt",
DROP COLUMN IF EXISTS "priority",
DROP COLUMN IF EXISTS "tooth";
-- 3. Rebuild LabTaskStatus enum without PENDING.
ALTER TABLE "lab_case_tasks" ALTER COLUMN "status" DROP DEFAULT;
ALTER TYPE "LabTaskStatus" RENAME TO "LabTaskStatus_old";
CREATE TYPE "LabTaskStatus" AS ENUM ('IN_PROGRESS', 'COMPLETED');
ALTER TABLE "lab_case_tasks"
ALTER COLUMN "status" TYPE "LabTaskStatus" USING ("status"::text::"LabTaskStatus");
ALTER TABLE "lab_case_tasks" ALTER COLUMN "status" SET DEFAULT 'IN_PROGRESS';
DROP TYPE "LabTaskStatus_old";
-- 4. New task columns.
ALTER TABLE "lab_case_tasks" ADD COLUMN "teeth" JSONB NOT NULL DEFAULT '[]';
ALTER TABLE "lab_case_tasks" ALTER COLUMN "teeth" DROP DEFAULT;
ALTER TABLE "lab_case_tasks" ADD COLUMN "isImportant" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "lab_case_tasks" ADD COLUMN "lastStatusChangedByUserId" TEXT;
ALTER TABLE "lab_case_tasks" ADD COLUMN "lastStatusChangedAt" TIMESTAMP(3);
-- 5. New unique + indexes.
CREATE UNIQUE INDEX "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_stepOrder_key"
ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "prosthesisTypeCode", "stepOrder");
CREATE INDEX "lab_case_tasks_labCaseId_isImportant_idx"
ON "lab_case_tasks"("labCaseId", "isImportant");
ALTER TABLE "lab_case_tasks" ADD CONSTRAINT "lab_case_tasks_lastStatusChangedByUserId_fkey"
FOREIGN KEY ("lastStatusChangedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- 6. Task status timeline.
CREATE TABLE "lab_case_task_status_events" (
"id" TEXT NOT NULL,
"taskId" TEXT NOT NULL,
"fromStatus" "LabTaskStatus",
"toStatus" "LabTaskStatus" NOT NULL,
"changedByUserId" TEXT,
"changedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lab_case_task_status_events_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "lab_case_task_status_events_taskId_changedAt_idx"
ON "lab_case_task_status_events"("taskId", "changedAt");
ALTER TABLE "lab_case_task_status_events" ADD CONSTRAINT "lab_case_task_status_events_taskId_fkey"
FOREIGN KEY ("taskId") REFERENCES "lab_case_tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "lab_case_task_status_events" ADD CONSTRAINT "lab_case_task_status_events_changedByUserId_fkey"
FOREIGN KEY ("changedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- 7. Per-case comments.
CREATE TYPE "LabCaseCommentSide" AS ENUM ('LAB', 'CLINIC');
CREATE TABLE "lab_case_comments" (
"id" TEXT NOT NULL,
"labCaseId" TEXT NOT NULL,
"authorUserId" TEXT,
"authorOrganizationId" TEXT,
"authorSide" "LabCaseCommentSide" NOT NULL,
"body" TEXT NOT NULL,
"visibleToClinic" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "lab_case_comments_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "lab_case_comments_labCaseId_createdAt_idx"
ON "lab_case_comments"("labCaseId", "createdAt");
ALTER TABLE "lab_case_comments" ADD CONSTRAINT "lab_case_comments_labCaseId_fkey"
FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "lab_case_comments" ADD CONSTRAINT "lab_case_comments_authorUserId_fkey"
FOREIGN KEY ("authorUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "lab_case_comments" ADD CONSTRAINT "lab_case_comments_authorOrganizationId_fkey"
FOREIGN KEY ("authorOrganizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,60 @@
/**
* Dev-only: regenerate lab case tasks from existing LabCaseToothProsthesis rows.
* Usage: npx ts-node prisma/regenerate-lab-tasks.ts
*
* The lab workflow refactor truncated lab_case_tasks. This rebuilds task sets
* (grouped by treatment detail + prosthesis type, one set per workflow step)
* for every already-sent case that still has prosthesis selections.
*/
import { PrismaClient } from '@prisma/client';
import { config } from 'dotenv';
import path from 'path';
import { generateLabCaseTasks } from '../src/modules/cases/lab-case-task.generator';
const envPath = path.join(__dirname, '..', '.env');
config({ path: envPath });
if (process.env.NODE_ENV === 'production') {
console.error('regenerate-lab-tasks is not allowed in production');
process.exit(1);
}
const prisma = new PrismaClient();
async function main() {
const cases = await prisma.labCase.findMany({
where: {
sentAt: { not: null },
toothProsthesis: { some: {} },
},
select: {
id: true,
treatment: { select: { organization: { select: { owner: { select: { language: true } } } } } },
},
});
console.log(`Regenerating tasks for ${cases.length} sent case(s)...`);
let total = 0;
for (const labCase of cases) {
const locale = labCase.treatment.organization.owner.language ?? 'en';
// Clear any stale tasks first so the generator's "already exists" guard passes.
await prisma.labCaseTask.deleteMany({ where: { labCaseId: labCase.id } });
const created = await prisma.$transaction((tx) =>
generateLabCaseTasks(tx, labCase.id, locale),
);
total += created;
console.log(` - ${labCase.id}: ${created} task(s)`);
}
console.log(`Done. ${total} task(s) created.`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[],

View File

@@ -0,0 +1,11 @@
import { Prisma } from '@prisma/client';
/** Normalize the JSON `teeth` column of a lab case task into a clean string[]. */
export function normalizeTaskTeeth(value: Prisma.JsonValue | null | undefined): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((v): v is string | number => typeof v === 'string' || typeof v === 'number')
.map((v) => String(v));
}

View File

@@ -0,0 +1,17 @@
import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateLabCaseCommentDto {
@IsString()
@MinLength(1)
@MaxLength(2000)
body: string;
@IsOptional()
@IsBoolean()
visibleToClinic?: boolean;
}
export class SetCommentVisibilityDto {
@IsBoolean()
visibleToClinic: boolean;
}

View File

@@ -0,0 +1,61 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import {
CreateLabCaseCommentDto,
SetCommentVisibilityDto,
} from './dto/lab-case-comment.dto';
import { LabCaseCommentsService } from './lab-case-comments.service';
@ApiTags('case-comments')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard, LabOrgGuard)
@Controller('case-comments')
export class LabCaseCommentsController {
constructor(private readonly service: LabCaseCommentsService) {}
private orgId(req: { user: { organizationId?: string } }) {
return req.user.organizationId as string;
}
@Get(':caseId')
@ApiOperation({ summary: 'List comments for a lab case (lab side)' })
list(@Param('caseId') caseId: string, @Req() req) {
return this.service.listForLab(caseId, this.orgId(req), req.user.id);
}
@Post(':caseId')
@ApiOperation({ summary: 'Add a comment to a lab case (lab side)' })
add(
@Param('caseId') caseId: string,
@Body() dto: CreateLabCaseCommentDto,
@Req() req,
) {
return this.service.addForLab(caseId, this.orgId(req), req.user.id, dto);
}
@Patch('item/:commentId/visibility')
@ApiOperation({ summary: 'Toggle whether a comment is visible to the clinic' })
setVisibility(
@Param('commentId') commentId: string,
@Body() dto: SetCommentVisibilityDto,
@Req() req,
) {
return this.service.setVisibility(
commentId,
this.orgId(req),
req.user.id,
dto.visibleToClinic,
);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { LabCaseCommentsController } from './lab-case-comments.controller';
import { LabCaseCommentsService } from './lab-case-comments.service';
@Module({
controllers: [LabCaseCommentsController],
providers: [LabCaseCommentsService, PrismaService, LabOrgGuard],
exports: [LabCaseCommentsService],
})
export class LabCaseCommentsModule {}

View File

@@ -0,0 +1,183 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LabCaseCommentSide, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
const commentInclude = {
authorUser: { select: { id: true, name: true } },
authorOrganization: { select: { id: true, name: true } },
} satisfies Prisma.LabCaseCommentInclude;
type CommentWithRelations = Prisma.LabCaseCommentGetPayload<{
include: typeof commentInclude;
}>;
@Injectable()
export class LabCaseCommentsService {
constructor(private readonly prisma: PrismaService) {}
// ---------- Lab side (TAB_TASKS_EDIT) ----------
async listForLab(caseId: string, labOrganizationId: string, actorUserId: string) {
await this.assertLabCanComment(caseId, labOrganizationId, actorUserId);
const comments = await this.fetchComments(caseId);
return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.LAB)) };
}
async addForLab(
caseId: string,
labOrganizationId: string,
actorUserId: string,
dto: CreateLabCaseCommentDto,
) {
await this.assertLabCanComment(caseId, labOrganizationId, actorUserId);
const created = await this.prisma.labCaseComment.create({
data: {
labCaseId: caseId,
authorUserId: actorUserId,
authorOrganizationId: labOrganizationId,
authorSide: LabCaseCommentSide.LAB,
body: dto.body.trim(),
visibleToClinic: dto.visibleToClinic ?? false,
},
include: commentInclude,
});
return { success: true, data: this.mapComment(created, LabCaseCommentSide.LAB) };
}
async setVisibility(
commentId: string,
labOrganizationId: string,
actorUserId: string,
visibleToClinic: boolean,
) {
const comment = await this.prisma.labCaseComment.findUnique({
where: { id: commentId },
select: { id: true, labCaseId: true, authorSide: true },
});
if (!comment) {
throw new NotFoundException('Comment not found');
}
await this.assertLabCanComment(comment.labCaseId, labOrganizationId, actorUserId);
if (comment.authorSide !== LabCaseCommentSide.LAB) {
throw new ForbiddenException('Only lab comments can change visibility');
}
const updated = await this.prisma.labCaseComment.update({
where: { id: commentId },
data: { visibleToClinic },
include: commentInclude,
});
return { success: true, data: this.mapComment(updated, LabCaseCommentSide.LAB) };
}
// ---------- Clinic side (connection access is validated by caller) ----------
async listForClinic(caseId: string, clinicOrganizationId: string) {
await this.assertClinicOwnsCase(caseId, clinicOrganizationId);
const comments = await this.fetchComments(caseId, { visibleOnly: true });
return {
success: true,
data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.CLINIC)),
};
}
async addForClinic(
caseId: string,
clinicOrganizationId: string,
actorUserId: string,
dto: CreateLabCaseCommentDto,
) {
await this.assertClinicOwnsCase(caseId, clinicOrganizationId);
const created = await this.prisma.labCaseComment.create({
data: {
labCaseId: caseId,
authorUserId: actorUserId,
authorOrganizationId: clinicOrganizationId,
authorSide: LabCaseCommentSide.CLINIC,
body: dto.body.trim(),
// Clinic-authored comments are inherently visible to the clinic.
visibleToClinic: true,
},
include: commentInclude,
});
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
}
// ---------- Helpers ----------
private fetchComments(caseId: string, opts?: { visibleOnly?: boolean }) {
return this.prisma.labCaseComment.findMany({
where: {
labCaseId: caseId,
...(opts?.visibleOnly ? { visibleToClinic: true } : {}),
},
include: commentInclude,
orderBy: { createdAt: 'asc' },
});
}
private mapComment(comment: CommentWithRelations, viewerSide: LabCaseCommentSide) {
return {
id: comment.id,
body: comment.body,
authorSide: comment.authorSide,
authorName: comment.authorUser?.name ?? null,
authorOrganizationName: comment.authorOrganization?.name ?? null,
visibleToClinic: comment.visibleToClinic,
createdAt: comment.createdAt.toISOString(),
// Only lab viewers can toggle visibility, and only on lab-authored comments.
canToggleVisibility:
viewerSide === LabCaseCommentSide.LAB &&
comment.authorSide === LabCaseCommentSide.LAB,
};
}
private async assertLabCanComment(
caseId: string,
labOrganizationId: string,
actorUserId: string,
) {
const labCase = await this.prisma.labCase.findFirst({
where: {
id: caseId,
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
select: { id: true },
});
if (!labCase) {
throw new NotFoundException('Case not found');
}
const membership = await this.prisma.membership.findFirst({
where: { userId: actorUserId, organizationId: labOrganizationId, isActive: true },
include: { permissions: { include: { permission: true } } },
});
if (!membership) {
throw new ForbiddenException('You are not a member of this organization');
}
if (membership.isOwner) return;
const names = membership.permissions.map((p) => p.permission.name);
if (!names.includes('TAB_TASKS_EDIT')) {
throw new ForbiddenException('You do not have access to task comments');
}
}
private async assertClinicOwnsCase(caseId: string, clinicOrganizationId: string) {
const labCase = await this.prisma.labCase.findFirst({
where: {
id: caseId,
sentAt: { not: null },
treatment: { organizationId: clinicOrganizationId },
},
select: { id: true },
});
if (!labCase) {
throw new NotFoundException('Case not found');
}
}
}

View File

@@ -19,6 +19,7 @@ import { PreviewOrganizationInviteDto } from './dto/preview-organization-invite.
import { RespondConnectionRequestDto } from './dto/respond-connection-request.dto';
import { 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' })

View File

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

View File

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

View File

@@ -1,13 +1,71 @@
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
import {
IsBoolean,
IsDateString,
IsEnum,
IsIn,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
Min,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { 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()

View File

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

View File

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

View File

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

View File

@@ -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": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائه‌دهنده کلیک کنید تا رزرو کنید.",

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,164 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Eye, EyeOff } from 'lucide-react';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { Button } from '@/components/ui/shared/Button';
import type { LabCaseComment } from '@/types/cases';
interface LabCaseCommentsPanelProps {
caseId: string;
canPost: boolean;
canToggleVisibility: boolean;
loadComments: () => Promise<LabCaseComment[]>;
onPost: (body: string, visibleToClinic?: boolean) => Promise<LabCaseComment>;
onToggleVisibility?: (commentId: string, visible: boolean) => Promise<LabCaseComment>;
onError?: (message: string) => void;
}
export function LabCaseCommentsPanel({
caseId,
canPost,
canToggleVisibility,
loadComments,
onPost,
onToggleVisibility,
onError,
}: LabCaseCommentsPanelProps) {
const t = useTranslations('caseComments');
const [comments, setComments] = useState<LabCaseComment[]>([]);
const [loading, setLoading] = useState(false);
const [posting, setPosting] = useState(false);
const [body, setBody] = useState('');
const [visibleToClinic, setVisibleToClinic] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const items = await loadComments();
setComments(items);
} catch (error: unknown) {
onError?.(formatApiErrorMessage(error, t('errorLoad')));
} finally {
setLoading(false);
}
}, [loadComments, onError, t]);
useEffect(() => {
void refresh();
}, [caseId, refresh]);
async function handlePost() {
const trimmed = body.trim();
if (!trimmed || !canPost) return;
setPosting(true);
try {
const created = await onPost(trimmed, visibleToClinic);
setComments((prev) => [...prev, created]);
setBody('');
setVisibleToClinic(false);
} catch (error: unknown) {
onError?.(formatApiErrorMessage(error, t('errorPost')));
} finally {
setPosting(false);
}
}
async function handleToggle(comment: LabCaseComment) {
if (!onToggleVisibility || !canToggleVisibility) return;
try {
const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic);
setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
} catch (error: unknown) {
onError?.(formatApiErrorMessage(error, t('errorToggle')));
}
}
return (
<div className="space-y-3">
<h4 className="text-sm font-medium text-text-primary">{t('title')}</h4>
{loading ? (
<p className="text-xs text-text-muted"></p>
) : comments.length === 0 ? (
<p className="text-xs text-text-muted">{t('empty')}</p>
) : (
<ul className="space-y-2 max-h-48 overflow-y-auto">
{comments.map((comment) => (
<li
key={comment.id}
className="rounded-md border border-border bg-background p-2 text-sm"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-text-muted">
<span className="font-medium text-text-secondary">
{comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
{comment.authorName ? ` · ${comment.authorName}` : ''}
</span>
{comment.visibleToClinic ? (
<span className="text-primary">{t('clinicCanSee')}</span>
) : (
<span>{t('hiddenFromClinic')}</span>
)}
</div>
<p className="mt-1 text-text-primary whitespace-pre-wrap">{comment.body}</p>
</div>
{canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? (
<button
type="button"
onClick={() => void handleToggle(comment)}
className="shrink-0 p-1 rounded hover:bg-border text-text-muted"
title={
comment.visibleToClinic ? t('makeHidden') : t('makeVisible')
}
aria-label={
comment.visibleToClinic ? t('makeHidden') : t('makeVisible')
}
>
{comment.visibleToClinic ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</button>
) : null}
</div>
</li>
))}
</ul>
)}
{canPost ? (
<div className="space-y-2 border-t border-border pt-2">
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={t('placeholder')}
rows={2}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm resize-none"
/>
{canToggleVisibility ? (
<label className="flex items-center gap-2 text-xs text-text-muted cursor-pointer">
<input
type="checkbox"
checked={visibleToClinic}
onChange={(e) => setVisibleToClinic(e.target.checked)}
/>
{t('visibleToClinicToggle')}
</label>
) : null}
<Button
type="button"
size="sm"
disabled={posting || !body.trim()}
onClick={() => void handlePost()}
>
{t('post')}
</Button>
</div>
) : null}
</div>
);
}

View File

@@ -12,6 +12,11 @@ import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { 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>

View File

@@ -69,6 +69,27 @@ function labCaseDraftsToPast(
}));
}
function enrichDetailsWithLabSendState(
details: TreatmentDetailDraft[],
labCaseDrafts: LabCaseDraft[],
): TreatmentDetailDraft[] {
return details.map((detail) => {
const sentLabCase = labCaseDrafts.find(
(lc) => lc.sentAt && lc.detailClientIds.includes(detail.clientId),
);
if (!sentLabCase) return detail;
return {
...detail,
labCaseId: sentLabCase.id ?? detail.labCaseId,
sentAt: sentLabCase.sentAt ?? detail.sentAt,
sends: sentLabCase.sends ?? detail.sends,
sendToOrganizationIds: sentLabCase.destinationOrganizationId
? [sentLabCase.destinationOrganizationId]
: detail.sendToOrganizationIds,
};
});
}
function buildWorkspaceSnapshot(
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

View File

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

View File

@@ -1,6 +1,5 @@
import { apiClient } from './client';
import 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;
},
};

View File

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

View File

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

View File

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