From a391eee15f29490e10624a01e868a053ac301f29 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 13 Jul 2026 15:47:32 +0330 Subject: [PATCH] improvement: task assignment flow added. cases and tasks feature updated accordingly. --- .cursor/rules/lab-tasks.mdc | 3 +- .cursor/skills/lab-tasks/SKILL.md | 16 ++- AGENTS.md | 2 +- .../migration.sql | 9 ++ backend/prisma/schema.prisma | 5 + backend/src/modules/cases/cases.controller.ts | 28 +++++- backend/src/modules/cases/cases.service.ts | 98 ++++++++++++++++++- backend/src/modules/cases/dto/cases.dto.ts | 6 ++ backend/src/modules/tasks/dto/tasks.dto.ts | 12 +++ backend/src/modules/tasks/tasks.service.ts | 32 ++++-- frontend/messages/en.json | 6 ++ frontend/messages/fa.json | 6 ++ frontend/messages/nl.json | 6 ++ .../components/lab/labTaskStatusDisplay.ts | 12 ++- .../src/components/lab/tasksViewDefaults.ts | 3 + .../src/components/ui/lab/CaseDetailPanel.tsx | 51 +++++++--- frontend/src/components/ui/lab/CasesPage.tsx | 29 +++++- frontend/src/components/ui/lab/TaskRow.tsx | 26 ++++- frontend/src/components/ui/lab/TasksPage.tsx | 14 ++- frontend/src/lib/api/cases.ts | 17 ++++ frontend/src/types/cases.ts | 11 +++ 21 files changed, 357 insertions(+), 35 deletions(-) create mode 100644 backend/prisma/migrations/20260713120000_lab_task_assignment/migration.sql diff --git a/.cursor/rules/lab-tasks.mdc b/.cursor/rules/lab-tasks.mdc index bb1e150..feb420b 100644 --- a/.cursor/rules/lab-tasks.mdc +++ b/.cursor/rules/lab-tasks.mdc @@ -9,7 +9,8 @@ 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. -- **Important only:** server-side `important=true` (not client per-page). +- **Important first:** `pinImportant=true` (sort pin, not filter). +- **Task assignment:** assign in Cases (`TAB_CASES_EDIT`); status edit on Tasks only for assignee or unassigned tasks; others see “Assigned to {name}”. - **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 faff3ae..9cbd83f 100644 --- a/.cursor/skills/lab-tasks/SKILL.md +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -38,12 +38,18 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. |-------|-----|-----| | `q`, `clinicOrganizationId`, `status` | `GET /tasks` | Search, clinic, status | | `stepCompleted` | `GET /tasks` | Workflow step dropdown | -| `important` | `GET /tasks` | Important cases only | +| `pinImportant` | `GET /tasks` | Important first (sort pin) | +| `assignedToMe` | `GET /tasks` | Only tasks assigned to current user | | Clinics + steps options | `GET /tasks/filter-options` | Populates dropdowns (not from current page) | +**Task assignment:** Managed in **Cases** (`TAB_CASES_EDIT`), not on Tasks tab. `PATCH /cases/:caseId/tasks/:taskId/assign`; assignable staff via `GET /cases/assignable-staff` (members with `TAB_TASKS_EDIT`, including participating owner). Case detail task row: step label, status badge, assign dropdown, and last-updated line on one compact row. + +**Tasks visibility & status edit:** All tasks remain visible to every user with task access (no hiding assigned tasks). **Unassigned** tasks or tasks **assigned to you** → status dropdown when `TAB_TASKS_EDIT`. **Assigned to someone else** → read-only “Assigned to {name}” badge instead of the dropdown (backend rejects status PATCH). Managers assign/monitor in Cases. + **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). +- **Important first:** `pinImportant=true` prepends important cases in sort order. +- **Assigned to me:** `assignedToMe=true` filters to current user's assigned tasks only. - **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. @@ -51,11 +57,13 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. ## APIs - `GET /tasks` — paginated flat task list (grouping is client-side when `sortBy=date`) -- `PATCH /tasks/:taskId` — update status +- `PATCH /tasks/:taskId` — update status (only assignee or unassigned task) - `GET /tasks/filter-options` — clinics + workflow steps (localized) - `GET /tasks/locate-page` — page number for a task in the sorted filtered list +- `GET /cases/assignable-staff` — staff eligible for task assignment +- `PATCH /cases/:caseId/tasks/:taskId/assign` — assign or unassign (`assigneeUserId` nullable) -List items include `caseSentAt` for case headers. +List items include `caseSentAt`, `assignee`, `assignedAt` for case headers / flat rows. ## Permissions diff --git a/AGENTS.md b/AGENTS.md index f21ba25..b800cb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ frontend/src/ - **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. - **Lab case comments** on a detail when sent and lab case tasks are not all `COMPLETED` (`taskProgress` from API). -**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from `PROSTHESIS_TYPE_COLORS` via catalog — see `.cursor/skills/lab-tasks/SKILL.md`. +**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from `PROSTHESIS_TYPE_COLORS` via catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown — see `.cursor/skills/lab-tasks/SKILL.md`. ## Backend layout diff --git a/backend/prisma/migrations/20260713120000_lab_task_assignment/migration.sql b/backend/prisma/migrations/20260713120000_lab_task_assignment/migration.sql new file mode 100644 index 0000000..95fd0cf --- /dev/null +++ b/backend/prisma/migrations/20260713120000_lab_task_assignment/migration.sql @@ -0,0 +1,9 @@ +-- Reintroduce per-task assignment for lab workflow + +ALTER TABLE "lab_case_tasks" ADD COLUMN "assigneeUserId" TEXT; +ALTER TABLE "lab_case_tasks" ADD COLUMN "assignedAt" TIMESTAMP(3); + +ALTER TABLE "lab_case_tasks" ADD CONSTRAINT "lab_case_tasks_assigneeUserId_fkey" + FOREIGN KEY ("assigneeUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "lab_case_tasks_assigneeUserId_idx" ON "lab_case_tasks"("assigneeUserId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 06bf90a..e503da2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -25,6 +25,7 @@ model User { sentStaffInvites StaffInvitation[] sentOrganizationInvitations OrganizationInvitation[] statusChangedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskLastStatusChangedBy") + assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee") labCaseTaskStatusEvents LabCaseTaskStatusEvent[] labCaseComments LabCaseComment[] phoneVerificationCodes PhoneVerificationCode[] @@ -353,11 +354,14 @@ model LabCaseTask { stepOrder Int stepLabel String status LabTaskStatus @default(IN_PROGRESS) + assigneeUserId String? + assignedAt DateTime? lastStatusChangedByUserId String? lastStatusChangedAt DateTime? labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) + assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull) lastStatusChangedBy User? @relation("LabCaseTaskLastStatusChangedBy", fields: [lastStatusChangedByUserId], references: [id], onDelete: SetNull) statusEvents LabCaseTaskStatusEvent[] @@ -366,6 +370,7 @@ model LabCaseTask { @@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder]) @@index([labCaseId, status]) + @@index([assigneeUserId]) @@map("lab_case_tasks") } diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts index d54a7b1..451dbf5 100644 --- a/backend/src/modules/cases/cases.controller.ts +++ b/backend/src/modules/cases/cases.controller.ts @@ -14,7 +14,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { LabOrgGuard } from '../../common/guards/lab-org.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CasesService } from './cases.service'; -import { ListLabCasesDto, UpdateLabCaseImportantDto } from './dto/cases.dto'; +import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto'; @ApiTags('cases') @ApiBearerAuth('JWT-auth') @@ -30,6 +30,13 @@ export class CasesController { return this.casesService.list(organizationId, req.user.id, query); } + @Get('assignable-staff') + @ApiOperation({ summary: 'Staff who can be assigned lab tasks' }) + listAssignableStaff(@Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.listAssignableStaff(organizationId, req.user.id); + } + @Get('filter-options') @ApiOperation({ summary: 'Clinics and treatment types for inbox filters' }) listFilterOptions(@Req() req) { @@ -74,4 +81,23 @@ export class CasesController { const organizationId = this.casesService.getOrganizationIdFromUser(req.user); return this.casesService.setCaseImportant(id, dto, organizationId, req.user.id, req.user.language); } + + @Patch(':caseId/tasks/:taskId/assign') + @ApiOperation({ summary: 'Assign or unassign a task to lab staff' }) + assignTask( + @Param('caseId') caseId: string, + @Param('taskId') taskId: string, + @Body() dto: AssignLabCaseTaskDto, + @Req() req, + ) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.assignTask( + caseId, + taskId, + dto, + organizationId, + req.user.id, + req.user.language, + ); + } } diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 62c97b6..072c97a 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -14,8 +14,9 @@ import { } from '../catalog/catalog-label.service'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { normalizeTeeth } from '../treatments/treatment.utils'; -import { ListLabCasesDto, UpdateLabCaseImportantDto } from './dto/cases.dto'; +import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto'; import { normalizeTaskTeeth } from './lab-case-task.util'; +import { hasEffectivePermission } from '../../common/membership-permissions'; const labCaseListInclude = { treatment: { @@ -49,6 +50,7 @@ const labCaseListInclude = { ], include: { lastStatusChangedBy: { select: { id: true, name: true } }, + assignee: { select: { id: true, name: true } }, statusEvents: { orderBy: { changedAt: 'asc' as const }, include: { changedBy: { select: { id: true, name: true } } }, @@ -74,6 +76,7 @@ const labCaseListInclude = { type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{ include: { lastStatusChangedBy: { select: { id: true; name: true } }; + assignee: { select: { id: true; name: true } }; statusEvents: { include: { changedBy: { select: { id: true; name: true } } }; }; @@ -370,6 +373,95 @@ export class CasesService { return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) }; } + async listAssignableStaff(labOrganizationId: string, actorUserId: string) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + + const memberships = await this.prisma.membership.findMany({ + where: { + organizationId: labOrganizationId, + OR: [{ isOwner: true }, { isActive: true }], + }, + include: { + permissions: { include: { permission: true } }, + organization: { include: { type: true, plan: true } }, + user: { select: { id: true, name: true } }, + }, + }); + + const staff = memberships + .filter((m) => hasEffectivePermission(m, 'TAB_TASKS_EDIT')) + .map((m) => ({ + id: m.user.id, + name: m.user.name, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { success: true, data: staff }; + } + + async assignTask( + labCaseId: string, + taskId: string, + dto: AssignLabCaseTaskDto, + labOrganizationId: string, + actorUserId: string, + localeInput?: string | null, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + + const task = await this.prisma.labCaseTask.findFirst({ + where: { + id: taskId, + labCaseId, + labCase: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + }, + select: { id: true }, + }); + + if (!task) { + throw new NotFoundException('Task not found'); + } + + const assigneeUserId = dto.assigneeUserId ?? null; + + if (assigneeUserId) { + const memberships = await this.prisma.membership.findMany({ + where: { + organizationId: labOrganizationId, + userId: assigneeUserId, + OR: [{ isOwner: true }, { isActive: true }], + }, + include: { + permissions: { include: { permission: true } }, + organization: { include: { type: true, plan: true } }, + }, + }); + + const canReceive = memberships.some((m) => hasEffectivePermission(m, 'TAB_TASKS_EDIT')); + if (!canReceive) { + throw new BadRequestException('Selected user cannot be assigned tasks'); + } + } + + await this.prisma.labCaseTask.update({ + where: { id: taskId }, + data: { + assigneeUserId, + assignedAt: assigneeUserId ? new Date() : null, + }, + }); + + const labCase = await this.prisma.labCase.findFirstOrThrow({ + where: { id: labCaseId }, + include: labCaseListInclude, + }); + + return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) }; + } + private buildListWhere( labOrganizationId: string, query: ListLabCasesDto, @@ -579,6 +671,10 @@ export class CasesService { stepOrder: task.stepOrder, stepLabel: task.stepLabel, status: task.status, + assignee: task.assignee + ? { id: task.assignee.id, name: task.assignee.name } + : null, + assignedAt: task.assignedAt?.toISOString() ?? null, createdAt: task.createdAt.toISOString(), lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null, lastStatusChangedBy: task.lastStatusChangedBy diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts index afa969b..be290bb 100644 --- a/backend/src/modules/cases/dto/cases.dto.ts +++ b/backend/src/modules/cases/dto/cases.dto.ts @@ -6,6 +6,12 @@ export class UpdateLabCaseImportantDto { isImportant: boolean; } +export class AssignLabCaseTaskDto { + @IsOptional() + @IsUUID() + assigneeUserId?: string | null; +} + export class ListLabCasesDto { @IsOptional() @IsString() diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts index 86956db..dcffd93 100644 --- a/backend/src/modules/tasks/dto/tasks.dto.ts +++ b/backend/src/modules/tasks/dto/tasks.dto.ts @@ -81,6 +81,12 @@ export class ListLabTasksDto { @IsUUID() labCaseId?: string; + /** When true, only tasks assigned to the current user. */ + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + assignedToMe?: boolean; + @IsOptional() @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType']) sortBy?: TaskSortField; @@ -147,6 +153,12 @@ export class LocateTaskPageDto { @IsString() stepCompleted?: string; + /** When true, only tasks assigned to the current user. */ + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + assignedToMe?: boolean; + @IsOptional() @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType']) sortBy?: TaskSortField; diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 46619eb..0460720 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -17,6 +17,7 @@ import { hasEffectivePermission } from '../../common/membership-permissions'; const taskListInclude = { lastStatusChangedBy: { select: { id: true, name: true } }, + assignee: { select: { id: true, name: true } }, labCase: { include: { treatment: { @@ -55,7 +56,7 @@ export class TasksService { const limit = Math.min(Math.max(query.limit ?? 50, 1), 100); const skip = (page - 1) * limit; - const where = await this.buildListWhere(labOrganizationId, query); + const where = await this.buildListWhere(labOrganizationId, actorUserId, query); const [items, total] = await Promise.all([ this.prisma.labCaseTask.findMany({ @@ -117,7 +118,7 @@ export class TasksService { throw new NotFoundException('Task not found'); } - const where = await this.buildListWhere(labOrganizationId, listQuery); + const where = await this.buildListWhere(labOrganizationId, actorUserId, listQuery); const inFilteredSet = await this.prisma.labCaseTask.count({ where: { AND: [where, { id: query.taskId }] }, }); @@ -176,6 +177,10 @@ export class TasksService { throw new NotFoundException('Task not found'); } + if (task.assigneeUserId && task.assigneeUserId !== actorUserId) { + throw new ForbiddenException('This task is assigned to another staff member'); + } + const updated = await this.prisma.$transaction(async (tx) => { const result = await tx.labCaseTask.update({ where: { id: taskId }, @@ -263,6 +268,7 @@ export class TasksService { private async buildListWhere( labOrganizationId: string, + actorUserId: string, query: ListLabTasksDto, ): Promise { const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null }; @@ -306,6 +312,7 @@ export class TasksService { ...(query.labCaseId ? { labCaseId: query.labCaseId } : {}), labCase: labCaseScope, ...(status !== undefined ? { status } : {}), + ...(query.assignedToMe ? { assigneeUserId: actorUserId } : {}), }; const stepCompleted = query.stepCompleted?.trim(); @@ -327,12 +334,16 @@ export class TasksService { } return { - ...base, - OR: completedGroups.map((group) => ({ - labCaseId: group.labCaseId, - treatmentDetailId: group.treatmentDetailId, - prosthesisTypeCode: group.prosthesisTypeCode, - })), + AND: [ + base, + { + OR: completedGroups.map((group) => ({ + labCaseId: group.labCaseId, + treatmentDetailId: group.treatmentDetailId, + prosthesisTypeCode: group.prosthesisTypeCode, + })), + }, + ], }; } @@ -348,6 +359,7 @@ export class TasksService { sentFrom: query.sentFrom, sentTo: query.sentTo, stepCompleted: query.stepCompleted, + assignedToMe: query.assignedToMe, sortBy: query.sortBy, sortDir: query.sortDir, limit: query.limit, @@ -527,6 +539,10 @@ export class TasksService { stepLabel: task.stepLabel, status: task.status, isImportant: task.labCase.isImportant, + assignee: task.assignee + ? { id: task.assignee.id, name: task.assignee.name } + : null, + assignedAt: task.assignedAt?.toISOString() ?? null, lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null, lastStatusChangedBy: task.lastStatusChangedBy ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2a4c382..2012dd1 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -435,6 +435,10 @@ "errorLoadList": "Failed to load cases.", "errorLoadDetail": "Failed to load case details.", "errorUpdateTask": "Failed to update task.", + "errorAssignTask": "Failed to assign task.", + "assigneeLabel": "Assigned to", + "assigneeUnassigned": "Unassigned", + "assignedTo": "Assigned to {name}", "filterClinic": "Clinic", "filterClinicAll": "All clinics", "filterTreatmentType": "Treatment type", @@ -483,6 +487,8 @@ "filterStepCompletedAll": "Any step", "showCompleted": "Show completed", "importantOnly": "Important first", + "assignedToMe": "Assigned to me", + "assignedToStaff": "Assigned to {name}", "resetView": "Reset filters & sort", "showInCase": "Show in case", "showInCaseNotFound": "This task is not in the default in-progress list.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 1b61932..d41236e 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -435,6 +435,10 @@ "errorLoadList": "بارگذاری پرونده‌ها ناموفق بود.", "errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.", "errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود.", + "errorAssignTask": "واگذاری وظیفه ناموفق بود.", + "assigneeLabel": "واگذار شده به", + "assigneeUnassigned": "واگذار نشده", + "assignedTo": "واگذار شده به {name}", "filterClinic": "کلینیک", "filterClinicAll": "همه کلینیک‌ها", "filterTreatmentType": "نوع درمان", @@ -484,6 +488,8 @@ "filterStepCompletedAll": "هر مرحله‌ای", "showCompleted": "نمایش تکمیل‌شده‌ها", "importantOnly": "مهم‌ها در ابتدا", + "assignedToMe": "واگذار شده به من", + "assignedToStaff": "واگذار شده به {name}", "resetView": "بازنشانی فیلترها و مرتب‌سازی", "showInCase": "نمایش در پرونده", "showInCaseNotFound": "این کار در فهرست پیش‌فرض در حال انجام نیست.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 797d35c..7b26ce4 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -435,6 +435,10 @@ "errorLoadList": "Dossiers laden mislukt.", "errorLoadDetail": "Dossierdetails laden mislukt.", "errorUpdateTask": "Taak bijwerken mislukt.", + "errorAssignTask": "Taak toewijzen mislukt.", + "assigneeLabel": "Toegewezen aan", + "assigneeUnassigned": "Niet toegewezen", + "assignedTo": "Toegewezen aan {name}", "filterClinic": "Kliniek", "filterClinicAll": "Alle klinieken", "filterTreatmentType": "Behandeltype", @@ -484,6 +488,8 @@ "filterStepCompletedAll": "Elke stap", "showCompleted": "Voltooide tonen", "importantOnly": "Belangrijke cases eerst", + "assignedToMe": "Toegewezen aan mij", + "assignedToStaff": "Toegewezen aan {name}", "resetView": "Filters en sortering resetten", "showInCase": "In case tonen", "showInCaseNotFound": "Deze taak staat niet in de standaardlijst met taken in uitvoering.", diff --git a/frontend/src/components/lab/labTaskStatusDisplay.ts b/frontend/src/components/lab/labTaskStatusDisplay.ts index f96f8b9..b358606 100644 --- a/frontend/src/components/lab/labTaskStatusDisplay.ts +++ b/frontend/src/components/lab/labTaskStatusDisplay.ts @@ -1,6 +1,16 @@ import type { CSSProperties } from 'react'; import type { BadgeVariant } from '@/components/ui/shared/Badge'; -import type { LabTaskStatus } from '@/types/cases'; +import type { LabTaskListItem, LabTaskStatus } from '@/types/cases'; + +export function canEditLabTaskStatus( + task: Pick, + currentUserId: string | undefined, + canEditTasks: boolean, +): boolean { + if (!canEditTasks || !currentUserId) return false; + if (!task.assignee) return true; + return task.assignee.id === currentUserId; +} export function labTaskStatusVariant(status: LabTaskStatus): BadgeVariant { return status === 'COMPLETED' ? 'success' : 'warning'; diff --git a/frontend/src/components/lab/tasksViewDefaults.ts b/frontend/src/components/lab/tasksViewDefaults.ts index f3da4b0..4d2d285 100644 --- a/frontend/src/components/lab/tasksViewDefaults.ts +++ b/frontend/src/components/lab/tasksViewDefaults.ts @@ -8,6 +8,7 @@ export type TasksViewState = { sortBy: TaskSortField; sortDir: 'asc' | 'desc'; importantOnly: boolean; + assignedToMe: boolean; page: number; highlightTaskId: string | null; }; @@ -20,6 +21,7 @@ export const DEFAULT_TASKS_VIEW: TasksViewState = { sortBy: 'date', sortDir: 'desc', importantOnly: false, + assignedToMe: false, page: 1, highlightTaskId: null, }; @@ -35,6 +37,7 @@ export function isDefaultTasksView(state: TasksViewState): boolean { state.sortBy === DEFAULT_TASKS_VIEW.sortBy && state.sortDir === DEFAULT_TASKS_VIEW.sortDir && state.importantOnly === DEFAULT_TASKS_VIEW.importantOnly && + state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe && state.page === DEFAULT_TASKS_VIEW.page && state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId ); diff --git a/frontend/src/components/ui/lab/CaseDetailPanel.tsx b/frontend/src/components/ui/lab/CaseDetailPanel.tsx index 5229a8d..1159de4 100644 --- a/frontend/src/components/ui/lab/CaseDetailPanel.tsx +++ b/frontend/src/components/ui/lab/CaseDetailPanel.tsx @@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/shared/Badge'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel'; +import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview'; import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog'; import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay'; @@ -21,7 +22,7 @@ import { formatPatientName, latestCaseAttachment, } from '@/components/lab/caseDetailUtils'; -import type { LabCaseDetail, LabTaskStatus } from '@/types/cases'; +import type { AssignableTaskStaff, LabCaseDetail, LabTaskStatus } from '@/types/cases'; import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; function CaseTaskProgressBar({ completed, total }: { completed: number; total: number }) { @@ -60,6 +61,10 @@ export interface CaseDetailPanelProps { updatingImportant?: boolean; onImportantChange?: (checked: boolean) => void; commentsSection?: ReactNode; + assignableStaff?: AssignableTaskStaff[]; + canAssignTasks?: boolean; + assigningTaskId?: string | null; + onAssignTask?: (taskId: string, assigneeUserId: string | null) => void; } export function CaseDetailPanel({ @@ -76,6 +81,10 @@ export function CaseDetailPanel({ updatingImportant = false, onImportantChange, commentsSection, + assignableStaff = [], + canAssignTasks = false, + assigningTaskId = null, + onAssignTask, }: CaseDetailPanelProps) { const t = useTranslations('cases'); const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false); @@ -213,24 +222,42 @@ export function CaseDetailPanel({
    {group.tasks.map((task) => ( -
  • -
    - +
  • +
    + {task.stepOrder}. {task.stepLabel} + + {task.lastStatusChangedBy + ? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name }) + : t('lastUpdatedUnknown')} + {task.lastStatusChangedAt + ? ` · ${formatCaseDateTime(task.lastStatusChangedAt, locale)}` + : ''} + + {canAssignTasks && onAssignTask ? ( + + ) : null} {statusOptions.find((opt) => opt.value === task.status)?.label ?? task.status}
    -

    - {task.lastStatusChangedBy - ? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name }) - : t('lastUpdatedUnknown')} - {task.lastStatusChangedAt - ? ` · ${formatCaseDateTime(task.lastStatusChangedAt, locale)}` - : ''} -

  • ))}
diff --git a/frontend/src/components/ui/lab/CasesPage.tsx b/frontend/src/components/ui/lab/CasesPage.tsx index a4ec79f..32ef830 100644 --- a/frontend/src/components/ui/lab/CasesPage.tsx +++ b/frontend/src/components/ui/lab/CasesPage.tsx @@ -24,6 +24,7 @@ import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { + AssignableTaskStaff, CasesFilterOptions, LabCaseDetail, LabCaseListItem, @@ -67,6 +68,8 @@ export function CasesPage() { const [loadingList, setLoadingList] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false); const [updatingImportant, setUpdatingImportant] = useState(false); + const [assignableStaff, setAssignableStaff] = useState([]); + const [assigningTaskId, setAssigningTaskId] = useState(null); const [commentCount, setCommentCount] = useState(0); const canEdit = canEditCases(currentOrganization); @@ -142,8 +145,11 @@ export function CasesPage() { useEffect(() => { void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {}); void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); + if (canEdit) { + void casesApi.listAssignableStaff().then((r) => setAssignableStaff(r.data)).catch(() => {}); + } // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch - }, []); + }, [canEdit]); useEffect(() => { const caseIdFromUrl = searchParams.get('caseId'); @@ -206,6 +212,21 @@ export function CasesPage() { setPage(1); } + async function handleAssignTask(taskId: string, assigneeUserId: string | null) { + if (!selectedCaseId || !canEdit) return; + + setAssigningTaskId(taskId); + toast.setError(''); + try { + const response = await casesApi.assignTask(selectedCaseId, taskId, assigneeUserId); + setSelectedCase(response.data); + } catch (error: unknown) { + toast.showError(getUserFacingError(error, tErrors, t('errorAssignTask'))); + } finally { + setAssigningTaskId(null); + } + } + async function handleCaseImportantToggle(isImportant: boolean) { if (!selectedCaseId || !canEdit || !selectedCase) return; @@ -439,6 +460,12 @@ export function CasesPage() { canEditImportant={canEdit} updatingImportant={updatingImportant} onImportantChange={(checked) => void handleCaseImportantToggle(checked)} + assignableStaff={assignableStaff} + canAssignTasks={canEdit} + assigningTaskId={assigningTaskId} + onAssignTask={(taskId, assigneeUserId) => + void handleAssignTask(taskId, assigneeUserId) + } headerMetaLines={

{t('fromClinic', { name: selectedCase.clinic.name })} diff --git a/frontend/src/components/ui/lab/TaskRow.tsx b/frontend/src/components/ui/lab/TaskRow.tsx index a4a3233..7c1137b 100644 --- a/frontend/src/components/ui/lab/TaskRow.tsx +++ b/frontend/src/components/ui/lab/TaskRow.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/shared/Button'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; import { + canEditLabTaskStatus, labTaskStatusSelectStyle, labTaskStatusVariant, } from '@/components/lab/labTaskStatusDisplay'; @@ -25,6 +26,7 @@ interface TaskRowProps { highlighted?: boolean; exiting?: boolean; canEdit: boolean; + currentUserId?: string; statusOptions: { value: LabTaskStatus; label: string }[]; updatingTaskId: string | null; commentsOpen: boolean; @@ -46,6 +48,7 @@ export function TaskRow({ highlighted = false, exiting = false, canEdit, + currentUserId, statusOptions, updatingTaskId, commentsOpen, @@ -56,6 +59,9 @@ export function TaskRow({ onShowInCase, }: TaskRowProps) { const t = useTranslations('tasks'); + const canEditStatus = canEditLabTaskStatus(task, currentUserId, canEdit); + const assignedToOther = + Boolean(task.assignee) && task.assignee!.id !== currentUserId; const taskDate = new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'short', @@ -75,7 +81,7 @@ export function TaskRow({ return (

  • @@ -113,8 +119,12 @@ export function TaskRow({

    -
    - {canEdit ? ( +
    + {canEditStatus && !exiting ? ( + ) : assignedToOther && !exiting ? ( + + {t('assignedToStaff', { name: task.assignee!.name })} + ) : ( {statusOptions.find((opt) => opt.value === task.status)?.label ?? task.status} @@ -135,7 +153,7 @@ export function TaskRow({ )}
    -
    +
    {flatMode && onShowInCase && !exiting ? (