improvement: sorts and filters updated for tasks feature.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user