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

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

View File

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