improvement: tasks feature UX fully overhauled.

This commit is contained in:
2026-07-13 02:09:49 +03:30
parent f28cd06615
commit 4e6ed75844
22 changed files with 833 additions and 210 deletions

View File

@@ -65,6 +65,11 @@ export class ListLabTasksDto {
@IsDateString()
sentTo?: string;
/** Workflow step code (e.g. design) completed within the prosthesis group. */
@IsOptional()
@IsString()
stepCompleted?: string;
@IsOptional()
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
sortBy?: TaskSortField;

View File

@@ -19,6 +19,17 @@ export class TasksController {
return this.tasksService.list(organizationId, req.user.id, query, req.user.language);
}
@Get('filter-options')
@ApiOperation({ summary: 'Filter options for lab tasks list' })
listFilterOptions(@Req() req) {
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
return this.tasksService.listFilterOptions(
organizationId,
req.user.id,
req.user.language,
);
}
@Patch(':taskId')
@ApiOperation({ summary: 'Update task status' })
updateStatus(

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { CatalogModule } from '../catalog/catalog.module';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
imports: [CatalogModule],
controllers: [TasksController],
providers: [TasksService],
})

View File

@@ -55,7 +55,7 @@ export class TasksService {
const limit = Math.min(Math.max(query.limit ?? 50, 1), 100);
const skip = (page - 1) * limit;
const where = this.buildListWhere(labOrganizationId, query);
const where = await this.buildListWhere(labOrganizationId, query);
const [items, total] = await Promise.all([
this.prisma.labCaseTask.findMany({
@@ -149,10 +149,60 @@ export class TasksService {
return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) };
}
private buildListWhere(
async listFilterOptions(
labOrganizationId: string,
actorUserId: string,
localeInput?: string | null,
) {
await this.assertCanReadTasks(actorUserId, labOrganizationId);
const rows = await this.prisma.labCase.findMany({
where: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
select: {
treatment: {
select: {
organization: { select: { id: true, name: true } },
},
},
},
});
const clinicsById = new Map<string, { id: string; name: string }>();
for (const row of rows) {
clinicsById.set(row.treatment.organization.id, row.treatment.organization);
}
const locale = normalizeCatalogLocale(localeInput);
const steps = await this.prisma.labWorkflowStep.findMany({
orderBy: { sortOrder: 'asc' },
select: { code: true },
});
const stepCodes = steps.map((s) => s.code);
const stepLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.LAB_WORKFLOW_STEP,
stepCodes,
locale,
);
return {
success: true,
data: {
clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)),
workflowSteps: steps.map((step) => ({
code: step.code,
label: stepLabels.get(step.code) ?? step.code,
})),
},
};
}
private async buildListWhere(
labOrganizationId: string,
query: ListLabTasksDto,
): Prisma.LabCaseTaskWhereInput {
): Promise<Prisma.LabCaseTaskWhereInput> {
const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null };
if (query.sentFrom) {
@@ -181,18 +231,47 @@ export class TasksService {
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()) } : {}),
...(query.important !== undefined ? { isImportant: query.important } : {}),
},
const labCaseScope: Prisma.LabCaseWhereInput = {
sentAt: sentAtFilter,
sends: { some: { organizationId: labOrganizationId } },
...(query.clinicOrganizationId
? { treatment: { organizationId: query.clinicOrganizationId } }
: {}),
...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}),
...(query.important !== undefined ? { isImportant: query.important } : {}),
};
const base: Prisma.LabCaseTaskWhereInput = {
labCase: labCaseScope,
...(status !== undefined ? { status } : {}),
};
const stepCompleted = query.stepCompleted?.trim();
if (!stepCompleted) {
return base;
}
const completedGroups = await this.prisma.labCaseTask.groupBy({
by: ['labCaseId', 'treatmentDetailId', 'prosthesisTypeCode'],
where: {
workflowStepCode: stepCompleted,
status: LabTaskStatus.COMPLETED,
labCase: labCaseScope,
},
});
if (completedGroups.length === 0) {
return { id: { in: [] } };
}
return {
...base,
OR: completedGroups.map((group) => ({
labCaseId: group.labCaseId,
treatmentDetailId: group.treatmentDetailId,
prosthesisTypeCode: group.prosthesisTypeCode,
})),
};
}
private buildSearchWhere(q: string): Prisma.TreatmentWhereInput {
@@ -214,40 +293,50 @@ export class TasksService {
private buildOrderBy(query: ListLabTasksDto): Prisma.LabCaseTaskOrderByWithRelationInput[] {
const dir = query.sortDir ?? 'desc';
const stepTiebreakers: Prisma.LabCaseTaskOrderByWithRelationInput[] = [
{ stepOrder: 'asc' },
{ id: 'asc' },
];
switch (query.sortBy) {
case 'status':
return [{ status: dir }, { createdAt: 'desc' }, { id: 'asc' }];
return [{ status: dir }, { createdAt: 'desc' }, ...stepTiebreakers];
case 'clinic':
return [
{ labCase: { treatment: { organization: { name: dir } } } },
{ createdAt: 'desc' },
{ id: 'asc' },
...stepTiebreakers,
];
case 'patient':
return [
{ labCase: { treatment: { patient: { lastName: dir } } } },
{ labCase: { treatment: { patient: { firstName: dir } } } },
{ id: 'asc' },
...stepTiebreakers,
];
case 'important':
return [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, { id: 'asc' }];
return [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, ...stepTiebreakers];
case 'prosthesis':
return [{ prosthesisTypeCode: dir }, { createdAt: 'desc' }, { id: 'asc' }];
return [
{ prosthesisTypeCode: dir },
{ createdAt: 'desc' },
...stepTiebreakers,
];
case 'taskType':
return [
{ workflowStepCode: dir },
{ stepOrder: 'asc' },
{ createdAt: 'desc' },
{ id: 'asc' },
...stepTiebreakers,
];
case 'date':
default:
// date / caseId / taskId / stepId — newest first by default.
return [
{ labCase: { sentAt: dir } },
{ labCaseId: dir },
{ id: dir },
{ stepOrder: dir },
{ labCaseId: 'asc' },
{ treatmentDetailId: 'asc' },
{ prosthesisTypeCode: 'asc' },
{ stepOrder: 'asc' },
{ id: 'asc' },
];
}
}
@@ -275,6 +364,7 @@ export class TasksService {
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
: null,
createdAt: task.createdAt.toISOString(),
caseSentAt: task.labCase.sentAt?.toISOString() ?? null,
clinic: task.labCase.treatment.organization,
patient: {
id: task.labCase.treatment.patient.id,