improvement/ux-overhaul up #61
@@ -9,7 +9,8 @@ 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.
|
||||||
- **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.
|
- **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,12 +38,18 @@ 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 |
|
| `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) |
|
| 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).
|
**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`.
|
- **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.
|
- **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.
|
- **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
|
## 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 (only assignee or unassigned task)
|
||||||
- `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
|
- `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
|
## Permissions
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- **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 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
|
## Backend layout
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -25,6 +25,7 @@ model User {
|
|||||||
sentStaffInvites StaffInvitation[]
|
sentStaffInvites StaffInvitation[]
|
||||||
sentOrganizationInvitations OrganizationInvitation[]
|
sentOrganizationInvitations OrganizationInvitation[]
|
||||||
statusChangedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskLastStatusChangedBy")
|
statusChangedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskLastStatusChangedBy")
|
||||||
|
assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee")
|
||||||
labCaseTaskStatusEvents LabCaseTaskStatusEvent[]
|
labCaseTaskStatusEvents LabCaseTaskStatusEvent[]
|
||||||
labCaseComments LabCaseComment[]
|
labCaseComments LabCaseComment[]
|
||||||
phoneVerificationCodes PhoneVerificationCode[]
|
phoneVerificationCodes PhoneVerificationCode[]
|
||||||
@@ -353,11 +354,14 @@ model LabCaseTask {
|
|||||||
stepOrder Int
|
stepOrder Int
|
||||||
stepLabel String
|
stepLabel String
|
||||||
status LabTaskStatus @default(IN_PROGRESS)
|
status LabTaskStatus @default(IN_PROGRESS)
|
||||||
|
assigneeUserId String?
|
||||||
|
assignedAt DateTime?
|
||||||
lastStatusChangedByUserId String?
|
lastStatusChangedByUserId String?
|
||||||
lastStatusChangedAt DateTime?
|
lastStatusChangedAt DateTime?
|
||||||
|
|
||||||
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
||||||
detail TreatmentDetail @relation(fields: [treatmentDetailId], 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)
|
lastStatusChangedBy User? @relation("LabCaseTaskLastStatusChangedBy", fields: [lastStatusChangedByUserId], references: [id], onDelete: SetNull)
|
||||||
statusEvents LabCaseTaskStatusEvent[]
|
statusEvents LabCaseTaskStatusEvent[]
|
||||||
|
|
||||||
@@ -366,6 +370,7 @@ model LabCaseTask {
|
|||||||
|
|
||||||
@@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder])
|
@@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder])
|
||||||
@@index([labCaseId, status])
|
@@index([labCaseId, status])
|
||||||
|
@@index([assigneeUserId])
|
||||||
@@map("lab_case_tasks")
|
@@map("lab_case_tasks")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ 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 { CasesService } from './cases.service';
|
import { CasesService } from './cases.service';
|
||||||
import { ListLabCasesDto, UpdateLabCaseImportantDto } from './dto/cases.dto';
|
import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto';
|
||||||
|
|
||||||
@ApiTags('cases')
|
@ApiTags('cases')
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@@ -30,6 +30,13 @@ export class CasesController {
|
|||||||
return this.casesService.list(organizationId, req.user.id, query);
|
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')
|
@Get('filter-options')
|
||||||
@ApiOperation({ summary: 'Clinics and treatment types for inbox filters' })
|
@ApiOperation({ summary: 'Clinics and treatment types for inbox filters' })
|
||||||
listFilterOptions(@Req() req) {
|
listFilterOptions(@Req() req) {
|
||||||
@@ -74,4 +81,23 @@ export class CasesController {
|
|||||||
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
||||||
return this.casesService.setCaseImportant(id, dto, organizationId, req.user.id, req.user.language);
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import {
|
|||||||
} from '../catalog/catalog-label.service';
|
} from '../catalog/catalog-label.service';
|
||||||
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||||
import { normalizeTeeth } from '../treatments/treatment.utils';
|
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 { normalizeTaskTeeth } from './lab-case-task.util';
|
||||||
|
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||||
|
|
||||||
const labCaseListInclude = {
|
const labCaseListInclude = {
|
||||||
treatment: {
|
treatment: {
|
||||||
@@ -49,6 +50,7 @@ const labCaseListInclude = {
|
|||||||
],
|
],
|
||||||
include: {
|
include: {
|
||||||
lastStatusChangedBy: { select: { id: true, name: true } },
|
lastStatusChangedBy: { select: { id: true, name: true } },
|
||||||
|
assignee: { select: { id: true, name: true } },
|
||||||
statusEvents: {
|
statusEvents: {
|
||||||
orderBy: { changedAt: 'asc' as const },
|
orderBy: { changedAt: 'asc' as const },
|
||||||
include: { changedBy: { select: { id: true, name: true } } },
|
include: { changedBy: { select: { id: true, name: true } } },
|
||||||
@@ -74,6 +76,7 @@ const labCaseListInclude = {
|
|||||||
type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
|
type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
lastStatusChangedBy: { select: { id: true; name: true } };
|
lastStatusChangedBy: { select: { id: true; name: true } };
|
||||||
|
assignee: { select: { id: true; name: true } };
|
||||||
statusEvents: {
|
statusEvents: {
|
||||||
include: { changedBy: { select: { id: true; name: true } } };
|
include: { changedBy: { select: { id: true; name: true } } };
|
||||||
};
|
};
|
||||||
@@ -370,6 +373,95 @@ export class CasesService {
|
|||||||
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
|
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(
|
private buildListWhere(
|
||||||
labOrganizationId: string,
|
labOrganizationId: string,
|
||||||
query: ListLabCasesDto,
|
query: ListLabCasesDto,
|
||||||
@@ -579,6 +671,10 @@ export class CasesService {
|
|||||||
stepOrder: task.stepOrder,
|
stepOrder: task.stepOrder,
|
||||||
stepLabel: task.stepLabel,
|
stepLabel: task.stepLabel,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
|
assignee: task.assignee
|
||||||
|
? { id: task.assignee.id, name: task.assignee.name }
|
||||||
|
: null,
|
||||||
|
assignedAt: task.assignedAt?.toISOString() ?? null,
|
||||||
createdAt: task.createdAt.toISOString(),
|
createdAt: task.createdAt.toISOString(),
|
||||||
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
|
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
|
||||||
lastStatusChangedBy: task.lastStatusChangedBy
|
lastStatusChangedBy: task.lastStatusChangedBy
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ export class UpdateLabCaseImportantDto {
|
|||||||
isImportant: boolean;
|
isImportant: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AssignLabCaseTaskDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
assigneeUserId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class ListLabCasesDto {
|
export class ListLabCasesDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ export class ListLabTasksDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
labCaseId?: string;
|
labCaseId?: string;
|
||||||
|
|
||||||
|
/** When true, only tasks assigned to the current user. */
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
assignedToMe?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
|
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
@@ -147,6 +153,12 @@ export class LocateTaskPageDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
stepCompleted?: string;
|
stepCompleted?: string;
|
||||||
|
|
||||||
|
/** When true, only tasks assigned to the current user. */
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
assignedToMe?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
|
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { hasEffectivePermission } from '../../common/membership-permissions';
|
|||||||
|
|
||||||
const taskListInclude = {
|
const taskListInclude = {
|
||||||
lastStatusChangedBy: { select: { id: true, name: true } },
|
lastStatusChangedBy: { select: { id: true, name: true } },
|
||||||
|
assignee: { select: { id: true, name: true } },
|
||||||
labCase: {
|
labCase: {
|
||||||
include: {
|
include: {
|
||||||
treatment: {
|
treatment: {
|
||||||
@@ -55,7 +56,7 @@ export class TasksService {
|
|||||||
const limit = Math.min(Math.max(query.limit ?? 50, 1), 100);
|
const limit = Math.min(Math.max(query.limit ?? 50, 1), 100);
|
||||||
const skip = (page - 1) * limit;
|
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([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.labCaseTask.findMany({
|
this.prisma.labCaseTask.findMany({
|
||||||
@@ -117,7 +118,7 @@ export class TasksService {
|
|||||||
throw new NotFoundException('Task not found');
|
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({
|
const inFilteredSet = await this.prisma.labCaseTask.count({
|
||||||
where: { AND: [where, { id: query.taskId }] },
|
where: { AND: [where, { id: query.taskId }] },
|
||||||
});
|
});
|
||||||
@@ -176,6 +177,10 @@ export class TasksService {
|
|||||||
throw new NotFoundException('Task not found');
|
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 updated = await this.prisma.$transaction(async (tx) => {
|
||||||
const result = await tx.labCaseTask.update({
|
const result = await tx.labCaseTask.update({
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
@@ -263,6 +268,7 @@ export class TasksService {
|
|||||||
|
|
||||||
private async buildListWhere(
|
private async buildListWhere(
|
||||||
labOrganizationId: string,
|
labOrganizationId: string,
|
||||||
|
actorUserId: string,
|
||||||
query: ListLabTasksDto,
|
query: ListLabTasksDto,
|
||||||
): Promise<Prisma.LabCaseTaskWhereInput> {
|
): Promise<Prisma.LabCaseTaskWhereInput> {
|
||||||
const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null };
|
const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null };
|
||||||
@@ -306,6 +312,7 @@ export class TasksService {
|
|||||||
...(query.labCaseId ? { labCaseId: query.labCaseId } : {}),
|
...(query.labCaseId ? { labCaseId: query.labCaseId } : {}),
|
||||||
labCase: labCaseScope,
|
labCase: labCaseScope,
|
||||||
...(status !== undefined ? { status } : {}),
|
...(status !== undefined ? { status } : {}),
|
||||||
|
...(query.assignedToMe ? { assigneeUserId: actorUserId } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const stepCompleted = query.stepCompleted?.trim();
|
const stepCompleted = query.stepCompleted?.trim();
|
||||||
@@ -327,12 +334,16 @@ export class TasksService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
AND: [
|
||||||
|
base,
|
||||||
|
{
|
||||||
OR: completedGroups.map((group) => ({
|
OR: completedGroups.map((group) => ({
|
||||||
labCaseId: group.labCaseId,
|
labCaseId: group.labCaseId,
|
||||||
treatmentDetailId: group.treatmentDetailId,
|
treatmentDetailId: group.treatmentDetailId,
|
||||||
prosthesisTypeCode: group.prosthesisTypeCode,
|
prosthesisTypeCode: group.prosthesisTypeCode,
|
||||||
})),
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,6 +359,7 @@ export class TasksService {
|
|||||||
sentFrom: query.sentFrom,
|
sentFrom: query.sentFrom,
|
||||||
sentTo: query.sentTo,
|
sentTo: query.sentTo,
|
||||||
stepCompleted: query.stepCompleted,
|
stepCompleted: query.stepCompleted,
|
||||||
|
assignedToMe: query.assignedToMe,
|
||||||
sortBy: query.sortBy,
|
sortBy: query.sortBy,
|
||||||
sortDir: query.sortDir,
|
sortDir: query.sortDir,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
@@ -527,6 +539,10 @@ export class TasksService {
|
|||||||
stepLabel: task.stepLabel,
|
stepLabel: task.stepLabel,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
isImportant: task.labCase.isImportant,
|
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,
|
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
|
||||||
lastStatusChangedBy: task.lastStatusChangedBy
|
lastStatusChangedBy: task.lastStatusChangedBy
|
||||||
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
|
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
|
||||||
|
|||||||
@@ -435,6 +435,10 @@
|
|||||||
"errorLoadList": "Failed to load cases.",
|
"errorLoadList": "Failed to load cases.",
|
||||||
"errorLoadDetail": "Failed to load case details.",
|
"errorLoadDetail": "Failed to load case details.",
|
||||||
"errorUpdateTask": "Failed to update task.",
|
"errorUpdateTask": "Failed to update task.",
|
||||||
|
"errorAssignTask": "Failed to assign task.",
|
||||||
|
"assigneeLabel": "Assigned to",
|
||||||
|
"assigneeUnassigned": "Unassigned",
|
||||||
|
"assignedTo": "Assigned to {name}",
|
||||||
"filterClinic": "Clinic",
|
"filterClinic": "Clinic",
|
||||||
"filterClinicAll": "All clinics",
|
"filterClinicAll": "All clinics",
|
||||||
"filterTreatmentType": "Treatment type",
|
"filterTreatmentType": "Treatment type",
|
||||||
@@ -483,6 +487,8 @@
|
|||||||
"filterStepCompletedAll": "Any step",
|
"filterStepCompletedAll": "Any step",
|
||||||
"showCompleted": "Show completed",
|
"showCompleted": "Show completed",
|
||||||
"importantOnly": "Important first",
|
"importantOnly": "Important first",
|
||||||
|
"assignedToMe": "Assigned to me",
|
||||||
|
"assignedToStaff": "Assigned to {name}",
|
||||||
"resetView": "Reset filters & sort",
|
"resetView": "Reset filters & sort",
|
||||||
"showInCase": "Show in case",
|
"showInCase": "Show in case",
|
||||||
"showInCaseNotFound": "This task is not in the default in-progress list.",
|
"showInCaseNotFound": "This task is not in the default in-progress list.",
|
||||||
|
|||||||
@@ -435,6 +435,10 @@
|
|||||||
"errorLoadList": "بارگذاری پروندهها ناموفق بود.",
|
"errorLoadList": "بارگذاری پروندهها ناموفق بود.",
|
||||||
"errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.",
|
"errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.",
|
||||||
"errorUpdateTask": "بهروزرسانی وظیفه ناموفق بود.",
|
"errorUpdateTask": "بهروزرسانی وظیفه ناموفق بود.",
|
||||||
|
"errorAssignTask": "واگذاری وظیفه ناموفق بود.",
|
||||||
|
"assigneeLabel": "واگذار شده به",
|
||||||
|
"assigneeUnassigned": "واگذار نشده",
|
||||||
|
"assignedTo": "واگذار شده به {name}",
|
||||||
"filterClinic": "کلینیک",
|
"filterClinic": "کلینیک",
|
||||||
"filterClinicAll": "همه کلینیکها",
|
"filterClinicAll": "همه کلینیکها",
|
||||||
"filterTreatmentType": "نوع درمان",
|
"filterTreatmentType": "نوع درمان",
|
||||||
@@ -484,6 +488,8 @@
|
|||||||
"filterStepCompletedAll": "هر مرحلهای",
|
"filterStepCompletedAll": "هر مرحلهای",
|
||||||
"showCompleted": "نمایش تکمیلشدهها",
|
"showCompleted": "نمایش تکمیلشدهها",
|
||||||
"importantOnly": "مهمها در ابتدا",
|
"importantOnly": "مهمها در ابتدا",
|
||||||
|
"assignedToMe": "واگذار شده به من",
|
||||||
|
"assignedToStaff": "واگذار شده به {name}",
|
||||||
"resetView": "بازنشانی فیلترها و مرتبسازی",
|
"resetView": "بازنشانی فیلترها و مرتبسازی",
|
||||||
"showInCase": "نمایش در پرونده",
|
"showInCase": "نمایش در پرونده",
|
||||||
"showInCaseNotFound": "این کار در فهرست پیشفرض در حال انجام نیست.",
|
"showInCaseNotFound": "این کار در فهرست پیشفرض در حال انجام نیست.",
|
||||||
|
|||||||
@@ -435,6 +435,10 @@
|
|||||||
"errorLoadList": "Dossiers laden mislukt.",
|
"errorLoadList": "Dossiers laden mislukt.",
|
||||||
"errorLoadDetail": "Dossierdetails laden mislukt.",
|
"errorLoadDetail": "Dossierdetails laden mislukt.",
|
||||||
"errorUpdateTask": "Taak bijwerken mislukt.",
|
"errorUpdateTask": "Taak bijwerken mislukt.",
|
||||||
|
"errorAssignTask": "Taak toewijzen mislukt.",
|
||||||
|
"assigneeLabel": "Toegewezen aan",
|
||||||
|
"assigneeUnassigned": "Niet toegewezen",
|
||||||
|
"assignedTo": "Toegewezen aan {name}",
|
||||||
"filterClinic": "Kliniek",
|
"filterClinic": "Kliniek",
|
||||||
"filterClinicAll": "Alle klinieken",
|
"filterClinicAll": "Alle klinieken",
|
||||||
"filterTreatmentType": "Behandeltype",
|
"filterTreatmentType": "Behandeltype",
|
||||||
@@ -484,6 +488,8 @@
|
|||||||
"filterStepCompletedAll": "Elke stap",
|
"filterStepCompletedAll": "Elke stap",
|
||||||
"showCompleted": "Voltooide tonen",
|
"showCompleted": "Voltooide tonen",
|
||||||
"importantOnly": "Belangrijke cases eerst",
|
"importantOnly": "Belangrijke cases eerst",
|
||||||
|
"assignedToMe": "Toegewezen aan mij",
|
||||||
|
"assignedToStaff": "Toegewezen aan {name}",
|
||||||
"resetView": "Filters en sortering resetten",
|
"resetView": "Filters en sortering resetten",
|
||||||
"showInCase": "In case tonen",
|
"showInCase": "In case tonen",
|
||||||
"showInCaseNotFound": "Deze taak staat niet in de standaardlijst met taken in uitvoering.",
|
"showInCaseNotFound": "Deze taak staat niet in de standaardlijst met taken in uitvoering.",
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import type { BadgeVariant } from '@/components/ui/shared/Badge';
|
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<LabTaskListItem, 'assignee'>,
|
||||||
|
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 {
|
export function labTaskStatusVariant(status: LabTaskStatus): BadgeVariant {
|
||||||
return status === 'COMPLETED' ? 'success' : 'warning';
|
return status === 'COMPLETED' ? 'success' : 'warning';
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type TasksViewState = {
|
|||||||
sortBy: TaskSortField;
|
sortBy: TaskSortField;
|
||||||
sortDir: 'asc' | 'desc';
|
sortDir: 'asc' | 'desc';
|
||||||
importantOnly: boolean;
|
importantOnly: boolean;
|
||||||
|
assignedToMe: boolean;
|
||||||
page: number;
|
page: number;
|
||||||
highlightTaskId: string | null;
|
highlightTaskId: string | null;
|
||||||
};
|
};
|
||||||
@@ -20,6 +21,7 @@ export const DEFAULT_TASKS_VIEW: TasksViewState = {
|
|||||||
sortBy: 'date',
|
sortBy: 'date',
|
||||||
sortDir: 'desc',
|
sortDir: 'desc',
|
||||||
importantOnly: false,
|
importantOnly: false,
|
||||||
|
assignedToMe: false,
|
||||||
page: 1,
|
page: 1,
|
||||||
highlightTaskId: null,
|
highlightTaskId: null,
|
||||||
};
|
};
|
||||||
@@ -35,6 +37,7 @@ export function isDefaultTasksView(state: TasksViewState): boolean {
|
|||||||
state.sortBy === DEFAULT_TASKS_VIEW.sortBy &&
|
state.sortBy === DEFAULT_TASKS_VIEW.sortBy &&
|
||||||
state.sortDir === DEFAULT_TASKS_VIEW.sortDir &&
|
state.sortDir === DEFAULT_TASKS_VIEW.sortDir &&
|
||||||
state.importantOnly === DEFAULT_TASKS_VIEW.importantOnly &&
|
state.importantOnly === DEFAULT_TASKS_VIEW.importantOnly &&
|
||||||
|
state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe &&
|
||||||
state.page === DEFAULT_TASKS_VIEW.page &&
|
state.page === DEFAULT_TASKS_VIEW.page &&
|
||||||
state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId
|
state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/shared/Badge';
|
|||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
|
import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel';
|
||||||
|
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||||
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
|
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
|
||||||
import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog';
|
import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog';
|
||||||
import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay';
|
import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay';
|
||||||
@@ -21,7 +22,7 @@ import {
|
|||||||
formatPatientName,
|
formatPatientName,
|
||||||
latestCaseAttachment,
|
latestCaseAttachment,
|
||||||
} from '@/components/lab/caseDetailUtils';
|
} 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';
|
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
function CaseTaskProgressBar({ completed, total }: { completed: number; total: number }) {
|
function CaseTaskProgressBar({ completed, total }: { completed: number; total: number }) {
|
||||||
@@ -60,6 +61,10 @@ export interface CaseDetailPanelProps {
|
|||||||
updatingImportant?: boolean;
|
updatingImportant?: boolean;
|
||||||
onImportantChange?: (checked: boolean) => void;
|
onImportantChange?: (checked: boolean) => void;
|
||||||
commentsSection?: ReactNode;
|
commentsSection?: ReactNode;
|
||||||
|
assignableStaff?: AssignableTaskStaff[];
|
||||||
|
canAssignTasks?: boolean;
|
||||||
|
assigningTaskId?: string | null;
|
||||||
|
onAssignTask?: (taskId: string, assigneeUserId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CaseDetailPanel({
|
export function CaseDetailPanel({
|
||||||
@@ -76,6 +81,10 @@ export function CaseDetailPanel({
|
|||||||
updatingImportant = false,
|
updatingImportant = false,
|
||||||
onImportantChange,
|
onImportantChange,
|
||||||
commentsSection,
|
commentsSection,
|
||||||
|
assignableStaff = [],
|
||||||
|
canAssignTasks = false,
|
||||||
|
assigningTaskId = null,
|
||||||
|
onAssignTask,
|
||||||
}: CaseDetailPanelProps) {
|
}: CaseDetailPanelProps) {
|
||||||
const t = useTranslations('cases');
|
const t = useTranslations('cases');
|
||||||
const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false);
|
const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false);
|
||||||
@@ -213,24 +222,42 @@ export function CaseDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{group.tasks.map((task) => (
|
{group.tasks.map((task) => (
|
||||||
<li key={task.id} className="rounded bg-background p-2 text-sm space-y-1">
|
<li key={task.id} className="rounded bg-background px-2 py-1.5 text-sm">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1 font-medium text-text-primary">
|
||||||
{task.stepOrder}. {task.stepLabel}
|
{task.stepOrder}. {task.stepLabel}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
|
<span className="text-[11px] text-text-muted shrink-0">
|
||||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
|
||||||
task.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-text-muted">
|
|
||||||
{task.lastStatusChangedBy
|
{task.lastStatusChangedBy
|
||||||
? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })
|
? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })
|
||||||
: t('lastUpdatedUnknown')}
|
: t('lastUpdatedUnknown')}
|
||||||
{task.lastStatusChangedAt
|
{task.lastStatusChangedAt
|
||||||
? ` · ${formatCaseDateTime(task.lastStatusChangedAt, locale)}`
|
? ` · ${formatCaseDateTime(task.lastStatusChangedAt, locale)}`
|
||||||
: ''}
|
: ''}
|
||||||
</p>
|
</span>
|
||||||
|
{canAssignTasks && onAssignTask ? (
|
||||||
|
<select
|
||||||
|
value={task.assignee?.id ?? ''}
|
||||||
|
disabled={assigningTaskId === task.id}
|
||||||
|
onChange={(e) =>
|
||||||
|
onAssignTask(task.id, e.target.value ? e.target.value : null)
|
||||||
|
}
|
||||||
|
aria-label={t('assigneeLabel')}
|
||||||
|
className={`${FORM_SELECT_CLASS} max-w-[9.5rem] rounded-md px-2 py-0.5 text-xs`}
|
||||||
|
>
|
||||||
|
<option value="">{t('assigneeUnassigned')}</option>
|
||||||
|
{assignableStaff.map((staff) => (
|
||||||
|
<option key={staff.id} value={staff.id}>
|
||||||
|
{staff.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
|
||||||
|
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||||
|
task.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
|||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type {
|
import type {
|
||||||
|
AssignableTaskStaff,
|
||||||
CasesFilterOptions,
|
CasesFilterOptions,
|
||||||
LabCaseDetail,
|
LabCaseDetail,
|
||||||
LabCaseListItem,
|
LabCaseListItem,
|
||||||
@@ -67,6 +68,8 @@ export function CasesPage() {
|
|||||||
const [loadingList, setLoadingList] = useState(false);
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
const [updatingImportant, setUpdatingImportant] = useState(false);
|
const [updatingImportant, setUpdatingImportant] = useState(false);
|
||||||
|
const [assignableStaff, setAssignableStaff] = useState<AssignableTaskStaff[]>([]);
|
||||||
|
const [assigningTaskId, setAssigningTaskId] = useState<string | null>(null);
|
||||||
const [commentCount, setCommentCount] = useState(0);
|
const [commentCount, setCommentCount] = useState(0);
|
||||||
|
|
||||||
const canEdit = canEditCases(currentOrganization);
|
const canEdit = canEditCases(currentOrganization);
|
||||||
@@ -142,8 +145,11 @@ export function CasesPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
|
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
|
||||||
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(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
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
|
||||||
}, []);
|
}, [canEdit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const caseIdFromUrl = searchParams.get('caseId');
|
const caseIdFromUrl = searchParams.get('caseId');
|
||||||
@@ -206,6 +212,21 @@ export function CasesPage() {
|
|||||||
setPage(1);
|
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) {
|
async function handleCaseImportantToggle(isImportant: boolean) {
|
||||||
if (!selectedCaseId || !canEdit || !selectedCase) return;
|
if (!selectedCaseId || !canEdit || !selectedCase) return;
|
||||||
|
|
||||||
@@ -439,6 +460,12 @@ export function CasesPage() {
|
|||||||
canEditImportant={canEdit}
|
canEditImportant={canEdit}
|
||||||
updatingImportant={updatingImportant}
|
updatingImportant={updatingImportant}
|
||||||
onImportantChange={(checked) => void handleCaseImportantToggle(checked)}
|
onImportantChange={(checked) => void handleCaseImportantToggle(checked)}
|
||||||
|
assignableStaff={assignableStaff}
|
||||||
|
canAssignTasks={canEdit}
|
||||||
|
assigningTaskId={assigningTaskId}
|
||||||
|
onAssignTask={(taskId, assigneeUserId) =>
|
||||||
|
void handleAssignTask(taskId, assigneeUserId)
|
||||||
|
}
|
||||||
headerMetaLines={
|
headerMetaLines={
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
{t('fromClinic', { name: selectedCase.clinic.name })}
|
{t('fromClinic', { name: selectedCase.clinic.name })}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 {
|
||||||
|
canEditLabTaskStatus,
|
||||||
labTaskStatusSelectStyle,
|
labTaskStatusSelectStyle,
|
||||||
labTaskStatusVariant,
|
labTaskStatusVariant,
|
||||||
} from '@/components/lab/labTaskStatusDisplay';
|
} from '@/components/lab/labTaskStatusDisplay';
|
||||||
@@ -25,6 +26,7 @@ interface TaskRowProps {
|
|||||||
highlighted?: boolean;
|
highlighted?: boolean;
|
||||||
exiting?: boolean;
|
exiting?: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
|
currentUserId?: string;
|
||||||
statusOptions: { value: LabTaskStatus; label: string }[];
|
statusOptions: { value: LabTaskStatus; label: string }[];
|
||||||
updatingTaskId: string | null;
|
updatingTaskId: string | null;
|
||||||
commentsOpen: boolean;
|
commentsOpen: boolean;
|
||||||
@@ -46,6 +48,7 @@ export function TaskRow({
|
|||||||
highlighted = false,
|
highlighted = false,
|
||||||
exiting = false,
|
exiting = false,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
currentUserId,
|
||||||
statusOptions,
|
statusOptions,
|
||||||
updatingTaskId,
|
updatingTaskId,
|
||||||
commentsOpen,
|
commentsOpen,
|
||||||
@@ -56,6 +59,9 @@ export function TaskRow({
|
|||||||
onShowInCase,
|
onShowInCase,
|
||||||
}: TaskRowProps) {
|
}: TaskRowProps) {
|
||||||
const t = useTranslations('tasks');
|
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, {
|
const taskDate = new Intl.DateTimeFormat(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -75,7 +81,7 @@ export function TaskRow({
|
|||||||
return (
|
return (
|
||||||
<li id={`task-row-${task.id}`} className={rowClassName}>
|
<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)_minmax(8.5rem,11rem)_auto] sm:items-center sm:gap-x-3 sm:gap-y-0.5 sm:py-2 ${
|
||||||
flatMode ? '' : 'ps-5'
|
flatMode ? '' : 'ps-5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -113,8 +119,12 @@ export function TaskRow({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex sm:justify-center">
|
<div
|
||||||
{canEdit ? (
|
className={`flex sm:justify-center pe-2 sm:pe-3 shrink-0 ${
|
||||||
|
assignedToOther ? 'mb-1 sm:mb-0' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{canEditStatus && !exiting ? (
|
||||||
<select
|
<select
|
||||||
value={task.status}
|
value={task.status}
|
||||||
disabled={updatingTaskId === task.id || exiting}
|
disabled={updatingTaskId === task.id || exiting}
|
||||||
@@ -128,6 +138,14 @@ export function TaskRow({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
) : assignedToOther && !exiting ? (
|
||||||
|
<Badge
|
||||||
|
variant="default"
|
||||||
|
fixedWidth={false}
|
||||||
|
className="text-center whitespace-normal leading-snug max-w-[10.5rem]"
|
||||||
|
>
|
||||||
|
{t('assignedToStaff', { name: task.assignee!.name })}
|
||||||
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
|
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
|
||||||
{statusOptions.find((opt) => opt.value === task.status)?.label ?? task.status}
|
{statusOptions.find((opt) => opt.value === task.status)?.label ?? task.status}
|
||||||
@@ -135,7 +153,7 @@ export function TaskRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0 justify-between sm:justify-end flex-wrap">
|
<div className="flex items-center gap-1.5 shrink-0 justify-between sm:justify-end flex-wrap ps-1 sm:ps-0">
|
||||||
{flatMode && onShowInCase && !exiting ? (
|
{flatMode && onShowInCase && !exiting ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export function TasksPage() {
|
|||||||
);
|
);
|
||||||
const [stepCompleted, setStepCompleted] = useState(DEFAULT_TASKS_VIEW.stepCompleted);
|
const [stepCompleted, setStepCompleted] = useState(DEFAULT_TASKS_VIEW.stepCompleted);
|
||||||
const [importantOnly, setImportantOnly] = useState(DEFAULT_TASKS_VIEW.importantOnly);
|
const [importantOnly, setImportantOnly] = useState(DEFAULT_TASKS_VIEW.importantOnly);
|
||||||
|
const [assignedToMe, setAssignedToMe] = useState(DEFAULT_TASKS_VIEW.assignedToMe);
|
||||||
const [sortBy, setSortBy] = useState<TaskSortField>(DEFAULT_TASKS_VIEW.sortBy);
|
const [sortBy, setSortBy] = useState<TaskSortField>(DEFAULT_TASKS_VIEW.sortBy);
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>(DEFAULT_TASKS_VIEW.sortDir);
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>(DEFAULT_TASKS_VIEW.sortDir);
|
||||||
const [highlightTaskId, setHighlightTaskId] = useState<string | null>(
|
const [highlightTaskId, setHighlightTaskId] = useState<string | null>(
|
||||||
@@ -101,8 +102,9 @@ export function TasksPage() {
|
|||||||
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;
|
if (importantOnly) params.pinImportant = true;
|
||||||
|
if (assignedToMe) params.assignedToMe = true;
|
||||||
return params;
|
return params;
|
||||||
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, sortBy, sortDir]);
|
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, assignedToMe, sortBy, sortDir]);
|
||||||
|
|
||||||
const displayModel = useMemo(() => groupTasksForDisplay(tasks, sortBy), [tasks, sortBy]);
|
const displayModel = useMemo(() => groupTasksForDisplay(tasks, sortBy), [tasks, sortBy]);
|
||||||
|
|
||||||
@@ -117,6 +119,7 @@ export function TasksPage() {
|
|||||||
sortBy,
|
sortBy,
|
||||||
sortDir,
|
sortDir,
|
||||||
importantOnly,
|
importantOnly,
|
||||||
|
assignedToMe,
|
||||||
page,
|
page,
|
||||||
highlightTaskId,
|
highlightTaskId,
|
||||||
}),
|
}),
|
||||||
@@ -128,6 +131,7 @@ export function TasksPage() {
|
|||||||
sortBy,
|
sortBy,
|
||||||
sortDir,
|
sortDir,
|
||||||
importantOnly,
|
importantOnly,
|
||||||
|
assignedToMe,
|
||||||
page,
|
page,
|
||||||
highlightTaskId,
|
highlightTaskId,
|
||||||
],
|
],
|
||||||
@@ -192,6 +196,7 @@ export function TasksPage() {
|
|||||||
setStatusFilter(DEFAULT_TASKS_VIEW.statusFilter);
|
setStatusFilter(DEFAULT_TASKS_VIEW.statusFilter);
|
||||||
setStepCompleted(DEFAULT_TASKS_VIEW.stepCompleted);
|
setStepCompleted(DEFAULT_TASKS_VIEW.stepCompleted);
|
||||||
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
|
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
|
||||||
|
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
|
||||||
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
||||||
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
||||||
setPage(DEFAULT_TASKS_VIEW.page);
|
setPage(DEFAULT_TASKS_VIEW.page);
|
||||||
@@ -307,6 +312,7 @@ export function TasksPage() {
|
|||||||
highlighted={highlightTaskId === task.id}
|
highlighted={highlightTaskId === task.id}
|
||||||
exiting={exitingTaskIds.has(task.id)}
|
exiting={exitingTaskIds.has(task.id)}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
currentUserId={user?.id}
|
||||||
statusOptions={statusOptions}
|
statusOptions={statusOptions}
|
||||||
updatingTaskId={updatingTaskId}
|
updatingTaskId={updatingTaskId}
|
||||||
commentsOpen={expandedCommentsTaskId === task.id}
|
commentsOpen={expandedCommentsTaskId === task.id}
|
||||||
@@ -428,6 +434,12 @@ export function TasksPage() {
|
|||||||
label={t('importantOnly')}
|
label={t('importantOnly')}
|
||||||
className="text-xs [&_span:last-child]:text-xs"
|
className="text-xs [&_span:last-child]:text-xs"
|
||||||
/>
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={assignedToMe}
|
||||||
|
onChange={(checked) => applyFilterChange(() => setAssignedToMe(checked))}
|
||||||
|
label={t('assignedToMe')}
|
||||||
|
className="text-xs [&_span:last-child]:text-xs"
|
||||||
|
/>
|
||||||
{showReset ? (
|
{showReset ? (
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={resetView}>
|
<Button type="button" variant="ghost" size="sm" onClick={resetView}>
|
||||||
{t('resetView')}
|
{t('resetView')}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
import type {
|
import type {
|
||||||
CasesFilterOptions,
|
CasesFilterOptions,
|
||||||
|
AssignableTaskStaff,
|
||||||
LabCaseDetail,
|
LabCaseDetail,
|
||||||
ListLabCasesParams,
|
ListLabCasesParams,
|
||||||
PaginatedLabCases,
|
PaginatedLabCases,
|
||||||
@@ -24,6 +25,22 @@ export const casesApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listAssignableStaff: async (): Promise<{ success: boolean; data: AssignableTaskStaff[] }> => {
|
||||||
|
const response = await apiClient.get('/cases/assignable-staff');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
assignTask: async (
|
||||||
|
caseId: string,
|
||||||
|
taskId: string,
|
||||||
|
assigneeUserId: string | null,
|
||||||
|
): Promise<{ success: boolean; data: LabCaseDetail }> => {
|
||||||
|
const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}/assign`, {
|
||||||
|
assigneeUserId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
setCaseImportant: async (
|
setCaseImportant: async (
|
||||||
caseId: string,
|
caseId: string,
|
||||||
isImportant: boolean,
|
isImportant: boolean,
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export interface LabCaseTask {
|
|||||||
stepOrder: number;
|
stepOrder: number;
|
||||||
stepLabel: string;
|
stepLabel: string;
|
||||||
status: LabTaskStatus;
|
status: LabTaskStatus;
|
||||||
|
assignee: LabTaskUser | null;
|
||||||
|
assignedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastStatusChangedAt: string | null;
|
lastStatusChangedAt: string | null;
|
||||||
lastStatusChangedBy: LabTaskUser | null;
|
lastStatusChangedBy: LabTaskUser | null;
|
||||||
@@ -150,6 +152,7 @@ export interface ListLabTasksParams {
|
|||||||
completed?: boolean;
|
completed?: boolean;
|
||||||
important?: boolean;
|
important?: boolean;
|
||||||
pinImportant?: boolean;
|
pinImportant?: boolean;
|
||||||
|
assignedToMe?: boolean;
|
||||||
sentFrom?: string;
|
sentFrom?: string;
|
||||||
sentTo?: string;
|
sentTo?: string;
|
||||||
stepCompleted?: string;
|
stepCompleted?: string;
|
||||||
@@ -167,6 +170,7 @@ export interface LocateTaskPageParams {
|
|||||||
status?: LabTaskStatus;
|
status?: LabTaskStatus;
|
||||||
important?: boolean;
|
important?: boolean;
|
||||||
pinImportant?: boolean;
|
pinImportant?: boolean;
|
||||||
|
assignedToMe?: boolean;
|
||||||
stepCompleted?: string;
|
stepCompleted?: string;
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
sortDir?: 'asc' | 'desc';
|
sortDir?: 'asc' | 'desc';
|
||||||
@@ -192,6 +196,8 @@ export interface LabTaskListItem {
|
|||||||
stepLabel: string;
|
stepLabel: string;
|
||||||
status: LabTaskStatus;
|
status: LabTaskStatus;
|
||||||
isImportant: boolean;
|
isImportant: boolean;
|
||||||
|
assignee: LabTaskUser | null;
|
||||||
|
assignedAt: string | null;
|
||||||
lastStatusChangedAt: string | null;
|
lastStatusChangedAt: string | null;
|
||||||
lastStatusChangedBy: LabTaskUser | null;
|
lastStatusChangedBy: LabTaskUser | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -205,6 +211,11 @@ export interface TaskFilterOptions {
|
|||||||
workflowSteps: { code: string; label: string }[];
|
workflowSteps: { code: string; label: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssignableTaskStaff {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedLabTasks {
|
export interface PaginatedLabTasks {
|
||||||
items: LabTaskListItem[];
|
items: LabTaskListItem[];
|
||||||
pagination: {
|
pagination: {
|
||||||
|
|||||||
Reference in New Issue
Block a user