improvement: sorts and filters updated for tasks feature.
This commit is contained in:
@@ -9,6 +9,7 @@ alwaysApply: false
|
|||||||
- **Default sort:** newest `labCase.sentAt` first; `stepOrder` asc within prosthesis group (backend `buildOrderBy`).
|
- **Default sort:** newest `labCase.sentAt` first; `stepOrder` asc within prosthesis group (backend `buildOrderBy`).
|
||||||
- **Grouping:** only when `sortBy=date`; flat list + hint for other sorts.
|
- **Grouping:** only when `sortBy=date`; flat list + hint for other sorts.
|
||||||
- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeBadgeStyleFromCatalog` — never row index.
|
- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeBadgeStyleFromCatalog` — never row index.
|
||||||
- **Step completed filter:** `stepCompleted` query param; groups where that step is `COMPLETED`; with `IN_PROGRESS` status shows remaining open tasks only.
|
- **Important only:** server-side `important=true` (not client per-page).
|
||||||
|
- **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll.
|
||||||
|
|
||||||
Full map: `.cursor/skills/lab-tasks/SKILL.md`
|
Full map: `.cursor/skills/lab-tasks/SKILL.md`
|
||||||
|
|||||||
@@ -38,15 +38,22 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
|
|||||||
|-------|-----|-----|
|
|-------|-----|-----|
|
||||||
| `q`, `clinicOrganizationId`, `status` | `GET /tasks` | Search, clinic, status |
|
| `q`, `clinicOrganizationId`, `status` | `GET /tasks` | Search, clinic, status |
|
||||||
| `stepCompleted` | `GET /tasks` | Workflow step dropdown |
|
| `stepCompleted` | `GET /tasks` | Workflow step dropdown |
|
||||||
|
| `important` | `GET /tasks` | Important cases only |
|
||||||
| Clinics + steps options | `GET /tasks/filter-options` | Populates dropdowns (not from current page) |
|
| Clinics + steps options | `GET /tasks/filter-options` | Populates dropdowns (not from current page) |
|
||||||
|
|
||||||
**Step completed filter:** Restricts to prosthesis groups `(labCaseId, treatmentDetailId, prosthesisTypeCode)` where that `workflowStepCode` task is `COMPLETED`. Combined with `status=IN_PROGRESS`, returns only in-progress tasks in those groups (completed step row hidden).
|
**Step completed filter:** Restricts to prosthesis groups `(labCaseId, treatmentDetailId, prosthesisTypeCode)` where that `workflowStepCode` task is `COMPLETED`. Combined with `status=IN_PROGRESS`, returns only in-progress tasks in those groups (completed step row hidden).
|
||||||
|
|
||||||
|
- **Important only:** server-side `important=true` on `GET /tasks` (full list pagination, not per-page client filter).
|
||||||
|
- **Reset view:** `resetView` restores `DEFAULT_TASKS_VIEW` from `tasksViewDefaults.ts`.
|
||||||
|
- **Show in case:** flat-sort rows only; resets filters/sort, calls `GET /tasks/locate-page` to find the correct page in the full default-sorted list, then highlights + scrolls to the task.
|
||||||
|
- **Complete animation:** when marking done under in-progress filter, row plays exit animation + success toast before refetch.
|
||||||
|
|
||||||
## APIs
|
## APIs
|
||||||
|
|
||||||
- `GET /tasks` — paginated flat task list (grouping is client-side when `sortBy=date`)
|
- `GET /tasks` — paginated flat task list (grouping is client-side when `sortBy=date`)
|
||||||
- `PATCH /tasks/:taskId` — update status
|
- `PATCH /tasks/:taskId` — update status
|
||||||
- `GET /tasks/filter-options` — clinics + workflow steps (localized)
|
- `GET /tasks/filter-options` — clinics + workflow steps (localized)
|
||||||
|
- `GET /tasks/locate-page` — page number for a task in the sorted filtered list
|
||||||
|
|
||||||
List items include `caseSentAt` for case headers.
|
List items include `caseSentAt` for case headers.
|
||||||
|
|
||||||
|
|||||||
@@ -442,6 +442,7 @@ export class CasesService {
|
|||||||
private mapLabCaseListItem(lc: {
|
private mapLabCaseListItem(lc: {
|
||||||
id: string;
|
id: string;
|
||||||
sentAt: Date | null;
|
sentAt: Date | null;
|
||||||
|
isImportant: boolean;
|
||||||
treatment: {
|
treatment: {
|
||||||
organization: { id: string; name: string };
|
organization: { id: string; name: string };
|
||||||
patient: { id: string; firstName: string; lastName: string; mobile: string };
|
patient: { id: string; firstName: string; lastName: string; mobile: string };
|
||||||
@@ -455,6 +456,7 @@ export class CasesService {
|
|||||||
return {
|
return {
|
||||||
id: lc.id,
|
id: lc.id,
|
||||||
sentAt: lc.sentAt?.toISOString() ?? null,
|
sentAt: lc.sentAt?.toISOString() ?? null,
|
||||||
|
isImportant: lc.isImportant,
|
||||||
clinic: lc.treatment.organization,
|
clinic: lc.treatment.organization,
|
||||||
patient: {
|
patient: {
|
||||||
id: lc.treatment.patient.id,
|
id: lc.treatment.patient.id,
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ export class ListLabTasksDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
important?: boolean;
|
important?: boolean;
|
||||||
|
|
||||||
|
/** When true, important lab cases are listed before others (does not hide non-important). */
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
pinImportant?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
sentFrom?: string;
|
sentFrom?: string;
|
||||||
@@ -70,6 +76,11 @@ export class ListLabTasksDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
stepCompleted?: string;
|
stepCompleted?: string;
|
||||||
|
|
||||||
|
/** Narrow list to a single lab case (e.g. show-in-case navigation). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
labCaseId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
|
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
@@ -91,3 +102,63 @@ export class ListLabTasksDto {
|
|||||||
@Max(100)
|
@Max(100)
|
||||||
limit = 50;
|
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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.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';
|
import { TasksService } from './tasks.service';
|
||||||
|
|
||||||
@ApiTags('tasks')
|
@ApiTags('tasks')
|
||||||
@@ -19,6 +19,13 @@ export class TasksController {
|
|||||||
return this.tasksService.list(organizationId, req.user.id, query, req.user.language);
|
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')
|
@Get('filter-options')
|
||||||
@ApiOperation({ summary: 'Filter options for lab tasks list' })
|
@ApiOperation({ summary: 'Filter options for lab tasks list' })
|
||||||
listFilterOptions(@Req() req) {
|
listFilterOptions(@Req() req) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
normalizeCatalogLocale,
|
normalizeCatalogLocale,
|
||||||
} from '../catalog/catalog-label.service';
|
} from '../catalog/catalog-label.service';
|
||||||
import { normalizeTaskTeeth } from '../cases/lab-case-task.util';
|
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';
|
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||||
|
|
||||||
const taskListInclude = {
|
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(
|
async updateStatus(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
dto: UpdateLabTaskDto,
|
dto: UpdateLabTaskDto,
|
||||||
@@ -238,10 +300,10 @@ export class TasksService {
|
|||||||
? { treatment: { organizationId: query.clinicOrganizationId } }
|
? { treatment: { organizationId: query.clinicOrganizationId } }
|
||||||
: {}),
|
: {}),
|
||||||
...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}),
|
...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}),
|
||||||
...(query.important !== undefined ? { isImportant: query.important } : {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const base: Prisma.LabCaseTaskWhereInput = {
|
const base: Prisma.LabCaseTaskWhereInput = {
|
||||||
|
...(query.labCaseId ? { labCaseId: query.labCaseId } : {}),
|
||||||
labCase: labCaseScope,
|
labCase: labCaseScope,
|
||||||
...(status !== undefined ? { status } : {}),
|
...(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 {
|
private buildSearchWhere(q: string): Prisma.TreatmentWhereInput {
|
||||||
const orConditions: Prisma.PatientWhereInput[] = [
|
const orConditions: Prisma.PatientWhereInput[] = [
|
||||||
{ firstName: { contains: q, mode: 'insensitive' } },
|
{ firstName: { contains: q, mode: 'insensitive' } },
|
||||||
@@ -298,39 +451,47 @@ export class TasksService {
|
|||||||
{ id: 'asc' },
|
{ id: 'asc' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let orderBy: Prisma.LabCaseTaskOrderByWithRelationInput[];
|
||||||
|
|
||||||
switch (query.sortBy) {
|
switch (query.sortBy) {
|
||||||
case 'status':
|
case 'status':
|
||||||
return [{ status: dir }, { createdAt: 'desc' }, ...stepTiebreakers];
|
orderBy = [{ status: dir }, { createdAt: 'desc' }, ...stepTiebreakers];
|
||||||
|
break;
|
||||||
case 'clinic':
|
case 'clinic':
|
||||||
return [
|
orderBy = [
|
||||||
{ labCase: { treatment: { organization: { name: dir } } } },
|
{ labCase: { treatment: { organization: { name: dir } } } },
|
||||||
{ createdAt: 'desc' },
|
{ createdAt: 'desc' },
|
||||||
...stepTiebreakers,
|
...stepTiebreakers,
|
||||||
];
|
];
|
||||||
|
break;
|
||||||
case 'patient':
|
case 'patient':
|
||||||
return [
|
orderBy = [
|
||||||
{ labCase: { treatment: { patient: { lastName: dir } } } },
|
{ labCase: { treatment: { patient: { lastName: dir } } } },
|
||||||
{ labCase: { treatment: { patient: { firstName: dir } } } },
|
{ labCase: { treatment: { patient: { firstName: dir } } } },
|
||||||
...stepTiebreakers,
|
...stepTiebreakers,
|
||||||
];
|
];
|
||||||
|
break;
|
||||||
case 'important':
|
case 'important':
|
||||||
return [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, ...stepTiebreakers];
|
orderBy = [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, ...stepTiebreakers];
|
||||||
|
break;
|
||||||
case 'prosthesis':
|
case 'prosthesis':
|
||||||
return [
|
orderBy = [
|
||||||
{ prosthesisTypeCode: dir },
|
{ prosthesisTypeCode: dir },
|
||||||
{ createdAt: 'desc' },
|
{ createdAt: 'desc' },
|
||||||
...stepTiebreakers,
|
...stepTiebreakers,
|
||||||
];
|
];
|
||||||
|
break;
|
||||||
case 'taskType':
|
case 'taskType':
|
||||||
return [
|
orderBy = [
|
||||||
{ workflowStepCode: dir },
|
{ workflowStepCode: dir },
|
||||||
{ stepOrder: 'asc' },
|
{ stepOrder: 'asc' },
|
||||||
{ createdAt: 'desc' },
|
{ createdAt: 'desc' },
|
||||||
...stepTiebreakers,
|
...stepTiebreakers,
|
||||||
];
|
];
|
||||||
|
break;
|
||||||
case 'date':
|
case 'date':
|
||||||
default:
|
default:
|
||||||
return [
|
orderBy = [
|
||||||
{ labCase: { sentAt: dir } },
|
{ labCase: { sentAt: dir } },
|
||||||
{ labCaseId: 'asc' },
|
{ labCaseId: 'asc' },
|
||||||
{ treatmentDetailId: 'asc' },
|
{ treatmentDetailId: 'asc' },
|
||||||
@@ -338,7 +499,14 @@ export class TasksService {
|
|||||||
{ stepOrder: 'asc' },
|
{ stepOrder: 'asc' },
|
||||||
{ id: 'asc' },
|
{ id: 'asc' },
|
||||||
];
|
];
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (query.pinImportant) {
|
||||||
|
return [{ labCase: { isImportant: 'desc' } }, ...orderBy];
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapTaskListItem(
|
private mapTaskListItem(
|
||||||
|
|||||||
@@ -482,7 +482,14 @@
|
|||||||
"filterStepCompleted": "Step completed",
|
"filterStepCompleted": "Step completed",
|
||||||
"filterStepCompletedAll": "Any step",
|
"filterStepCompletedAll": "Any step",
|
||||||
"showCompleted": "Show completed",
|
"showCompleted": "Show completed",
|
||||||
"importantOnly": "Important only",
|
"importantOnly": "Important first",
|
||||||
|
"resetView": "Reset filters & sort",
|
||||||
|
"showInCase": "Show in case",
|
||||||
|
"showInCaseNotFound": "This task is not in the default in-progress list.",
|
||||||
|
"showInCaseError": "Could not locate this task in the list.",
|
||||||
|
"locatingCase": "Finding case in list…",
|
||||||
|
"taskCompletedToast": "Step completed — nice work!",
|
||||||
|
"taskCompletedFlash": "Done",
|
||||||
"filterSentFrom": "From",
|
"filterSentFrom": "From",
|
||||||
"filterSentTo": "To",
|
"filterSentTo": "To",
|
||||||
"sortBy": "Sort by",
|
"sortBy": "Sort by",
|
||||||
|
|||||||
@@ -483,7 +483,14 @@
|
|||||||
"filterStepCompleted": "مرحله تکمیلشده",
|
"filterStepCompleted": "مرحله تکمیلشده",
|
||||||
"filterStepCompletedAll": "هر مرحلهای",
|
"filterStepCompletedAll": "هر مرحلهای",
|
||||||
"showCompleted": "نمایش تکمیلشدهها",
|
"showCompleted": "نمایش تکمیلشدهها",
|
||||||
"importantOnly": "فقط مهمها",
|
"importantOnly": "مهمها در ابتدا",
|
||||||
|
"resetView": "بازنشانی فیلترها و مرتبسازی",
|
||||||
|
"showInCase": "نمایش در پرونده",
|
||||||
|
"showInCaseNotFound": "این کار در فهرست پیشفرض در حال انجام نیست.",
|
||||||
|
"showInCaseError": "یافتن این کار در فهرست ممکن نشد.",
|
||||||
|
"locatingCase": "در حال یافتن پرونده در فهرست…",
|
||||||
|
"taskCompletedToast": "مرحله تکمیل شد — آفرین!",
|
||||||
|
"taskCompletedFlash": "انجام شد",
|
||||||
"filterSentFrom": "از",
|
"filterSentFrom": "از",
|
||||||
"filterSentTo": "تا",
|
"filterSentTo": "تا",
|
||||||
"sortBy": "مرتبسازی بر اساس",
|
"sortBy": "مرتبسازی بر اساس",
|
||||||
|
|||||||
@@ -483,7 +483,14 @@
|
|||||||
"filterStepCompleted": "Stap voltooid",
|
"filterStepCompleted": "Stap voltooid",
|
||||||
"filterStepCompletedAll": "Elke stap",
|
"filterStepCompletedAll": "Elke stap",
|
||||||
"showCompleted": "Voltooide tonen",
|
"showCompleted": "Voltooide tonen",
|
||||||
"importantOnly": "Alleen belangrijk",
|
"importantOnly": "Belangrijke cases eerst",
|
||||||
|
"resetView": "Filters en sortering resetten",
|
||||||
|
"showInCase": "In case tonen",
|
||||||
|
"showInCaseNotFound": "Deze taak staat niet in de standaardlijst met taken in uitvoering.",
|
||||||
|
"showInCaseError": "Kon deze taak niet in de lijst vinden.",
|
||||||
|
"locatingCase": "Case in lijst zoeken…",
|
||||||
|
"taskCompletedToast": "Stap voltooid — goed gedaan!",
|
||||||
|
"taskCompletedFlash": "Klaar",
|
||||||
"filterSentFrom": "Vanaf",
|
"filterSentFrom": "Vanaf",
|
||||||
"filterSentTo": "Tot",
|
"filterSentTo": "Tot",
|
||||||
"sortBy": "Sorteren op",
|
"sortBy": "Sorteren op",
|
||||||
|
|||||||
51
frontend/src/components/lab/tasksViewDefaults.ts
Normal file
51
frontend/src/components/lab/tasksViewDefaults.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { LabTaskStatus, TaskSortField } from '@/types/cases';
|
||||||
|
|
||||||
|
export type TasksViewState = {
|
||||||
|
search: string;
|
||||||
|
clinicId: string;
|
||||||
|
statusFilter: LabTaskStatus | '';
|
||||||
|
stepCompleted: string;
|
||||||
|
sortBy: TaskSortField;
|
||||||
|
sortDir: 'asc' | 'desc';
|
||||||
|
importantOnly: boolean;
|
||||||
|
page: number;
|
||||||
|
highlightTaskId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_TASKS_VIEW: TasksViewState = {
|
||||||
|
search: '',
|
||||||
|
clinicId: '',
|
||||||
|
statusFilter: 'IN_PROGRESS',
|
||||||
|
stepCompleted: '',
|
||||||
|
sortBy: 'date',
|
||||||
|
sortDir: 'desc',
|
||||||
|
importantOnly: false,
|
||||||
|
page: 1,
|
||||||
|
highlightTaskId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TASK_COMPLETE_EXIT_MS = 550;
|
||||||
|
|
||||||
|
export function isDefaultTasksView(state: TasksViewState): boolean {
|
||||||
|
return (
|
||||||
|
state.search === DEFAULT_TASKS_VIEW.search &&
|
||||||
|
state.clinicId === DEFAULT_TASKS_VIEW.clinicId &&
|
||||||
|
state.statusFilter === DEFAULT_TASKS_VIEW.statusFilter &&
|
||||||
|
state.stepCompleted === DEFAULT_TASKS_VIEW.stepCompleted &&
|
||||||
|
state.sortBy === DEFAULT_TASKS_VIEW.sortBy &&
|
||||||
|
state.sortDir === DEFAULT_TASKS_VIEW.sortDir &&
|
||||||
|
state.importantOnly === DEFAULT_TASKS_VIEW.importantOnly &&
|
||||||
|
state.page === DEFAULT_TASKS_VIEW.page &&
|
||||||
|
state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDefaultLocateParams(taskId: string, limit: number) {
|
||||||
|
return {
|
||||||
|
taskId,
|
||||||
|
limit,
|
||||||
|
sortBy: 'date' as const,
|
||||||
|
sortDir: 'desc' as const,
|
||||||
|
status: 'IN_PROGRESS' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { casesApi } from '@/lib/api/cases';
|
|||||||
import { tasksApi } from '@/lib/api/tasks';
|
import { tasksApi } from '@/lib/api/tasks';
|
||||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||||
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
|
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
|
||||||
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton';
|
import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton';
|
||||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||||
@@ -216,6 +217,11 @@ export function CasesPage() {
|
|||||||
try {
|
try {
|
||||||
const response = await casesApi.setCaseImportant(selectedCaseId, isImportant);
|
const response = await casesApi.setCaseImportant(selectedCaseId, isImportant);
|
||||||
setSelectedCase(response.data);
|
setSelectedCase(response.data);
|
||||||
|
setCases((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === selectedCaseId ? { ...item, isImportant: response.data.isImportant } : item,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setSelectedCase(previousCase);
|
setSelectedCase(previousCase);
|
||||||
toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
|
toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
|
||||||
@@ -345,9 +351,16 @@ export function CasesPage() {
|
|||||||
: 'border-border hover:border-primary/40'
|
: 'border-border hover:border-primary/40'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<div className="font-medium text-text-primary">
|
<div className="font-medium text-text-primary">
|
||||||
{formatPatientName(item.patient)}
|
{formatPatientName(item.patient)}
|
||||||
</div>
|
</div>
|
||||||
|
{item.isImportant ? (
|
||||||
|
<Badge variant="warning" fixedWidth={false}>
|
||||||
|
{t('importantLabel')}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-text-muted mt-0.5">
|
<div className="text-xs text-text-muted mt-0.5">
|
||||||
{item.patient.mobile}
|
{item.patient.mobile}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { MessageSquare } from 'lucide-react';
|
import { Check, FolderOpen, MessageSquare } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Badge } from '@/components/ui/shared/Badge';
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||||
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,8 @@ interface TaskRowProps {
|
|||||||
task: LabTaskListItem;
|
task: LabTaskListItem;
|
||||||
locale: string;
|
locale: string;
|
||||||
flatMode: boolean;
|
flatMode: boolean;
|
||||||
|
highlighted?: boolean;
|
||||||
|
exiting?: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
statusOptions: { value: LabTaskStatus; label: string }[];
|
statusOptions: { value: LabTaskStatus; label: string }[];
|
||||||
updatingTaskId: string | null;
|
updatingTaskId: string | null;
|
||||||
@@ -29,6 +32,7 @@ interface TaskRowProps {
|
|||||||
onStatusUpdate: (taskId: string, status: LabTaskStatus) => void;
|
onStatusUpdate: (taskId: string, status: LabTaskStatus) => void;
|
||||||
onToggleComments: (taskId: string) => void;
|
onToggleComments: (taskId: string) => void;
|
||||||
onCommentError: (message: string) => void;
|
onCommentError: (message: string) => void;
|
||||||
|
onShowInCase?: (task: LabTaskListItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||||
@@ -39,6 +43,8 @@ export function TaskRow({
|
|||||||
task,
|
task,
|
||||||
locale,
|
locale,
|
||||||
flatMode,
|
flatMode,
|
||||||
|
highlighted = false,
|
||||||
|
exiting = false,
|
||||||
canEdit,
|
canEdit,
|
||||||
statusOptions,
|
statusOptions,
|
||||||
updatingTaskId,
|
updatingTaskId,
|
||||||
@@ -47,6 +53,7 @@ export function TaskRow({
|
|||||||
onStatusUpdate,
|
onStatusUpdate,
|
||||||
onToggleComments,
|
onToggleComments,
|
||||||
onCommentError,
|
onCommentError,
|
||||||
|
onShowInCase,
|
||||||
}: TaskRowProps) {
|
}: TaskRowProps) {
|
||||||
const t = useTranslations('tasks');
|
const t = useTranslations('tasks');
|
||||||
const taskDate = new Intl.DateTimeFormat(locale, {
|
const taskDate = new Intl.DateTimeFormat(locale, {
|
||||||
@@ -55,8 +62,18 @@ export function TaskRow({
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
}).format(new Date(task.createdAt));
|
}).format(new Date(task.createdAt));
|
||||||
|
|
||||||
|
const rowClassName = [
|
||||||
|
flatMode ? undefined : 'border-b border-border/40 last:border-b-0',
|
||||||
|
exiting ? 'task-row-complete-exit' : undefined,
|
||||||
|
!exiting && highlighted
|
||||||
|
? 'relative bg-primary/10 ring-2 ring-inset ring-primary/50 shadow-[0_0_16px_rgba(99,102,241,0.2)]'
|
||||||
|
: undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={flatMode ? undefined : 'border-b border-border/40 last:border-b-0'}>
|
<li id={`task-row-${task.id}`} className={rowClassName}>
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col gap-3 px-3 py-3 sm:grid sm:grid-cols-[minmax(0,1fr)_132px_auto] sm:items-center sm:gap-x-3 sm:gap-y-0.5 sm:py-2 ${
|
className={`flex flex-col gap-3 px-3 py-3 sm:grid sm:grid-cols-[minmax(0,1fr)_132px_auto] sm:items-center sm:gap-x-3 sm:gap-y-0.5 sm:py-2 ${
|
||||||
flatMode ? '' : 'ps-5'
|
flatMode ? '' : 'ps-5'
|
||||||
@@ -67,7 +84,13 @@ export function TaskRow({
|
|||||||
<p className="text-sm font-medium text-text-primary">
|
<p className="text-sm font-medium text-text-primary">
|
||||||
{task.stepOrder}. {task.stepLabel}
|
{task.stepOrder}. {task.stepLabel}
|
||||||
</p>
|
</p>
|
||||||
{flatMode && task.isImportant ? (
|
{exiting ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-400">
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
{t('taskCompletedFlash')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{flatMode && task.isImportant && !exiting ? (
|
||||||
<Badge variant="warning" fixedWidth={false}>
|
<Badge variant="warning" fixedWidth={false}>
|
||||||
{t('importantBadge')}
|
{t('importantBadge')}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -94,7 +117,7 @@ export function TaskRow({
|
|||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<select
|
<select
|
||||||
value={task.status}
|
value={task.status}
|
||||||
disabled={updatingTaskId === task.id}
|
disabled={updatingTaskId === task.id || exiting}
|
||||||
onChange={(e) => onStatusUpdate(task.id, e.target.value as LabTaskStatus)}
|
onChange={(e) => onStatusUpdate(task.id, e.target.value as LabTaskStatus)}
|
||||||
className={`${FORM_SELECT_CLASS} w-full sm:max-w-[132px] font-medium`}
|
className={`${FORM_SELECT_CLASS} w-full sm:max-w-[132px] font-medium`}
|
||||||
style={labTaskStatusSelectStyle(task.status)}
|
style={labTaskStatusSelectStyle(task.status)}
|
||||||
@@ -112,8 +135,20 @@ export function TaskRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0 justify-between sm:justify-end">
|
<div className="flex items-center gap-1.5 shrink-0 justify-between sm:justify-end flex-wrap">
|
||||||
{canEdit ? (
|
{flatMode && onShowInCase && !exiting ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs px-2 py-1 h-auto"
|
||||||
|
onClick={() => onShowInCase(task)}
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5 me-1" />
|
||||||
|
{t('showInCase')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canEdit && !exiting ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggleComments(task.id)}
|
onClick={() => onToggleComments(task.id)}
|
||||||
@@ -144,7 +179,7 @@ export function TaskRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{commentsOpen && canEdit ? (
|
{commentsOpen && canEdit && !exiting ? (
|
||||||
<div className="px-3 pb-3 border-t border-border/50">
|
<div className="px-3 pb-3 border-t border-border/50">
|
||||||
<LabCaseCommentsPanel
|
<LabCaseCommentsPanel
|
||||||
caseId={task.labCaseId}
|
caseId={task.labCaseId}
|
||||||
|
|||||||
@@ -3,12 +3,20 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { TaskCaseGroupHeader } from '@/components/ui/lab/TaskCaseGroupHeader';
|
import { TaskCaseGroupHeader } from '@/components/ui/lab/TaskCaseGroupHeader';
|
||||||
import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGroupHeader';
|
import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGroupHeader';
|
||||||
import { TaskRow } from '@/components/ui/lab/TaskRow';
|
import { TaskRow } from '@/components/ui/lab/TaskRow';
|
||||||
import { groupTasksForDisplay } from '@/components/lab/taskListGrouping';
|
import { groupTasksForDisplay } from '@/components/lab/taskListGrouping';
|
||||||
|
import {
|
||||||
|
buildDefaultLocateParams,
|
||||||
|
DEFAULT_TASKS_VIEW,
|
||||||
|
isDefaultTasksView,
|
||||||
|
TASK_COMPLETE_EXIT_MS,
|
||||||
|
} from '@/components/lab/tasksViewDefaults';
|
||||||
|
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
@@ -31,7 +39,7 @@ export function TasksPage() {
|
|||||||
const t = useTranslations('tasks');
|
const t = useTranslations('tasks');
|
||||||
const tErrors = useTranslations('errors');
|
const tErrors = useTranslations('errors');
|
||||||
const { currentOrganization, user, isAuthReady } = useAuth();
|
const { currentOrganization, user, isAuthReady } = useAuth();
|
||||||
const { showError, setError, messages: toastMessages } = useToast();
|
const { showError, showSuccess, setError } = useToast();
|
||||||
|
|
||||||
const [tasks, setTasks] = useState<LabTaskListItem[]>([]);
|
const [tasks, setTasks] = useState<LabTaskListItem[]>([]);
|
||||||
const [pagination, setPagination] = useState<PaginatedLabTasks['pagination']>({
|
const [pagination, setPagination] = useState<PaginatedLabTasks['pagination']>({
|
||||||
@@ -45,17 +53,25 @@ export function TasksPage() {
|
|||||||
workflowSteps: [],
|
workflowSteps: [],
|
||||||
});
|
});
|
||||||
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(DEFAULT_TASKS_VIEW.page);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [locatingCase, setLocatingCase] = useState(false);
|
||||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||||
|
const [exitingTaskIds, setExitingTaskIds] = useState<Set<string>>(() => new Set());
|
||||||
const [expandedCommentsTaskId, setExpandedCommentsTaskId] = useState<string | null>(null);
|
const [expandedCommentsTaskId, setExpandedCommentsTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState(DEFAULT_TASKS_VIEW.search);
|
||||||
const [clinicId, setClinicId] = useState('');
|
const [clinicId, setClinicId] = useState(DEFAULT_TASKS_VIEW.clinicId);
|
||||||
const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS');
|
const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>(
|
||||||
const [stepCompleted, setStepCompleted] = useState('');
|
DEFAULT_TASKS_VIEW.statusFilter,
|
||||||
const [sortBy, setSortBy] = useState<TaskSortField>('date');
|
);
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
const [stepCompleted, setStepCompleted] = useState(DEFAULT_TASKS_VIEW.stepCompleted);
|
||||||
|
const [importantOnly, setImportantOnly] = useState(DEFAULT_TASKS_VIEW.importantOnly);
|
||||||
|
const [sortBy, setSortBy] = useState<TaskSortField>(DEFAULT_TASKS_VIEW.sortBy);
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>(DEFAULT_TASKS_VIEW.sortDir);
|
||||||
|
const [highlightTaskId, setHighlightTaskId] = useState<string | null>(
|
||||||
|
DEFAULT_TASKS_VIEW.highlightTaskId,
|
||||||
|
);
|
||||||
|
|
||||||
const canView = canViewTasks(currentOrganization);
|
const canView = canViewTasks(currentOrganization);
|
||||||
const canEdit = canEditTasks(currentOrganization);
|
const canEdit = canEditTasks(currentOrganization);
|
||||||
@@ -63,6 +79,7 @@ export function TasksPage() {
|
|||||||
|
|
||||||
const tRef = useRef(t);
|
const tRef = useRef(t);
|
||||||
tRef.current = t;
|
tRef.current = t;
|
||||||
|
const loadTasksRef = useRef<() => Promise<void>>(async () => {});
|
||||||
|
|
||||||
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
|
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -83,16 +100,41 @@ export function TasksPage() {
|
|||||||
if (clinicId) params.clinicOrganizationId = clinicId;
|
if (clinicId) params.clinicOrganizationId = clinicId;
|
||||||
if (statusFilter) params.status = statusFilter;
|
if (statusFilter) params.status = statusFilter;
|
||||||
if (stepCompleted) params.stepCompleted = stepCompleted;
|
if (stepCompleted) params.stepCompleted = stepCompleted;
|
||||||
|
if (importantOnly) params.pinImportant = true;
|
||||||
return params;
|
return params;
|
||||||
}, [page, search, clinicId, statusFilter, stepCompleted, sortBy, sortDir]);
|
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, sortBy, sortDir]);
|
||||||
|
|
||||||
const displayModel = useMemo(
|
const displayModel = useMemo(() => groupTasksForDisplay(tasks, sortBy), [tasks, sortBy]);
|
||||||
() => groupTasksForDisplay(tasks, sortBy),
|
|
||||||
[tasks, sortBy],
|
|
||||||
);
|
|
||||||
|
|
||||||
const groupingDisabled = sortBy !== 'date';
|
const groupingDisabled = sortBy !== 'date';
|
||||||
|
|
||||||
|
const viewState = useMemo(
|
||||||
|
() => ({
|
||||||
|
search,
|
||||||
|
clinicId,
|
||||||
|
statusFilter,
|
||||||
|
stepCompleted,
|
||||||
|
sortBy,
|
||||||
|
sortDir,
|
||||||
|
importantOnly,
|
||||||
|
page,
|
||||||
|
highlightTaskId,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
search,
|
||||||
|
clinicId,
|
||||||
|
statusFilter,
|
||||||
|
stepCompleted,
|
||||||
|
sortBy,
|
||||||
|
sortDir,
|
||||||
|
importantOnly,
|
||||||
|
page,
|
||||||
|
highlightTaskId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showReset = !isDefaultTasksView(viewState);
|
||||||
|
|
||||||
const loadTasks = useCallback(async () => {
|
const loadTasks = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -107,6 +149,8 @@ export function TasksPage() {
|
|||||||
}
|
}
|
||||||
}, [listParams, showError, setError, tErrors]);
|
}, [listParams, showError, setError, tErrors]);
|
||||||
|
|
||||||
|
loadTasksRef.current = loadTasks;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canView) return;
|
if (!canView) return;
|
||||||
const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0);
|
const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0);
|
||||||
@@ -129,6 +173,66 @@ export function TasksPage() {
|
|||||||
})();
|
})();
|
||||||
}, [canView]);
|
}, [canView]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightTaskId || loading || locatingCase) return;
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
const el = document.getElementById(`task-row-${highlightTaskId}`);
|
||||||
|
scrollWithinMainScrollContainer(el, { padding: 24 });
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [highlightTaskId, loading, locatingCase, tasks, displayModel]);
|
||||||
|
|
||||||
|
const clearFocus = useCallback(() => {
|
||||||
|
setHighlightTaskId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetView = useCallback(() => {
|
||||||
|
setSearch(DEFAULT_TASKS_VIEW.search);
|
||||||
|
setClinicId(DEFAULT_TASKS_VIEW.clinicId);
|
||||||
|
setStatusFilter(DEFAULT_TASKS_VIEW.statusFilter);
|
||||||
|
setStepCompleted(DEFAULT_TASKS_VIEW.stepCompleted);
|
||||||
|
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
|
||||||
|
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
||||||
|
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
||||||
|
setPage(DEFAULT_TASKS_VIEW.page);
|
||||||
|
setHighlightTaskId(DEFAULT_TASKS_VIEW.highlightTaskId);
|
||||||
|
setExpandedCommentsTaskId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowInCase = useCallback(
|
||||||
|
async (task: LabTaskListItem) => {
|
||||||
|
setLocatingCase(true);
|
||||||
|
setError('');
|
||||||
|
setSearch(DEFAULT_TASKS_VIEW.search);
|
||||||
|
setClinicId(DEFAULT_TASKS_VIEW.clinicId);
|
||||||
|
setStatusFilter(DEFAULT_TASKS_VIEW.statusFilter);
|
||||||
|
setStepCompleted(DEFAULT_TASKS_VIEW.stepCompleted);
|
||||||
|
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
|
||||||
|
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
||||||
|
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
||||||
|
setExpandedCommentsTaskId(null);
|
||||||
|
setHighlightTaskId(task.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await tasksApi.locatePage(buildDefaultLocateParams(task.id, PAGE_SIZE));
|
||||||
|
if (!response.data.found) {
|
||||||
|
showError(t('showInCaseNotFound'));
|
||||||
|
setHighlightTaskId(null);
|
||||||
|
setPage(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPage(response.data.page);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
showError(getUserFacingError(error, tErrors, t('showInCaseError')));
|
||||||
|
setHighlightTaskId(null);
|
||||||
|
setPage(1);
|
||||||
|
} finally {
|
||||||
|
setLocatingCase(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setError, showError, t, tErrors],
|
||||||
|
);
|
||||||
|
|
||||||
const handleStatusUpdate = useCallback(
|
const handleStatusUpdate = useCallback(
|
||||||
async (taskId: string, status: LabTaskStatus) => {
|
async (taskId: string, status: LabTaskStatus) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
@@ -136,14 +240,34 @@ export function TasksPage() {
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await tasksApi.updateStatus(taskId, status);
|
await tasksApi.updateStatus(taskId, status);
|
||||||
|
|
||||||
|
const willLeaveList = status === 'COMPLETED' && statusFilter === 'IN_PROGRESS';
|
||||||
|
|
||||||
|
if (willLeaveList) {
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((task) => (task.id === taskId ? { ...task, status: 'COMPLETED' } : task)),
|
||||||
|
);
|
||||||
|
setExitingTaskIds((prev) => new Set(prev).add(taskId));
|
||||||
|
showSuccess(t('taskCompletedToast'));
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setExitingTaskIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(taskId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
void loadTasksRef.current();
|
||||||
|
}, TASK_COMPLETE_EXIT_MS);
|
||||||
|
} else {
|
||||||
await loadTasks();
|
await loadTasks();
|
||||||
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
|
showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
|
||||||
} finally {
|
} finally {
|
||||||
setUpdatingTaskId(null);
|
setUpdatingTaskId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[canEdit, loadTasks, setError, showError, t, tErrors],
|
[canEdit, loadTasks, setError, showError, showSuccess, statusFilter, t, tErrors],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`;
|
const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`;
|
||||||
@@ -165,6 +289,37 @@ export function TasksPage() {
|
|||||||
}
|
}
|
||||||
}, [sortBy]);
|
}, [sortBy]);
|
||||||
|
|
||||||
|
const applyFilterChange = useCallback(
|
||||||
|
(apply: () => void) => {
|
||||||
|
apply();
|
||||||
|
setPage(1);
|
||||||
|
clearFocus();
|
||||||
|
},
|
||||||
|
[clearFocus],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTaskRow = (task: LabTaskListItem, flatMode: boolean) => (
|
||||||
|
<TaskRow
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
locale={locale}
|
||||||
|
flatMode={flatMode}
|
||||||
|
highlighted={highlightTaskId === task.id}
|
||||||
|
exiting={exitingTaskIds.has(task.id)}
|
||||||
|
canEdit={canEdit}
|
||||||
|
statusOptions={statusOptions}
|
||||||
|
updatingTaskId={updatingTaskId}
|
||||||
|
commentsOpen={expandedCommentsTaskId === task.id}
|
||||||
|
prosthesisCatalog={prosthesisCatalog}
|
||||||
|
onStatusUpdate={(id, status) => void handleStatusUpdate(id, status)}
|
||||||
|
onToggleComments={(id) =>
|
||||||
|
setExpandedCommentsTaskId((prev) => (prev === id ? null : id))
|
||||||
|
}
|
||||||
|
onCommentError={showError}
|
||||||
|
onShowInCase={flatMode ? (row) => void handleShowInCase(row) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (!isAuthReady) {
|
if (!isAuthReady) {
|
||||||
return <div className="text-sm text-text-muted">{t('loading')}</div>;
|
return <div className="text-sm text-text-muted">{t('loading')}</div>;
|
||||||
}
|
}
|
||||||
@@ -189,10 +344,7 @@ export function TasksPage() {
|
|||||||
<SearchBar
|
<SearchBar
|
||||||
embedded
|
embedded
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(v) => {
|
onChange={(v) => applyFilterChange(() => setSearch(v))}
|
||||||
setSearch(v);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
placeholder={t('searchPlaceholder')}
|
placeholder={t('searchPlaceholder')}
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
@@ -200,10 +352,7 @@ export function TasksPage() {
|
|||||||
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
|
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
|
||||||
<select
|
<select
|
||||||
value={clinicId}
|
value={clinicId}
|
||||||
onChange={(e) => {
|
onChange={(e) => applyFilterChange(() => setClinicId(e.target.value))}
|
||||||
setClinicId(e.target.value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
className={filterSelectClass}
|
className={filterSelectClass}
|
||||||
>
|
>
|
||||||
<option value="">{t('filterClinicAll')}</option>
|
<option value="">{t('filterClinicAll')}</option>
|
||||||
@@ -218,28 +367,21 @@ export function TasksPage() {
|
|||||||
<span className="text-xs text-text-muted">{t('filterStatus')}</span>
|
<span className="text-xs text-text-muted">{t('filterStatus')}</span>
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
setStatusFilter(e.target.value as '' | LabTaskStatus);
|
applyFilterChange(() => setStatusFilter(e.target.value as '' | LabTaskStatus))
|
||||||
setPage(1);
|
}
|
||||||
}}
|
|
||||||
className={filterSelectClass}
|
className={filterSelectClass}
|
||||||
>
|
>
|
||||||
|
<option value="IN_PROGRESS">{t('statusInProgress')}</option>
|
||||||
|
<option value="COMPLETED">{t('statusCompleted')}</option>
|
||||||
<option value="">{t('filterStatusAll')}</option>
|
<option value="">{t('filterStatusAll')}</option>
|
||||||
{statusOptions.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className="text-xs text-text-muted">{t('filterStepCompleted')}</span>
|
<span className="text-xs text-text-muted">{t('filterStepCompleted')}</span>
|
||||||
<select
|
<select
|
||||||
value={stepCompleted}
|
value={stepCompleted}
|
||||||
onChange={(e) => {
|
onChange={(e) => applyFilterChange(() => setStepCompleted(e.target.value))}
|
||||||
setStepCompleted(e.target.value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
className={filterSelectClass}
|
className={filterSelectClass}
|
||||||
>
|
>
|
||||||
<option value="">{t('filterStepCompletedAll')}</option>
|
<option value="">{t('filterStepCompletedAll')}</option>
|
||||||
@@ -255,7 +397,10 @@ export function TasksPage() {
|
|||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value as TaskSortField)}
|
onChange={(e) => {
|
||||||
|
setSortBy(e.target.value as TaskSortField);
|
||||||
|
clearFocus();
|
||||||
|
}}
|
||||||
className={`${filterSelectClass} min-w-0 flex-1`}
|
className={`${filterSelectClass} min-w-0 flex-1`}
|
||||||
>
|
>
|
||||||
<option value="date">{t('sortDate')}</option>
|
<option value="date">{t('sortDate')}</option>
|
||||||
@@ -276,14 +421,29 @@ export function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={importantOnly}
|
||||||
|
onChange={(checked) => applyFilterChange(() => setImportantOnly(checked))}
|
||||||
|
label={t('importantOnly')}
|
||||||
|
className="text-xs [&_span:last-child]:text-xs"
|
||||||
|
/>
|
||||||
|
{showReset ? (
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={resetView}>
|
||||||
|
{t('resetView')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
{groupingDisabled && sortHintKey ? (
|
{groupingDisabled && sortHintKey ? (
|
||||||
<p className="text-[11px] text-text-muted">{t(sortHintKey)}</p>
|
<p className="text-[11px] text-text-muted">{t(sortHintKey)}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="surface-card min-h-[280px]">
|
<section className="surface-card min-h-[280px]">
|
||||||
{loading && tasks.length === 0 ? (
|
{(loading || locatingCase) && tasks.length === 0 ? (
|
||||||
<p className="p-3 text-sm text-text-muted">{t('loading')}</p>
|
<p className="p-3 text-sm text-text-muted">
|
||||||
|
{locatingCase ? t('locatingCase') : t('loading')}
|
||||||
|
</p>
|
||||||
) : tasks.length === 0 ? (
|
) : tasks.length === 0 ? (
|
||||||
<p className="p-3 text-sm text-text-muted">{t('emptyList')}</p>
|
<p className="p-3 text-sm text-text-muted">{t('emptyList')}</p>
|
||||||
) : displayModel.mode === 'grouped' ? (
|
) : displayModel.mode === 'grouped' ? (
|
||||||
@@ -301,24 +461,7 @@ export function TasksPage() {
|
|||||||
prosthesisCatalog={prosthesisCatalog}
|
prosthesisCatalog={prosthesisCatalog}
|
||||||
/>
|
/>
|
||||||
<ul>
|
<ul>
|
||||||
{prosthesisGroup.tasks.map((task) => (
|
{prosthesisGroup.tasks.map((task) => renderTaskRow(task, false))}
|
||||||
<TaskRow
|
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
locale={locale}
|
|
||||||
flatMode={false}
|
|
||||||
canEdit={canEdit}
|
|
||||||
statusOptions={statusOptions}
|
|
||||||
updatingTaskId={updatingTaskId}
|
|
||||||
commentsOpen={expandedCommentsTaskId === task.id}
|
|
||||||
prosthesisCatalog={prosthesisCatalog}
|
|
||||||
onStatusUpdate={(id, status) => void handleStatusUpdate(id, status)}
|
|
||||||
onToggleComments={(id) =>
|
|
||||||
setExpandedCommentsTaskId((prev) => (prev === id ? null : id))
|
|
||||||
}
|
|
||||||
onCommentError={showError}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -327,29 +470,12 @@ export function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border">
|
<ul className="divide-y divide-border">
|
||||||
{displayModel.tasks.map((task) => (
|
{displayModel.tasks.map((task) => renderTaskRow(task, true))}
|
||||||
<TaskRow
|
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
locale={locale}
|
|
||||||
flatMode
|
|
||||||
canEdit={canEdit}
|
|
||||||
statusOptions={statusOptions}
|
|
||||||
updatingTaskId={updatingTaskId}
|
|
||||||
commentsOpen={expandedCommentsTaskId === task.id}
|
|
||||||
prosthesisCatalog={prosthesisCatalog}
|
|
||||||
onStatusUpdate={(id, status) => void handleStatusUpdate(id, status)}
|
|
||||||
onToggleComments={(id) =>
|
|
||||||
setExpandedCommentsTaskId((prev) => (prev === id ? null : id))
|
|
||||||
}
|
|
||||||
onCommentError={showError}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{pagination.totalPages > 1 && (
|
{pagination.totalPages > 1 ? (
|
||||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
{t('pageSummary', {
|
{t('pageSummary', {
|
||||||
@@ -363,7 +489,10 @@ export function TasksPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={page <= 1 || loading}
|
disabled={page <= 1 || loading}
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onClick={() => {
|
||||||
|
setPage((p) => Math.max(1, p - 1));
|
||||||
|
clearFocus();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
←
|
←
|
||||||
</Button>
|
</Button>
|
||||||
@@ -371,13 +500,16 @@ export function TasksPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={page >= pagination.totalPages || loading}
|
disabled={page >= pagination.totalPages || loading}
|
||||||
onClick={() => setPage((p) => p + 1)}
|
onClick={() => {
|
||||||
|
setPage((p) => p + 1);
|
||||||
|
clearFocus();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
→
|
→
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
LabTaskListItem,
|
LabTaskListItem,
|
||||||
LabTaskStatus,
|
LabTaskStatus,
|
||||||
ListLabTasksParams,
|
ListLabTasksParams,
|
||||||
|
LocateTaskPageParams,
|
||||||
|
LocateTaskPageResult,
|
||||||
PaginatedLabTasks,
|
PaginatedLabTasks,
|
||||||
TaskFilterOptions,
|
TaskFilterOptions,
|
||||||
} from '@/types/cases';
|
} from '@/types/cases';
|
||||||
@@ -21,6 +23,13 @@ export const tasksApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
locatePage: async (
|
||||||
|
params: LocateTaskPageParams,
|
||||||
|
): Promise<{ success: boolean; data: LocateTaskPageResult }> => {
|
||||||
|
const response = await apiClient.get('/tasks/locate-page', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
updateStatus: async (
|
updateStatus: async (
|
||||||
taskId: string,
|
taskId: string,
|
||||||
status: LabTaskStatus,
|
status: LabTaskStatus,
|
||||||
|
|||||||
@@ -310,3 +310,28 @@ select option {
|
|||||||
color: var(--color-icon);
|
color: var(--color-icon);
|
||||||
stroke: currentColor;
|
stroke: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes task-row-complete-exit {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
max-height: 9rem;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
background-color: color-mix(in srgb, var(--color-badge-success-bg) 75%, transparent);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(0.75rem);
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row-complete-exit {
|
||||||
|
animation: task-row-complete-exit 0.55s ease-in forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export type LabTaskStatus = 'IN_PROGRESS' | 'COMPLETED';
|
|||||||
export interface LabCaseListItem {
|
export interface LabCaseListItem {
|
||||||
id: string;
|
id: string;
|
||||||
sentAt: string | null;
|
sentAt: string | null;
|
||||||
|
isImportant: boolean;
|
||||||
clinic: { id: string; name: string };
|
clinic: { id: string; name: string };
|
||||||
patient: {
|
patient: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -148,15 +149,36 @@ export interface ListLabTasksParams {
|
|||||||
status?: LabTaskStatus;
|
status?: LabTaskStatus;
|
||||||
completed?: boolean;
|
completed?: boolean;
|
||||||
important?: boolean;
|
important?: boolean;
|
||||||
|
pinImportant?: boolean;
|
||||||
sentFrom?: string;
|
sentFrom?: string;
|
||||||
sentTo?: string;
|
sentTo?: string;
|
||||||
stepCompleted?: string;
|
stepCompleted?: string;
|
||||||
|
labCaseId?: string;
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
sortDir?: 'asc' | 'desc';
|
sortDir?: 'asc' | 'desc';
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocateTaskPageParams {
|
||||||
|
taskId: string;
|
||||||
|
q?: string;
|
||||||
|
clinicOrganizationId?: string;
|
||||||
|
status?: LabTaskStatus;
|
||||||
|
important?: boolean;
|
||||||
|
pinImportant?: boolean;
|
||||||
|
stepCompleted?: string;
|
||||||
|
sortBy?: TaskSortField;
|
||||||
|
sortDir?: 'asc' | 'desc';
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocateTaskPageResult {
|
||||||
|
page: number;
|
||||||
|
found: boolean;
|
||||||
|
labCaseId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LabTaskListItem {
|
export interface LabTaskListItem {
|
||||||
id: string;
|
id: string;
|
||||||
labCaseId: string;
|
labCaseId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user