From 0d073f1ec0c868b3fcabc3994fb930c9a9ee1737 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 13 Jul 2026 13:22:38 +0330 Subject: [PATCH] improvement: sorts and filters updated for tasks feature. --- .cursor/rules/lab-tasks.mdc | 3 +- .cursor/skills/lab-tasks/SKILL.md | 7 + backend/src/modules/cases/cases.service.ts | 2 + backend/src/modules/tasks/dto/tasks.dto.ts | 71 +++++ backend/src/modules/tasks/tasks.controller.ts | 9 +- backend/src/modules/tasks/tasks.service.ts | 186 ++++++++++- frontend/messages/en.json | 9 +- frontend/messages/fa.json | 9 +- frontend/messages/nl.json | 9 +- .../src/components/lab/tasksViewDefaults.ts | 51 +++ frontend/src/components/ui/lab/CasesPage.tsx | 17 +- frontend/src/components/ui/lab/TaskRow.tsx | 49 ++- frontend/src/components/ui/lab/TasksPage.tsx | 290 +++++++++++++----- frontend/src/lib/api/tasks.ts | 9 + frontend/src/styles/globals.css | 25 ++ frontend/src/types/cases.ts | 22 ++ 16 files changed, 666 insertions(+), 102 deletions(-) create mode 100644 frontend/src/components/lab/tasksViewDefaults.ts diff --git a/.cursor/rules/lab-tasks.mdc b/.cursor/rules/lab-tasks.mdc index 7049047..bb1e150 100644 --- a/.cursor/rules/lab-tasks.mdc +++ b/.cursor/rules/lab-tasks.mdc @@ -9,6 +9,7 @@ alwaysApply: false - **Default sort:** newest `labCase.sentAt` first; `stepOrder` asc within prosthesis group (backend `buildOrderBy`). - **Grouping:** only when `sortBy=date`; flat list + hint for other sorts. - **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` diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md index 92a7d4a..faff3ae 100644 --- a/.cursor/skills/lab-tasks/SKILL.md +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -38,15 +38,22 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. |-------|-----|-----| | `q`, `clinicOrganizationId`, `status` | `GET /tasks` | Search, clinic, status | | `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) | **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 - `GET /tasks` — paginated flat task list (grouping is client-side when `sortBy=date`) - `PATCH /tasks/:taskId` — update status - `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. diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index ea5fe64..62c97b6 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -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, diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts index f93dc12..86956db 100644 --- a/backend/src/modules/tasks/dto/tasks.dto.ts +++ b/backend/src/modules/tasks/dto/tasks.dto.ts @@ -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; +} diff --git a/backend/src/modules/tasks/tasks.controller.ts b/backend/src/modules/tasks/tasks.controller.ts index 7ea7788..a5bd091 100644 --- a/backend/src/modules/tasks/tasks.controller.ts +++ b/backend/src/modules/tasks/tasks.controller.ts @@ -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) { diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 7a5c8b8..46619eb 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -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 { + 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( diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 7b9abb9..2a4c382 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -482,7 +482,14 @@ "filterStepCompleted": "Step completed", "filterStepCompletedAll": "Any step", "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", "filterSentTo": "To", "sortBy": "Sort by", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 4710503..1b61932 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -483,7 +483,14 @@ "filterStepCompleted": "مرحله تکمیل‌شده", "filterStepCompletedAll": "هر مرحله‌ای", "showCompleted": "نمایش تکمیل‌شده‌ها", - "importantOnly": "فقط مهم‌ها", + "importantOnly": "مهم‌ها در ابتدا", + "resetView": "بازنشانی فیلترها و مرتب‌سازی", + "showInCase": "نمایش در پرونده", + "showInCaseNotFound": "این کار در فهرست پیش‌فرض در حال انجام نیست.", + "showInCaseError": "یافتن این کار در فهرست ممکن نشد.", + "locatingCase": "در حال یافتن پرونده در فهرست…", + "taskCompletedToast": "مرحله تکمیل شد — آفرین!", + "taskCompletedFlash": "انجام شد", "filterSentFrom": "از", "filterSentTo": "تا", "sortBy": "مرتب‌سازی بر اساس", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 4cb1339..797d35c 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -483,7 +483,14 @@ "filterStepCompleted": "Stap voltooid", "filterStepCompletedAll": "Elke stap", "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", "filterSentTo": "Tot", "sortBy": "Sorteren op", diff --git a/frontend/src/components/lab/tasksViewDefaults.ts b/frontend/src/components/lab/tasksViewDefaults.ts new file mode 100644 index 0000000..f3da4b0 --- /dev/null +++ b/frontend/src/components/lab/tasksViewDefaults.ts @@ -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, + }; +} diff --git a/frontend/src/components/ui/lab/CasesPage.tsx b/frontend/src/components/ui/lab/CasesPage.tsx index d0d9688..a4ec79f 100644 --- a/frontend/src/components/ui/lab/CasesPage.tsx +++ b/frontend/src/components/ui/lab/CasesPage.tsx @@ -17,6 +17,7 @@ import { casesApi } from '@/lib/api/cases'; import { tasksApi } from '@/lib/api/tasks'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; +import { Badge } from '@/components/ui/shared/Badge'; import { Button } from '@/components/ui/shared/Button'; import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; @@ -216,6 +217,11 @@ export function CasesPage() { try { const response = await casesApi.setCaseImportant(selectedCaseId, isImportant); setSelectedCase(response.data); + setCases((prev) => + prev.map((item) => + item.id === selectedCaseId ? { ...item, isImportant: response.data.isImportant } : item, + ), + ); } catch (error: unknown) { setSelectedCase(previousCase); toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); @@ -345,8 +351,15 @@ export function CasesPage() { : 'border-border hover:border-primary/40' }`} > -
- {formatPatientName(item.patient)} +
+
+ {formatPatientName(item.patient)} +
+ {item.isImportant ? ( + + {t('importantLabel')} + + ) : null}
{item.patient.mobile} diff --git a/frontend/src/components/ui/lab/TaskRow.tsx b/frontend/src/components/ui/lab/TaskRow.tsx index 8fc63e5..a4a3233 100644 --- a/frontend/src/components/ui/lab/TaskRow.tsx +++ b/frontend/src/components/ui/lab/TaskRow.tsx @@ -1,8 +1,9 @@ 'use client'; -import { MessageSquare } from 'lucide-react'; +import { Check, FolderOpen, MessageSquare } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { Badge } from '@/components/ui/shared/Badge'; +import { Button } from '@/components/ui/shared/Button'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; import { @@ -21,6 +22,8 @@ interface TaskRowProps { task: LabTaskListItem; locale: string; flatMode: boolean; + highlighted?: boolean; + exiting?: boolean; canEdit: boolean; statusOptions: { value: LabTaskStatus; label: string }[]; updatingTaskId: string | null; @@ -29,6 +32,7 @@ interface TaskRowProps { onStatusUpdate: (taskId: string, status: LabTaskStatus) => void; onToggleComments: (taskId: string) => void; onCommentError: (message: string) => void; + onShowInCase?: (task: LabTaskListItem) => void; } function formatPatientName(patient: { firstName: string; lastName: string }) { @@ -39,6 +43,8 @@ export function TaskRow({ task, locale, flatMode, + highlighted = false, + exiting = false, canEdit, statusOptions, updatingTaskId, @@ -47,6 +53,7 @@ export function TaskRow({ onStatusUpdate, onToggleComments, onCommentError, + onShowInCase, }: TaskRowProps) { const t = useTranslations('tasks'); const taskDate = new Intl.DateTimeFormat(locale, { @@ -55,8 +62,18 @@ export function TaskRow({ day: 'numeric', }).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 ( -
  • +
  • {task.stepOrder}. {task.stepLabel}

    - {flatMode && task.isImportant ? ( + {exiting ? ( + + + {t('taskCompletedFlash')} + + ) : null} + {flatMode && task.isImportant && !exiting ? ( {t('importantBadge')} @@ -94,7 +117,7 @@ export function TaskRow({ {canEdit ? ( { - setClinicId(e.target.value); - setPage(1); - }} + onChange={(e) => applyFilterChange(() => setClinicId(e.target.value))} className={filterSelectClass} > @@ -218,28 +367,21 @@ export function TasksPage() { {t('filterStatus')}
  • +
    + applyFilterChange(() => setImportantOnly(checked))} + label={t('importantOnly')} + className="text-xs [&_span:last-child]:text-xs" + /> + {showReset ? ( + + ) : null} +
    {groupingDisabled && sortHintKey ? (

    {t(sortHintKey)}

    ) : null}
    - {loading && tasks.length === 0 ? ( -

    {t('loading')}

    + {(loading || locatingCase) && tasks.length === 0 ? ( +

    + {locatingCase ? t('locatingCase') : t('loading')} +

    ) : tasks.length === 0 ? (

    {t('emptyList')}

    ) : displayModel.mode === 'grouped' ? ( @@ -301,24 +461,7 @@ export function TasksPage() { prosthesisCatalog={prosthesisCatalog} />
      - {prosthesisGroup.tasks.map((task) => ( - void handleStatusUpdate(id, status)} - onToggleComments={(id) => - setExpandedCommentsTaskId((prev) => (prev === id ? null : id)) - } - onCommentError={showError} - /> - ))} + {prosthesisGroup.tasks.map((task) => renderTaskRow(task, false))}
    ))} @@ -327,29 +470,12 @@ export function TasksPage() { ) : ( )} - {pagination.totalPages > 1 && ( + {pagination.totalPages > 1 ? (

    {t('pageSummary', { @@ -363,7 +489,10 @@ export function TasksPage() { type="button" variant="secondary" disabled={page <= 1 || loading} - onClick={() => setPage((p) => Math.max(1, p - 1))} + onClick={() => { + setPage((p) => Math.max(1, p - 1)); + clearFocus(); + }} > ← @@ -371,13 +500,16 @@ export function TasksPage() { type="button" variant="secondary" disabled={page >= pagination.totalPages || loading} - onClick={() => setPage((p) => p + 1)} + onClick={() => { + setPage((p) => p + 1); + clearFocus(); + }} > →

    - )} + ) : null} ); } diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts index de12817..02647cc 100644 --- a/frontend/src/lib/api/tasks.ts +++ b/frontend/src/lib/api/tasks.ts @@ -4,6 +4,8 @@ import type { LabTaskListItem, LabTaskStatus, ListLabTasksParams, + LocateTaskPageParams, + LocateTaskPageResult, PaginatedLabTasks, TaskFilterOptions, } from '@/types/cases'; @@ -21,6 +23,13 @@ export const tasksApi = { 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 ( taskId: string, status: LabTaskStatus, diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 20e9786..048a7db 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -309,4 +309,29 @@ select option { .lucide { color: var(--color-icon); 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; } \ No newline at end of file diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts index 0672e46..6a520a5 100644 --- a/frontend/src/types/cases.ts +++ b/frontend/src/types/cases.ts @@ -3,6 +3,7 @@ export type LabTaskStatus = 'IN_PROGRESS' | 'COMPLETED'; export interface LabCaseListItem { id: string; sentAt: string | null; + isImportant: boolean; clinic: { id: string; name: string }; patient: { id: string; @@ -148,15 +149,36 @@ export interface ListLabTasksParams { status?: LabTaskStatus; completed?: boolean; important?: boolean; + pinImportant?: boolean; sentFrom?: string; sentTo?: string; stepCompleted?: string; + labCaseId?: string; sortBy?: TaskSortField; sortDir?: 'asc' | 'desc'; page?: 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 { id: string; labCaseId: string;