improvement: tasks and cases feature updated based on the new prosthesis types and their steps. the whole assignment proccess removed from the flow.
This commit is contained in:
@@ -1,13 +1,71 @@
|
||||
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { LabTaskStatus } from '@prisma/client';
|
||||
|
||||
const toBoolean = ({ value }: { value: unknown }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true' || value === '1') return true;
|
||||
if (value === 'false' || value === '0') return false;
|
||||
return value;
|
||||
};
|
||||
|
||||
export class UpdateLabTaskDto {
|
||||
@IsEnum(LabTaskStatus)
|
||||
status: LabTaskStatus;
|
||||
}
|
||||
|
||||
export type TaskSortField = 'date' | 'status' | 'clinic' | 'patient' | 'important';
|
||||
|
||||
export class ListLabTasksDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
clinicOrganizationId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(LabTaskStatus)
|
||||
status?: LabTaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
completed?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
important?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
sentFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
sentTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['date', 'status', 'clinic', 'patient', 'important'])
|
||||
sortBy?: TaskSortField;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['asc', 'desc'])
|
||||
sortDir?: 'asc' | 'desc';
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsInt()
|
||||
|
||||
@@ -13,7 +13,7 @@ export class TasksController {
|
||||
constructor(private readonly tasksService: TasksService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' })
|
||||
@ApiOperation({ summary: 'List lab tasks' })
|
||||
list(@Query() query: ListLabTasksDto, @Req() req) {
|
||||
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
|
||||
return this.tasksService.list(organizationId, req.user.id, query, req.user.language);
|
||||
|
||||
@@ -6,14 +6,16 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { normalizeMobile } from '../../common/phone';
|
||||
import {
|
||||
CatalogLabelService,
|
||||
normalizeCatalogLocale,
|
||||
} from '../catalog/catalog-label.service';
|
||||
import { normalizeTaskTeeth } from '../cases/lab-case-task.util';
|
||||
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
|
||||
|
||||
const taskListInclude = {
|
||||
assignee: { select: { id: true, name: true, email: true } },
|
||||
lastStatusChangedBy: { select: { id: true, name: true } },
|
||||
labCase: {
|
||||
include: {
|
||||
treatment: {
|
||||
@@ -48,35 +50,17 @@ export class TasksService {
|
||||
) {
|
||||
await this.assertCanReadTasks(actorUserId, labOrganizationId);
|
||||
|
||||
const membership = await this.getMembership(actorUserId, labOrganizationId);
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(Math.max(query.limit ?? 50, 1), 100);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: Prisma.LabCaseTaskWhereInput = {
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
...(membership.isOwner ? {} : { assigneeUserId: actorUserId }),
|
||||
};
|
||||
const where = this.buildListWhere(labOrganizationId, query);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.labCaseTask.findMany({
|
||||
where,
|
||||
include: taskListInclude,
|
||||
orderBy: [
|
||||
{ assignedAt: { sort: 'desc', nulls: 'first' } },
|
||||
{ createdAt: 'desc' },
|
||||
{ labCaseId: 'asc' },
|
||||
{ priority: 'desc' },
|
||||
{ stepOrder: 'asc' },
|
||||
{ id: 'asc' },
|
||||
],
|
||||
orderBy: this.buildOrderBy(query),
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
@@ -114,11 +98,6 @@ export class TasksService {
|
||||
) {
|
||||
await this.assertCanEditTasks(actorUserId, labOrganizationId);
|
||||
|
||||
const membership = await this.getMembership(actorUserId, labOrganizationId);
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
|
||||
const task = await this.prisma.labCaseTask.findFirst({
|
||||
where: {
|
||||
id: taskId,
|
||||
@@ -134,14 +113,29 @@ export class TasksService {
|
||||
throw new NotFoundException('Task not found');
|
||||
}
|
||||
|
||||
if (!membership.isOwner && task.assigneeUserId !== actorUserId) {
|
||||
throw new ForbiddenException('You can only update tasks assigned to you');
|
||||
}
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.labCaseTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: dto.status,
|
||||
lastStatusChangedByUserId: actorUserId,
|
||||
lastStatusChangedAt: new Date(),
|
||||
},
|
||||
include: taskListInclude,
|
||||
});
|
||||
|
||||
const updated = await this.prisma.labCaseTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: dto.status },
|
||||
include: taskListInclude,
|
||||
if (task.status !== dto.status) {
|
||||
await tx.labCaseTaskStatusEvent.create({
|
||||
data: {
|
||||
taskId,
|
||||
fromStatus: task.status,
|
||||
toStatus: dto.status,
|
||||
changedByUserId: actorUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const locale = normalizeCatalogLocale(localeInput);
|
||||
@@ -154,6 +148,100 @@ export class TasksService {
|
||||
return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) };
|
||||
}
|
||||
|
||||
private buildListWhere(
|
||||
labOrganizationId: string,
|
||||
query: ListLabTasksDto,
|
||||
): Prisma.LabCaseTaskWhereInput {
|
||||
const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null };
|
||||
|
||||
if (query.sentFrom) {
|
||||
const from = new Date(query.sentFrom);
|
||||
if (Number.isNaN(from.getTime())) {
|
||||
throw new BadRequestException('Invalid sentFrom date');
|
||||
}
|
||||
sentAtFilter.gte = from;
|
||||
}
|
||||
if (query.sentTo) {
|
||||
const to = new Date(query.sentTo);
|
||||
if (Number.isNaN(to.getTime())) {
|
||||
throw new BadRequestException('Invalid sentTo date');
|
||||
}
|
||||
to.setHours(23, 59, 59, 999);
|
||||
sentAtFilter.lte = to;
|
||||
}
|
||||
|
||||
// Status: explicit status wins; completed=true/false narrows; otherwise no status filter.
|
||||
let status: LabTaskStatus | undefined;
|
||||
if (query.status) {
|
||||
status = query.status;
|
||||
} else if (query.completed === true) {
|
||||
status = LabTaskStatus.COMPLETED;
|
||||
} else if (query.completed === false) {
|
||||
status = LabTaskStatus.IN_PROGRESS;
|
||||
}
|
||||
|
||||
return {
|
||||
labCase: {
|
||||
sentAt: sentAtFilter,
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
...(query.clinicOrganizationId
|
||||
? { treatment: { organizationId: query.clinicOrganizationId } }
|
||||
: {}),
|
||||
...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}),
|
||||
},
|
||||
...(status !== undefined ? { status } : {}),
|
||||
...(query.important !== undefined ? { isImportant: query.important } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private buildSearchWhere(q: string): Prisma.TreatmentWhereInput {
|
||||
const orConditions: Prisma.PatientWhereInput[] = [
|
||||
{ firstName: { contains: q, mode: 'insensitive' } },
|
||||
{ lastName: { contains: q, mode: 'insensitive' } },
|
||||
];
|
||||
const normalized = normalizeMobile(q);
|
||||
if (normalized) {
|
||||
orConditions.push({ mobile: normalized });
|
||||
}
|
||||
return {
|
||||
OR: [
|
||||
{ patient: { OR: orConditions } },
|
||||
{ organization: { name: { contains: q, mode: 'insensitive' } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private buildOrderBy(query: ListLabTasksDto): Prisma.LabCaseTaskOrderByWithRelationInput[] {
|
||||
const dir = query.sortDir ?? 'desc';
|
||||
switch (query.sortBy) {
|
||||
case 'status':
|
||||
return [{ status: dir }, { createdAt: 'desc' }, { id: 'asc' }];
|
||||
case 'clinic':
|
||||
return [
|
||||
{ labCase: { treatment: { organization: { name: dir } } } },
|
||||
{ createdAt: 'desc' },
|
||||
{ id: 'asc' },
|
||||
];
|
||||
case 'patient':
|
||||
return [
|
||||
{ labCase: { treatment: { patient: { lastName: dir } } } },
|
||||
{ labCase: { treatment: { patient: { firstName: dir } } } },
|
||||
{ id: 'asc' },
|
||||
];
|
||||
case 'important':
|
||||
return [{ isImportant: dir }, { createdAt: 'desc' }, { id: 'asc' }];
|
||||
case 'date':
|
||||
default:
|
||||
return [
|
||||
{ labCase: { sentAt: dir } },
|
||||
{ labCaseId: 'asc' },
|
||||
{ treatmentDetailId: 'asc' },
|
||||
{ stepOrder: 'asc' },
|
||||
{ id: 'asc' },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private mapTaskListItem(
|
||||
task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
|
||||
prosthesisLabels: Map<string, string>,
|
||||
@@ -161,21 +249,22 @@ export class TasksService {
|
||||
return {
|
||||
id: task.id,
|
||||
labCaseId: task.labCaseId,
|
||||
tooth: task.tooth,
|
||||
treatmentDetailId: task.treatmentDetailId,
|
||||
teeth: normalizeTaskTeeth(task.teeth),
|
||||
treatmentType: task.treatmentType,
|
||||
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||
prosthesisTypeLabel:
|
||||
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
|
||||
workflowStepCode: task.workflowStepCode,
|
||||
stepOrder: task.stepOrder,
|
||||
stepLabel: task.stepLabel,
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
assignedAt: task.assignedAt?.toISOString() ?? null,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
assigneeUserId: task.assigneeUserId,
|
||||
assignee: task.assignee
|
||||
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
|
||||
isImportant: task.isImportant,
|
||||
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
|
||||
lastStatusChangedBy: task.lastStatusChangedBy
|
||||
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
|
||||
: null,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
clinic: task.labCase.treatment.organization,
|
||||
patient: {
|
||||
id: task.labCase.treatment.patient.id,
|
||||
|
||||
Reference in New Issue
Block a user