improvement: sorts and filters updated for tasks feature.

This commit is contained in:
2026-07-13 13:22:38 +03:30
parent 4e6ed75844
commit 0d073f1ec0
16 changed files with 666 additions and 102 deletions

View File

@@ -442,6 +442,7 @@ export class CasesService {
private mapLabCaseListItem(lc: {
id: string;
sentAt: Date | null;
isImportant: boolean;
treatment: {
organization: { id: string; name: string };
patient: { id: string; firstName: string; lastName: string; mobile: string };
@@ -455,6 +456,7 @@ export class CasesService {
return {
id: lc.id,
sentAt: lc.sentAt?.toISOString() ?? null,
isImportant: lc.isImportant,
clinic: lc.treatment.organization,
patient: {
id: lc.treatment.patient.id,

View File

@@ -57,6 +57,12 @@ export class ListLabTasksDto {
@IsBoolean()
important?: boolean;
/** When true, important lab cases are listed before others (does not hide non-important). */
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
pinImportant?: boolean;
@IsOptional()
@IsDateString()
sentFrom?: string;
@@ -70,6 +76,11 @@ export class ListLabTasksDto {
@IsString()
stepCompleted?: string;
/** Narrow list to a single lab case (e.g. show-in-case navigation). */
@IsOptional()
@IsUUID()
labCaseId?: string;
@IsOptional()
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
sortBy?: TaskSortField;
@@ -91,3 +102,63 @@ export class ListLabTasksDto {
@Max(100)
limit = 50;
}
/** Same filters as list (no page) — used to find which page contains a task. */
export class LocateTaskPageDto {
@IsUUID()
taskId: string;
@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()
@Transform(toBoolean)
@IsBoolean()
pinImportant?: boolean;
@IsOptional()
@IsDateString()
sentFrom?: string;
@IsOptional()
@IsDateString()
sentTo?: string;
@IsOptional()
@IsString()
stepCompleted?: string;
@IsOptional()
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
sortBy?: TaskSortField;
@IsOptional()
@IsIn(['asc', 'desc'])
sortDir?: 'asc' | 'desc';
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
@Max(100)
limit = 50;
}

View File

@@ -2,7 +2,7 @@ import { Body, Controller, Get, Param, Patch, Query, Req, UseGuards } from '@nes
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto';
import { TasksService } from './tasks.service';
@ApiTags('tasks')
@@ -19,6 +19,13 @@ export class TasksController {
return this.tasksService.list(organizationId, req.user.id, query, req.user.language);
}
@Get('locate-page')
@ApiOperation({ summary: 'Find pagination page for a task in the sorted list' })
locatePage(@Query() query: LocateTaskPageDto, @Req() req) {
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
return this.tasksService.locateTaskPage(organizationId, req.user.id, query);
}
@Get('filter-options')
@ApiOperation({ summary: 'Filter options for lab tasks list' })
listFilterOptions(@Req() req) {

View File

@@ -12,7 +12,7 @@ import {
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
import { normalizeTaskTeeth } from '../cases/lab-case-task.util';
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto';
import { hasEffectivePermission } from '../../common/membership-permissions';
const taskListInclude = {
@@ -90,6 +90,68 @@ export class TasksService {
};
}
async locateTaskPage(
labOrganizationId: string,
actorUserId: string,
query: LocateTaskPageDto,
) {
await this.assertCanReadTasks(actorUserId, labOrganizationId);
const limit = Math.min(Math.max(query.limit ?? 50, 1), 100);
const listQuery = this.toListQueryFromLocate(query);
const target = await this.prisma.labCaseTask.findFirst({
where: {
id: query.taskId,
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
},
include: {
labCase: { select: { sentAt: true } },
},
});
if (!target?.labCase.sentAt) {
throw new NotFoundException('Task not found');
}
const where = await this.buildListWhere(labOrganizationId, listQuery);
const inFilteredSet = await this.prisma.labCaseTask.count({
where: { AND: [where, { id: query.taskId }] },
});
if (inFilteredSet === 0) {
return {
success: true,
data: { page: 1, found: false, labCaseId: target.labCaseId },
};
}
const position = await this.countTasksBeforeSortedPosition(
where,
listQuery,
{
sentAt: target.labCase.sentAt,
labCaseId: target.labCaseId,
treatmentDetailId: target.treatmentDetailId,
prosthesisTypeCode: target.prosthesisTypeCode,
stepOrder: target.stepOrder,
id: target.id,
},
);
return {
success: true,
data: {
page: Math.floor(position / limit) + 1,
found: true,
labCaseId: target.labCaseId,
},
};
}
async updateStatus(
taskId: string,
dto: UpdateLabTaskDto,
@@ -238,10 +300,10 @@ export class TasksService {
? { treatment: { organizationId: query.clinicOrganizationId } }
: {}),
...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}),
...(query.important !== undefined ? { isImportant: query.important } : {}),
};
const base: Prisma.LabCaseTaskWhereInput = {
...(query.labCaseId ? { labCaseId: query.labCaseId } : {}),
labCase: labCaseScope,
...(status !== undefined ? { status } : {}),
};
@@ -274,6 +336,97 @@ export class TasksService {
};
}
private toListQueryFromLocate(query: LocateTaskPageDto): ListLabTasksDto {
return {
page: 1,
q: query.q,
clinicOrganizationId: query.clinicOrganizationId,
status: query.status,
completed: query.completed,
important: query.important,
pinImportant: query.pinImportant,
sentFrom: query.sentFrom,
sentTo: query.sentTo,
stepCompleted: query.stepCompleted,
sortBy: query.sortBy,
sortDir: query.sortDir,
limit: query.limit,
};
}
private async countTasksBeforeSortedPosition(
where: Prisma.LabCaseTaskWhereInput,
query: ListLabTasksDto,
target: {
sentAt: Date;
labCaseId: string;
treatmentDetailId: string;
prosthesisTypeCode: string;
stepOrder: number;
id: string;
},
): Promise<number> {
const sortBy = query.sortBy ?? 'date';
const dir = query.sortDir ?? 'desc';
if (sortBy !== 'date') {
throw new BadRequestException('Task page location is only supported for date sort');
}
const sentAt = target.sentAt;
const sameSentAt = { labCase: { sentAt } };
const tupleBefore: Prisma.LabCaseTaskWhereInput[] = [
{
AND: [sameSentAt, { labCaseId: { lt: target.labCaseId } }],
},
{
AND: [
sameSentAt,
{ labCaseId: target.labCaseId },
{ treatmentDetailId: { lt: target.treatmentDetailId } },
],
},
{
AND: [
sameSentAt,
{ labCaseId: target.labCaseId },
{ treatmentDetailId: target.treatmentDetailId },
{ prosthesisTypeCode: { lt: target.prosthesisTypeCode } },
],
},
{
AND: [
sameSentAt,
{ labCaseId: target.labCaseId },
{ treatmentDetailId: target.treatmentDetailId },
{ prosthesisTypeCode: target.prosthesisTypeCode },
{ stepOrder: { lt: target.stepOrder } },
],
},
{
AND: [
sameSentAt,
{ labCaseId: target.labCaseId },
{ treatmentDetailId: target.treatmentDetailId },
{ prosthesisTypeCode: target.prosthesisTypeCode },
{ stepOrder: target.stepOrder },
{ id: { lt: target.id } },
],
},
];
const sentAtBefore: Prisma.LabCaseTaskWhereInput =
dir === 'desc'
? { labCase: { sentAt: { gt: sentAt } } }
: { labCase: { sentAt: { lt: sentAt } } };
return this.prisma.labCaseTask.count({
where: {
AND: [where, { OR: [sentAtBefore, ...tupleBefore] }],
},
});
}
private buildSearchWhere(q: string): Prisma.TreatmentWhereInput {
const orConditions: Prisma.PatientWhereInput[] = [
{ firstName: { contains: q, mode: 'insensitive' } },
@@ -298,39 +451,47 @@ export class TasksService {
{ id: 'asc' },
];
let orderBy: Prisma.LabCaseTaskOrderByWithRelationInput[];
switch (query.sortBy) {
case 'status':
return [{ status: dir }, { createdAt: 'desc' }, ...stepTiebreakers];
orderBy = [{ status: dir }, { createdAt: 'desc' }, ...stepTiebreakers];
break;
case 'clinic':
return [
orderBy = [
{ labCase: { treatment: { organization: { name: dir } } } },
{ createdAt: 'desc' },
...stepTiebreakers,
];
break;
case 'patient':
return [
orderBy = [
{ labCase: { treatment: { patient: { lastName: dir } } } },
{ labCase: { treatment: { patient: { firstName: dir } } } },
...stepTiebreakers,
];
break;
case 'important':
return [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, ...stepTiebreakers];
orderBy = [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, ...stepTiebreakers];
break;
case 'prosthesis':
return [
orderBy = [
{ prosthesisTypeCode: dir },
{ createdAt: 'desc' },
...stepTiebreakers,
];
break;
case 'taskType':
return [
orderBy = [
{ workflowStepCode: dir },
{ stepOrder: 'asc' },
{ createdAt: 'desc' },
...stepTiebreakers,
];
break;
case 'date':
default:
return [
orderBy = [
{ labCase: { sentAt: dir } },
{ labCaseId: 'asc' },
{ treatmentDetailId: 'asc' },
@@ -338,7 +499,14 @@ export class TasksService {
{ stepOrder: 'asc' },
{ id: 'asc' },
];
break;
}
if (query.pinImportant) {
return [{ labCase: { isImportant: 'desc' } }, ...orderBy];
}
return orderBy;
}
private mapTaskListItem(