improvement/ux-overhaul up #61
14
.cursor/rules/lab-tasks.mdc
Normal file
14
.cursor/rules/lab-tasks.mdc
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
description: Lab Tasks tab — sort, grouping, filters, prosthesis colors
|
||||||
|
globs: frontend/src/components/ui/lab/TasksPage.tsx,frontend/src/components/ui/lab/Task*.tsx,frontend/src/components/lab/taskListGrouping.ts,frontend/src/components/treatment/prosthesisTypeDisplay.ts,frontend/src/components/shared/catalog-type-colors.ts,backend/src/modules/tasks/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Lab Tasks
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
Full map: `.cursor/skills/lab-tasks/SKILL.md`
|
||||||
55
.cursor/skills/lab-tasks/SKILL.md
Normal file
55
.cursor/skills/lab-tasks/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: dyolink-lab-tasks
|
||||||
|
description: Lab Tasks tab — list, sort, filters, case grouping, prosthesis colors, step-completed filter. Use when changing TasksPage, tasks API, or lab task list UX.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Lab Tasks
|
||||||
|
|
||||||
|
Main UI: [`frontend/src/components/ui/lab/TasksPage.tsx`](frontend/src/components/ui/lab/TasksPage.tsx)
|
||||||
|
Backend: [`backend/src/modules/tasks/`](backend/src/modules/tasks/)
|
||||||
|
|
||||||
|
## Default sort (backend)
|
||||||
|
|
||||||
|
`sortBy=date` + `sortDir=desc`:
|
||||||
|
|
||||||
|
1. `labCase.sentAt` desc (newest case first)
|
||||||
|
2. `labCaseId`, `treatmentDetailId`, `prosthesisTypeCode` asc (stable grouping)
|
||||||
|
3. `stepOrder` asc (steps 1→N within prosthesis group)
|
||||||
|
4. `id` asc
|
||||||
|
|
||||||
|
Other sorts use flat list on the frontend; `stepOrder asc` is still a tiebreaker.
|
||||||
|
|
||||||
|
## Case grouping (frontend)
|
||||||
|
|
||||||
|
- **`sortBy === 'date'`** → grouped view via [`taskListGrouping.ts`](frontend/src/components/lab/taskListGrouping.ts): case header → prosthesis sub-header → task rows.
|
||||||
|
- **Other sorts** → flat list; show muted hint (`groupingOff*` i18n keys). Each row keeps clinic/patient/teeth context.
|
||||||
|
|
||||||
|
Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
|
||||||
|
|
||||||
|
## Prosthesis colors
|
||||||
|
|
||||||
|
- Map: [`catalog-type-colors.ts`](frontend/src/components/shared/catalog-type-colors.ts) → `PROSTHESIS_TYPE_COLORS` (one hex per catalog code).
|
||||||
|
- Resolve with [`prosthesisTypeDisplay.ts`](frontend/src/components/treatment/prosthesisTypeDisplay.ts) — use `prosthesisTypeBadgeStyleFromCatalog(code, catalog)`, **not** list row index.
|
||||||
|
- Load catalog via `prosthesisCatalogApi.list()` on Tasks/Cases/Today dashboard.
|
||||||
|
|
||||||
|
## Filters
|
||||||
|
|
||||||
|
| Param | API | UI |
|
||||||
|
|-------|-----|-----|
|
||||||
|
| `q`, `clinicOrganizationId`, `status` | `GET /tasks` | Search, clinic, status |
|
||||||
|
| `stepCompleted` | `GET /tasks` | Workflow step dropdown |
|
||||||
|
| 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).
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
List items include `caseSentAt` for case headers.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
`TAB_TASKS_READ` / `TAB_TASKS_EDIT`; `LabOrgGuard` on all task routes.
|
||||||
@@ -45,6 +45,8 @@ 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`.
|
||||||
|
|
||||||
## Backend layout
|
## Backend layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -67,6 +69,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never
|
|||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature |
|
| `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature |
|
||||||
| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts |
|
| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts |
|
||||||
|
| `.cursor/skills/lab-tasks/` | Lab Tasks tab: sort, case grouping, step-completed filter, prosthesis colors |
|
||||||
| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout |
|
| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout |
|
||||||
| `.cursor/skills/api-errors/` | New backend errors + frontend translations |
|
| `.cursor/skills/api-errors/` | New backend errors + frontend translations |
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ export class ListLabTasksDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
sentTo?: string;
|
sentTo?: string;
|
||||||
|
|
||||||
|
/** Workflow step code (e.g. design) completed within the prosthesis group. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
stepCompleted?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
|
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ export class TasksController {
|
|||||||
return this.tasksService.list(organizationId, req.user.id, query, req.user.language);
|
return this.tasksService.list(organizationId, req.user.id, query, req.user.language);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('filter-options')
|
||||||
|
@ApiOperation({ summary: 'Filter options for lab tasks list' })
|
||||||
|
listFilterOptions(@Req() req) {
|
||||||
|
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
|
||||||
|
return this.tasksService.listFilterOptions(
|
||||||
|
organizationId,
|
||||||
|
req.user.id,
|
||||||
|
req.user.language,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':taskId')
|
@Patch(':taskId')
|
||||||
@ApiOperation({ summary: 'Update task status' })
|
@ApiOperation({ summary: 'Update task status' })
|
||||||
updateStatus(
|
updateStatus(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CatalogModule } from '../catalog/catalog.module';
|
||||||
import { TasksController } from './tasks.controller';
|
import { TasksController } from './tasks.controller';
|
||||||
import { TasksService } from './tasks.service';
|
import { TasksService } from './tasks.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [CatalogModule],
|
||||||
controllers: [TasksController],
|
controllers: [TasksController],
|
||||||
providers: [TasksService],
|
providers: [TasksService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,7 +55,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 = this.buildListWhere(labOrganizationId, query);
|
const where = await this.buildListWhere(labOrganizationId, query);
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.labCaseTask.findMany({
|
this.prisma.labCaseTask.findMany({
|
||||||
@@ -149,10 +149,60 @@ export class TasksService {
|
|||||||
return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) };
|
return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildListWhere(
|
async listFilterOptions(
|
||||||
|
labOrganizationId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
localeInput?: string | null,
|
||||||
|
) {
|
||||||
|
await this.assertCanReadTasks(actorUserId, labOrganizationId);
|
||||||
|
|
||||||
|
const rows = await this.prisma.labCase.findMany({
|
||||||
|
where: {
|
||||||
|
sentAt: { not: null },
|
||||||
|
sends: { some: { organizationId: labOrganizationId } },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
treatment: {
|
||||||
|
select: {
|
||||||
|
organization: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clinicsById = new Map<string, { id: string; name: string }>();
|
||||||
|
for (const row of rows) {
|
||||||
|
clinicsById.set(row.treatment.organization.id, row.treatment.organization);
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = normalizeCatalogLocale(localeInput);
|
||||||
|
const steps = await this.prisma.labWorkflowStep.findMany({
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
select: { code: true },
|
||||||
|
});
|
||||||
|
const stepCodes = steps.map((s) => s.code);
|
||||||
|
const stepLabels = await this.catalogLabels.resolveLabels(
|
||||||
|
CatalogEntityKind.LAB_WORKFLOW_STEP,
|
||||||
|
stepCodes,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
workflowSteps: steps.map((step) => ({
|
||||||
|
code: step.code,
|
||||||
|
label: stepLabels.get(step.code) ?? step.code,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildListWhere(
|
||||||
labOrganizationId: string,
|
labOrganizationId: string,
|
||||||
query: ListLabTasksDto,
|
query: ListLabTasksDto,
|
||||||
): Prisma.LabCaseTaskWhereInput {
|
): Promise<Prisma.LabCaseTaskWhereInput> {
|
||||||
const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null };
|
const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null };
|
||||||
|
|
||||||
if (query.sentFrom) {
|
if (query.sentFrom) {
|
||||||
@@ -181,8 +231,7 @@ export class TasksService {
|
|||||||
status = LabTaskStatus.IN_PROGRESS;
|
status = LabTaskStatus.IN_PROGRESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const labCaseScope: Prisma.LabCaseWhereInput = {
|
||||||
labCase: {
|
|
||||||
sentAt: sentAtFilter,
|
sentAt: sentAtFilter,
|
||||||
sends: { some: { organizationId: labOrganizationId } },
|
sends: { some: { organizationId: labOrganizationId } },
|
||||||
...(query.clinicOrganizationId
|
...(query.clinicOrganizationId
|
||||||
@@ -190,9 +239,39 @@ export class TasksService {
|
|||||||
: {}),
|
: {}),
|
||||||
...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}),
|
...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}),
|
||||||
...(query.important !== undefined ? { isImportant: query.important } : {}),
|
...(query.important !== undefined ? { isImportant: query.important } : {}),
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const base: Prisma.LabCaseTaskWhereInput = {
|
||||||
|
labCase: labCaseScope,
|
||||||
...(status !== undefined ? { status } : {}),
|
...(status !== undefined ? { status } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stepCompleted = query.stepCompleted?.trim();
|
||||||
|
if (!stepCompleted) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedGroups = await this.prisma.labCaseTask.groupBy({
|
||||||
|
by: ['labCaseId', 'treatmentDetailId', 'prosthesisTypeCode'],
|
||||||
|
where: {
|
||||||
|
workflowStepCode: stepCompleted,
|
||||||
|
status: LabTaskStatus.COMPLETED,
|
||||||
|
labCase: labCaseScope,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (completedGroups.length === 0) {
|
||||||
|
return { id: { in: [] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
OR: completedGroups.map((group) => ({
|
||||||
|
labCaseId: group.labCaseId,
|
||||||
|
treatmentDetailId: group.treatmentDetailId,
|
||||||
|
prosthesisTypeCode: group.prosthesisTypeCode,
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildSearchWhere(q: string): Prisma.TreatmentWhereInput {
|
private buildSearchWhere(q: string): Prisma.TreatmentWhereInput {
|
||||||
@@ -214,40 +293,50 @@ export class TasksService {
|
|||||||
|
|
||||||
private buildOrderBy(query: ListLabTasksDto): Prisma.LabCaseTaskOrderByWithRelationInput[] {
|
private buildOrderBy(query: ListLabTasksDto): Prisma.LabCaseTaskOrderByWithRelationInput[] {
|
||||||
const dir = query.sortDir ?? 'desc';
|
const dir = query.sortDir ?? 'desc';
|
||||||
|
const stepTiebreakers: Prisma.LabCaseTaskOrderByWithRelationInput[] = [
|
||||||
|
{ stepOrder: 'asc' },
|
||||||
|
{ id: 'asc' },
|
||||||
|
];
|
||||||
|
|
||||||
switch (query.sortBy) {
|
switch (query.sortBy) {
|
||||||
case 'status':
|
case 'status':
|
||||||
return [{ status: dir }, { createdAt: 'desc' }, { id: 'asc' }];
|
return [{ status: dir }, { createdAt: 'desc' }, ...stepTiebreakers];
|
||||||
case 'clinic':
|
case 'clinic':
|
||||||
return [
|
return [
|
||||||
{ labCase: { treatment: { organization: { name: dir } } } },
|
{ labCase: { treatment: { organization: { name: dir } } } },
|
||||||
{ createdAt: 'desc' },
|
{ createdAt: 'desc' },
|
||||||
{ id: 'asc' },
|
...stepTiebreakers,
|
||||||
];
|
];
|
||||||
case 'patient':
|
case 'patient':
|
||||||
return [
|
return [
|
||||||
{ labCase: { treatment: { patient: { lastName: dir } } } },
|
{ labCase: { treatment: { patient: { lastName: dir } } } },
|
||||||
{ labCase: { treatment: { patient: { firstName: dir } } } },
|
{ labCase: { treatment: { patient: { firstName: dir } } } },
|
||||||
{ id: 'asc' },
|
...stepTiebreakers,
|
||||||
];
|
];
|
||||||
case 'important':
|
case 'important':
|
||||||
return [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, { id: 'asc' }];
|
return [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, ...stepTiebreakers];
|
||||||
case 'prosthesis':
|
case 'prosthesis':
|
||||||
return [{ prosthesisTypeCode: dir }, { createdAt: 'desc' }, { id: 'asc' }];
|
return [
|
||||||
|
{ prosthesisTypeCode: dir },
|
||||||
|
{ createdAt: 'desc' },
|
||||||
|
...stepTiebreakers,
|
||||||
|
];
|
||||||
case 'taskType':
|
case 'taskType':
|
||||||
return [
|
return [
|
||||||
{ workflowStepCode: dir },
|
{ workflowStepCode: dir },
|
||||||
{ stepOrder: 'asc' },
|
{ stepOrder: 'asc' },
|
||||||
{ createdAt: 'desc' },
|
{ createdAt: 'desc' },
|
||||||
{ id: 'asc' },
|
...stepTiebreakers,
|
||||||
];
|
];
|
||||||
case 'date':
|
case 'date':
|
||||||
default:
|
default:
|
||||||
// date / caseId / taskId / stepId — newest first by default.
|
|
||||||
return [
|
return [
|
||||||
{ labCase: { sentAt: dir } },
|
{ labCase: { sentAt: dir } },
|
||||||
{ labCaseId: dir },
|
{ labCaseId: 'asc' },
|
||||||
{ id: dir },
|
{ treatmentDetailId: 'asc' },
|
||||||
{ stepOrder: dir },
|
{ prosthesisTypeCode: 'asc' },
|
||||||
|
{ stepOrder: 'asc' },
|
||||||
|
{ id: 'asc' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,6 +364,7 @@ export class TasksService {
|
|||||||
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
|
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
|
||||||
: null,
|
: null,
|
||||||
createdAt: task.createdAt.toISOString(),
|
createdAt: task.createdAt.toISOString(),
|
||||||
|
caseSentAt: task.labCase.sentAt?.toISOString() ?? null,
|
||||||
clinic: task.labCase.treatment.organization,
|
clinic: task.labCase.treatment.organization,
|
||||||
patient: {
|
patient: {
|
||||||
id: task.labCase.treatment.patient.id,
|
id: task.labCase.treatment.patient.id,
|
||||||
|
|||||||
@@ -479,6 +479,8 @@
|
|||||||
"filterClinicAll": "All clinics",
|
"filterClinicAll": "All clinics",
|
||||||
"filterStatus": "Status",
|
"filterStatus": "Status",
|
||||||
"filterStatusAll": "All statuses",
|
"filterStatusAll": "All statuses",
|
||||||
|
"filterStepCompleted": "Step completed",
|
||||||
|
"filterStepCompletedAll": "Any step",
|
||||||
"showCompleted": "Show completed",
|
"showCompleted": "Show completed",
|
||||||
"importantOnly": "Important only",
|
"importantOnly": "Important only",
|
||||||
"filterSentFrom": "From",
|
"filterSentFrom": "From",
|
||||||
@@ -492,6 +494,13 @@
|
|||||||
"sortProsthesis": "Prosthesis type",
|
"sortProsthesis": "Prosthesis type",
|
||||||
"sortTaskType": "Task type",
|
"sortTaskType": "Task type",
|
||||||
"sortDirection": "Sort direction",
|
"sortDirection": "Sort direction",
|
||||||
|
"groupingOffClinic": "Sorted by clinic — case grouping is off.",
|
||||||
|
"groupingOffPatient": "Sorted by patient — case grouping is off.",
|
||||||
|
"groupingOffProsthesis": "Sorted by prosthesis type — case grouping is off.",
|
||||||
|
"groupingOffTaskType": "Sorted by task type — case grouping is off.",
|
||||||
|
"groupingOffStatus": "Sorted by status — case grouping is off.",
|
||||||
|
"caseReceivedAt": "Received {date}",
|
||||||
|
"caseTaskProgress": "{completed}/{total} tasks on this page",
|
||||||
"clearFilters": "Clear filters",
|
"clearFilters": "Clear filters",
|
||||||
"commentsButton": "Comments",
|
"commentsButton": "Comments",
|
||||||
"errorLoadList": "Failed to load tasks.",
|
"errorLoadList": "Failed to load tasks.",
|
||||||
|
|||||||
@@ -480,6 +480,8 @@
|
|||||||
"filterClinicAll": "همه کلینیکها",
|
"filterClinicAll": "همه کلینیکها",
|
||||||
"filterStatus": "وضعیت",
|
"filterStatus": "وضعیت",
|
||||||
"filterStatusAll": "همه وضعیتها",
|
"filterStatusAll": "همه وضعیتها",
|
||||||
|
"filterStepCompleted": "مرحله تکمیلشده",
|
||||||
|
"filterStepCompletedAll": "هر مرحلهای",
|
||||||
"showCompleted": "نمایش تکمیلشدهها",
|
"showCompleted": "نمایش تکمیلشدهها",
|
||||||
"importantOnly": "فقط مهمها",
|
"importantOnly": "فقط مهمها",
|
||||||
"filterSentFrom": "از",
|
"filterSentFrom": "از",
|
||||||
@@ -493,6 +495,13 @@
|
|||||||
"sortProsthesis": "نوع پروتز",
|
"sortProsthesis": "نوع پروتز",
|
||||||
"sortTaskType": "نوع کار",
|
"sortTaskType": "نوع کار",
|
||||||
"sortDirection": "جهت مرتبسازی",
|
"sortDirection": "جهت مرتبسازی",
|
||||||
|
"groupingOffClinic": "مرتبسازی بر اساس کلینیک — گروهبندی پرونده غیرفعال است.",
|
||||||
|
"groupingOffPatient": "مرتبسازی بر اساس بیمار — گروهبندی پرونده غیرفعال است.",
|
||||||
|
"groupingOffProsthesis": "مرتبسازی بر اساس نوع پروتز — گروهبندی پرونده غیرفعال است.",
|
||||||
|
"groupingOffTaskType": "مرتبسازی بر اساس نوع کار — گروهبندی پرونده غیرفعال است.",
|
||||||
|
"groupingOffStatus": "مرتبسازی بر اساس وضعیت — گروهبندی پرونده غیرفعال است.",
|
||||||
|
"caseReceivedAt": "دریافت {date}",
|
||||||
|
"caseTaskProgress": "{completed}/{total} کار در این صفحه",
|
||||||
"clearFilters": "پاک کردن فیلترها",
|
"clearFilters": "پاک کردن فیلترها",
|
||||||
"commentsButton": "نظرات",
|
"commentsButton": "نظرات",
|
||||||
"errorLoadList": "بارگذاری وظایف ناموفق بود.",
|
"errorLoadList": "بارگذاری وظایف ناموفق بود.",
|
||||||
|
|||||||
@@ -480,6 +480,8 @@
|
|||||||
"filterClinicAll": "Alle klinieken",
|
"filterClinicAll": "Alle klinieken",
|
||||||
"filterStatus": "Status",
|
"filterStatus": "Status",
|
||||||
"filterStatusAll": "Alle statussen",
|
"filterStatusAll": "Alle statussen",
|
||||||
|
"filterStepCompleted": "Stap voltooid",
|
||||||
|
"filterStepCompletedAll": "Elke stap",
|
||||||
"showCompleted": "Voltooide tonen",
|
"showCompleted": "Voltooide tonen",
|
||||||
"importantOnly": "Alleen belangrijk",
|
"importantOnly": "Alleen belangrijk",
|
||||||
"filterSentFrom": "Vanaf",
|
"filterSentFrom": "Vanaf",
|
||||||
@@ -493,6 +495,13 @@
|
|||||||
"sortProsthesis": "Prothesetype",
|
"sortProsthesis": "Prothesetype",
|
||||||
"sortTaskType": "Taaktype",
|
"sortTaskType": "Taaktype",
|
||||||
"sortDirection": "Sorteerrichting",
|
"sortDirection": "Sorteerrichting",
|
||||||
|
"groupingOffClinic": "Gesorteerd op kliniek — casagroepering is uit.",
|
||||||
|
"groupingOffPatient": "Gesorteerd op patiënt — casagroepering is uit.",
|
||||||
|
"groupingOffProsthesis": "Gesorteerd op prothesetype — casagroepering is uit.",
|
||||||
|
"groupingOffTaskType": "Gesorteerd op taaktype — casagroepering is uit.",
|
||||||
|
"groupingOffStatus": "Gesorteerd op status — casagroepering is uit.",
|
||||||
|
"caseReceivedAt": "Ontvangen {date}",
|
||||||
|
"caseTaskProgress": "{completed}/{total} taken op deze pagina",
|
||||||
"clearFilters": "Filters wissen",
|
"clearFilters": "Filters wissen",
|
||||||
"commentsButton": "Opmerkingen",
|
"commentsButton": "Opmerkingen",
|
||||||
"errorLoadList": "Taken laden mislukt.",
|
"errorLoadList": "Taken laden mislukt.",
|
||||||
|
|||||||
89
frontend/src/components/lab/taskListGrouping.ts
Normal file
89
frontend/src/components/lab/taskListGrouping.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { LabTaskListItem, TaskSortField } from '@/types/cases';
|
||||||
|
|
||||||
|
export type ProsthesisTaskGroup = {
|
||||||
|
key: string;
|
||||||
|
treatmentDetailId: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
prosthesisTypeLabel: string;
|
||||||
|
teeth: string[];
|
||||||
|
tasks: LabTaskListItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CaseTaskGroup = {
|
||||||
|
labCaseId: string;
|
||||||
|
clinic: LabTaskListItem['clinic'];
|
||||||
|
patient: LabTaskListItem['patient'];
|
||||||
|
caseSentAt: string | null;
|
||||||
|
isImportant: boolean;
|
||||||
|
prosthesisGroups: ProsthesisTaskGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskDisplayModel =
|
||||||
|
| { mode: 'grouped'; cases: CaseTaskGroup[] }
|
||||||
|
| { mode: 'flat'; tasks: LabTaskListItem[] };
|
||||||
|
|
||||||
|
function prosthesisGroupKey(task: LabTaskListItem): string {
|
||||||
|
return `${task.treatmentDetailId}:${task.prosthesisTypeCode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupTasksForDisplay(
|
||||||
|
tasks: LabTaskListItem[],
|
||||||
|
sortBy: TaskSortField,
|
||||||
|
): TaskDisplayModel {
|
||||||
|
if (sortBy !== 'date') {
|
||||||
|
return { mode: 'flat', tasks };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cases: CaseTaskGroup[] = [];
|
||||||
|
const caseIndex = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
let caseIdx = caseIndex.get(task.labCaseId);
|
||||||
|
if (caseIdx === undefined) {
|
||||||
|
caseIdx = cases.length;
|
||||||
|
caseIndex.set(task.labCaseId, caseIdx);
|
||||||
|
cases.push({
|
||||||
|
labCaseId: task.labCaseId,
|
||||||
|
clinic: task.clinic,
|
||||||
|
patient: task.patient,
|
||||||
|
caseSentAt: task.caseSentAt ?? null,
|
||||||
|
isImportant: task.isImportant,
|
||||||
|
prosthesisGroups: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const caseGroup = cases[caseIdx];
|
||||||
|
const pgKey = prosthesisGroupKey(task);
|
||||||
|
let prosthesisGroup = caseGroup.prosthesisGroups.find((g) => g.key === pgKey);
|
||||||
|
if (!prosthesisGroup) {
|
||||||
|
prosthesisGroup = {
|
||||||
|
key: pgKey,
|
||||||
|
treatmentDetailId: task.treatmentDetailId,
|
||||||
|
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||||
|
prosthesisTypeLabel: task.prosthesisTypeLabel,
|
||||||
|
teeth: task.teeth,
|
||||||
|
tasks: [],
|
||||||
|
};
|
||||||
|
caseGroup.prosthesisGroups.push(prosthesisGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
prosthesisGroup.tasks.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode: 'grouped', cases };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countCaseTaskProgress(caseGroup: CaseTaskGroup): {
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
} {
|
||||||
|
let completed = 0;
|
||||||
|
let total = 0;
|
||||||
|
for (const group of caseGroup.prosthesisGroups) {
|
||||||
|
for (const task of group.tasks) {
|
||||||
|
total += 1;
|
||||||
|
if (task.status === 'COMPLETED') completed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { completed, total };
|
||||||
|
}
|
||||||
@@ -24,22 +24,22 @@ export const TREATMENT_TYPE_COLORS: Record<string, string> = {
|
|||||||
export const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
|
export const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
|
||||||
pfm_crown: '#e2e8f0',
|
pfm_crown: '#e2e8f0',
|
||||||
pfz_crown: '#bbf7d0',
|
pfz_crown: '#bbf7d0',
|
||||||
monolithic_zirconia: '#e0f2fe',
|
monolithic_zirconia: '#bae6fd',
|
||||||
glass_ceramic_crown: '#fef08a',
|
glass_ceramic_crown: '#fef08a',
|
||||||
full_metal_crown: '#d4d4d8',
|
full_metal_crown: '#d4d4d8',
|
||||||
temporary_resin_crown: '#bae6fd',
|
temporary_resin_crown: '#ddd6fe',
|
||||||
pmma: '#7dd3fc',
|
pmma: '#fcd34d',
|
||||||
peek_crown: '#5eead4',
|
peek_crown: '#fda4af',
|
||||||
veneer_zirconia: '#6ee7b7',
|
veneer_zirconia: '#86efac',
|
||||||
veneer_ips_press: '#fed7aa',
|
veneer_ips_press: '#fed7aa',
|
||||||
veneer_ips_cad: '#fdba74',
|
veneer_ips_cad: '#fdba74',
|
||||||
soft_structure: '#ddd6fe',
|
soft_structure: '#e9d5ff',
|
||||||
customized_abutment: '#a5b4fc',
|
customized_abutment: '#a5b4fc',
|
||||||
prefabricated_abutment: '#c7d2fe',
|
prefabricated_abutment: '#c7d2fe',
|
||||||
ti_base_abutment: '#bfdbfe',
|
ti_base_abutment: '#93c5fd',
|
||||||
multi_unit_abutment: '#818cf8',
|
multi_unit_abutment: '#818cf8',
|
||||||
zirconia_abutment: '#34d399',
|
zirconia_abutment: '#4ade80',
|
||||||
screw_retained: '#e9d5ff',
|
screw_retained: '#f0abfc',
|
||||||
zirconia_overlay: '#2dd4bf',
|
zirconia_overlay: '#2dd4bf',
|
||||||
ips_overlay: '#fef3c7',
|
ips_overlay: '#fef3c7',
|
||||||
smile_design: '#f9a8d4',
|
smile_design: '#f9a8d4',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
|
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import {
|
import {
|
||||||
PROSTHESIS_FALLBACK_COLORS,
|
PROSTHESIS_FALLBACK_COLORS,
|
||||||
PROSTHESIS_TYPE_COLORS,
|
PROSTHESIS_TYPE_COLORS,
|
||||||
@@ -15,15 +16,40 @@ import {
|
|||||||
/** Dark ink that stays readable on every pastel in the palette. */
|
/** Dark ink that stays readable on every pastel in the palette. */
|
||||||
const BADGE_INK = '#14253d';
|
const BADGE_INK = '#14253d';
|
||||||
|
|
||||||
|
/** Stable catalog index for fallback colors — use sortOrder, not list row position. */
|
||||||
|
export function prosthesisCatalogColorIndex(
|
||||||
|
code: string,
|
||||||
|
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
|
||||||
|
): number {
|
||||||
|
const entry = catalog.find((e) => e.code === code);
|
||||||
|
if (entry) return Math.max(0, entry.sortOrder - 1);
|
||||||
|
const idx = catalog.findIndex((e) => e.code === code);
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function prosthesisTypeColor(code: string, index = 0): string {
|
export function prosthesisTypeColor(code: string, index = 0): string {
|
||||||
return resolveCatalogTypeColor(code, PROSTHESIS_TYPE_COLORS, index, PROSTHESIS_FALLBACK_COLORS);
|
return resolveCatalogTypeColor(code, PROSTHESIS_TYPE_COLORS, index, PROSTHESIS_FALLBACK_COLORS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function prosthesisTypeColorFromCatalog(
|
||||||
|
code: string,
|
||||||
|
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
|
||||||
|
): string {
|
||||||
|
return prosthesisTypeColor(code, prosthesisCatalogColorIndex(code, catalog));
|
||||||
|
}
|
||||||
|
|
||||||
/** Filled swatch (small indicator dots). */
|
/** Filled swatch (small indicator dots). */
|
||||||
export function prosthesisTypeSwatchStyle(code: string, index = 0): CSSProperties {
|
export function prosthesisTypeSwatchStyle(code: string, index = 0): CSSProperties {
|
||||||
return { backgroundColor: prosthesisTypeColor(code, index), borderColor: 'rgba(0, 0, 0, 0.18)' };
|
return { backgroundColor: prosthesisTypeColor(code, index), borderColor: 'rgba(0, 0, 0, 0.18)' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function prosthesisTypeSwatchStyleFromCatalog(
|
||||||
|
code: string,
|
||||||
|
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
|
||||||
|
): CSSProperties {
|
||||||
|
return prosthesisTypeSwatchStyle(code, prosthesisCatalogColorIndex(code, catalog));
|
||||||
|
}
|
||||||
|
|
||||||
/** Pastel pill / banner fill with readable dark text (group headers, badges). */
|
/** Pastel pill / banner fill with readable dark text (group headers, badges). */
|
||||||
export function prosthesisTypeBadgeStyle(code: string, index = 0): CSSProperties {
|
export function prosthesisTypeBadgeStyle(code: string, index = 0): CSSProperties {
|
||||||
return {
|
return {
|
||||||
@@ -33,6 +59,13 @@ export function prosthesisTypeBadgeStyle(code: string, index = 0): CSSProperties
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function prosthesisTypeBadgeStyleFromCatalog(
|
||||||
|
code: string,
|
||||||
|
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
|
||||||
|
): CSSProperties {
|
||||||
|
return prosthesisTypeBadgeStyle(code, prosthesisCatalogColorIndex(code, catalog));
|
||||||
|
}
|
||||||
|
|
||||||
export function formatToothList(teeth: string[]): string {
|
export function formatToothList(teeth: string[]): string {
|
||||||
return teeth.join(', ');
|
return teeth.join(', ');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState, type ReactNode } from 'react';
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { MessageSquare } from 'lucide-react';
|
import { MessageSquare } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/shared/Badge';
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
@@ -12,8 +12,9 @@ import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachments
|
|||||||
import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay';
|
import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay';
|
||||||
import {
|
import {
|
||||||
formatToothList,
|
formatToothList,
|
||||||
prosthesisTypeBadgeStyle,
|
prosthesisTypeBadgeStyleFromCatalog,
|
||||||
} from '@/components/treatment/prosthesisTypeDisplay';
|
} from '@/components/treatment/prosthesisTypeDisplay';
|
||||||
|
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||||
import {
|
import {
|
||||||
buildCaseProsthesisRows,
|
buildCaseProsthesisRows,
|
||||||
formatCaseDateTime,
|
formatCaseDateTime,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
latestCaseAttachment,
|
latestCaseAttachment,
|
||||||
} from '@/components/lab/caseDetailUtils';
|
} from '@/components/lab/caseDetailUtils';
|
||||||
import type { LabCaseDetail, LabTaskStatus } from '@/types/cases';
|
import type { LabCaseDetail, LabTaskStatus } from '@/types/cases';
|
||||||
|
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
function CaseTaskProgressBar({ completed, total }: { completed: number; total: number }) {
|
function CaseTaskProgressBar({ completed, total }: { completed: number; total: number }) {
|
||||||
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
@@ -77,6 +79,14 @@ export function CaseDetailPanel({
|
|||||||
}: CaseDetailPanelProps) {
|
}: CaseDetailPanelProps) {
|
||||||
const t = useTranslations('cases');
|
const t = useTranslations('cases');
|
||||||
const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false);
|
const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false);
|
||||||
|
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void prosthesisCatalogApi
|
||||||
|
.list()
|
||||||
|
.then((response) => setProsthesisCatalog(response.data))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const prosthesisRows = useMemo(() => buildCaseProsthesisRows(labCase), [labCase]);
|
const prosthesisRows = useMemo(() => buildCaseProsthesisRows(labCase), [labCase]);
|
||||||
const previewAttachment = useMemo(() => latestCaseAttachment(labCase), [labCase]);
|
const previewAttachment = useMemo(() => latestCaseAttachment(labCase), [labCase]);
|
||||||
@@ -154,6 +164,7 @@ export function CaseDetailPanel({
|
|||||||
<CaseToothChartPanel
|
<CaseToothChartPanel
|
||||||
details={labCase.detail ? [{ teeth: labCase.detail.teeth }] : []}
|
details={labCase.detail ? [{ teeth: labCase.detail.teeth }] : []}
|
||||||
prosthesisRows={prosthesisRows}
|
prosthesisRows={prosthesisRows}
|
||||||
|
prosthesisCatalog={prosthesisCatalog}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -177,7 +188,7 @@ export function CaseDetailPanel({
|
|||||||
{labCase.tasksByTooth.length === 0 ? (
|
{labCase.tasksByTooth.length === 0 ? (
|
||||||
<p className="text-sm text-text-muted">{t('noTasks')}</p>
|
<p className="text-sm text-text-muted">{t('noTasks')}</p>
|
||||||
) : (
|
) : (
|
||||||
labCase.tasksByTooth.map((group, groupIndex) => (
|
labCase.tasksByTooth.map((group) => (
|
||||||
<div
|
<div
|
||||||
key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
|
key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
|
||||||
className="rounded-md border border-border p-3 space-y-2"
|
className="rounded-md border border-border p-3 space-y-2"
|
||||||
@@ -186,7 +197,10 @@ export function CaseDetailPanel({
|
|||||||
<Badge
|
<Badge
|
||||||
truncate
|
truncate
|
||||||
title={group.prosthesisTypeLabel}
|
title={group.prosthesisTypeLabel}
|
||||||
style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
|
style={prosthesisTypeBadgeStyleFromCatalog(
|
||||||
|
group.prosthesisTypeCode,
|
||||||
|
prosthesisCatalog,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{group.prosthesisTypeLabel}
|
{group.prosthesisTypeLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
||||||
import { prosthesisTypeColor } from '@/components/treatment/prosthesisTypeDisplay';
|
import { prosthesisTypeColor, prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
|
||||||
|
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type { FdiToothId } from '@/types/treatment';
|
import type { FdiToothId } from '@/types/treatment';
|
||||||
|
|
||||||
export interface CaseToothChartDetail {
|
export interface CaseToothChartDetail {
|
||||||
@@ -18,6 +19,7 @@ interface CaseToothChartPanelProps {
|
|||||||
details: CaseToothChartDetail[];
|
details: CaseToothChartDetail[];
|
||||||
/** Prosthesis mapping from case tasks or toothProsthesis rows. */
|
/** Prosthesis mapping from case tasks or toothProsthesis rows. */
|
||||||
prosthesisRows: CaseToothChartProsthesisRow[];
|
prosthesisRows: CaseToothChartProsthesisRow[];
|
||||||
|
prosthesisCatalog?: readonly ProsthesisCatalogEntry[];
|
||||||
scale?: number;
|
scale?: number;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -27,6 +29,7 @@ interface CaseToothChartPanelProps {
|
|||||||
export function CaseToothChartPanel({
|
export function CaseToothChartPanel({
|
||||||
details,
|
details,
|
||||||
prosthesisRows,
|
prosthesisRows,
|
||||||
|
prosthesisCatalog,
|
||||||
scale = 1,
|
scale = 1,
|
||||||
compact = true,
|
compact = true,
|
||||||
className = '',
|
className = '',
|
||||||
@@ -42,13 +45,15 @@ export function CaseToothChartPanel({
|
|||||||
const toothColors = useMemo(() => {
|
const toothColors = useMemo(() => {
|
||||||
const colors: Partial<Record<FdiToothId, string>> = {};
|
const colors: Partial<Record<FdiToothId, string>> = {};
|
||||||
prosthesisRows.forEach((row, index) => {
|
prosthesisRows.forEach((row, index) => {
|
||||||
const color = prosthesisTypeColor(row.prosthesisTypeCode, index);
|
const color = prosthesisCatalog?.length
|
||||||
|
? prosthesisTypeColorFromCatalog(row.prosthesisTypeCode, prosthesisCatalog)
|
||||||
|
: prosthesisTypeColor(row.prosthesisTypeCode, index);
|
||||||
for (const tooth of row.teeth) {
|
for (const tooth of row.teeth) {
|
||||||
colors[tooth as FdiToothId] = color;
|
colors[tooth as FdiToothId] = color;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return colors;
|
return colors;
|
||||||
}, [prosthesisRows]);
|
}, [prosthesisRows, prosthesisCatalog]);
|
||||||
|
|
||||||
if (selected.size === 0) return null;
|
if (selected.size === 0) return null;
|
||||||
|
|
||||||
|
|||||||
54
frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx
Normal file
54
frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
|
import type { CaseTaskGroup } from '@/components/lab/taskListGrouping';
|
||||||
|
import { countCaseTaskProgress } from '@/components/lab/taskListGrouping';
|
||||||
|
|
||||||
|
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||||
|
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskCaseGroupHeaderProps {
|
||||||
|
caseGroup: CaseTaskGroup;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderProps) {
|
||||||
|
const t = useTranslations('tasks');
|
||||||
|
const progress = countCaseTaskProgress(caseGroup);
|
||||||
|
|
||||||
|
const sentLabel = caseGroup.caseSentAt
|
||||||
|
? new Intl.DateTimeFormat(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(new Date(caseGroup.caseSentAt))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2.5 bg-background-secondary/60 border-b border-border/80">
|
||||||
|
<div className="min-w-0 space-y-0.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<p className="text-sm font-semibold text-text-primary truncate">
|
||||||
|
{t('fromClinic', { name: caseGroup.clinic.name })} ·{' '}
|
||||||
|
{formatPatientName(caseGroup.patient)}
|
||||||
|
</p>
|
||||||
|
{caseGroup.isImportant ? (
|
||||||
|
<Badge variant="warning" fixedWidth={false} className="text-[10px]">
|
||||||
|
{t('importantBadge')}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{sentLabel ? (
|
||||||
|
<p className="text-[11px] text-text-muted">
|
||||||
|
{t('caseReceivedAt', { date: sentLabel })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-muted tabular-nums shrink-0">
|
||||||
|
{t('caseTaskProgress', { completed: progress.completed, total: progress.total })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx
Normal file
36
frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
|
import { formatToothList, prosthesisTypeBadgeStyleFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
|
||||||
|
import type { ProsthesisTaskGroup } from '@/components/lab/taskListGrouping';
|
||||||
|
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
|
interface TaskProsthesisGroupHeaderProps {
|
||||||
|
group: ProsthesisTaskGroup;
|
||||||
|
prosthesisCatalog: readonly ProsthesisCatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskProsthesisGroupHeader({
|
||||||
|
group,
|
||||||
|
prosthesisCatalog,
|
||||||
|
}: TaskProsthesisGroupHeaderProps) {
|
||||||
|
const t = useTranslations('tasks');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 px-3 py-1.5 bg-background-secondary/30 border-b border-border/40">
|
||||||
|
<Badge
|
||||||
|
fixedWidth={false}
|
||||||
|
truncate
|
||||||
|
title={group.prosthesisTypeLabel}
|
||||||
|
style={prosthesisTypeBadgeStyleFromCatalog(group.prosthesisTypeCode, prosthesisCatalog)}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
{group.prosthesisTypeLabel}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-text-secondary">
|
||||||
|
{t('teethLabel', { teeth: formatToothList(group.teeth) })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
frontend/src/components/ui/lab/TaskRow.tsx
Normal file
174
frontend/src/components/ui/lab/TaskRow.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { MessageSquare } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
|
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||||
|
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||||
|
import {
|
||||||
|
labTaskStatusSelectStyle,
|
||||||
|
labTaskStatusVariant,
|
||||||
|
} from '@/components/lab/labTaskStatusDisplay';
|
||||||
|
import {
|
||||||
|
formatToothList,
|
||||||
|
prosthesisTypeBadgeStyleFromCatalog,
|
||||||
|
} from '@/components/treatment/prosthesisTypeDisplay';
|
||||||
|
import { tasksApi } from '@/lib/api/tasks';
|
||||||
|
import type { LabTaskListItem, LabTaskStatus } from '@/types/cases';
|
||||||
|
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
|
interface TaskRowProps {
|
||||||
|
task: LabTaskListItem;
|
||||||
|
locale: string;
|
||||||
|
flatMode: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
statusOptions: { value: LabTaskStatus; label: string }[];
|
||||||
|
updatingTaskId: string | null;
|
||||||
|
commentsOpen: boolean;
|
||||||
|
prosthesisCatalog: readonly ProsthesisCatalogEntry[];
|
||||||
|
onStatusUpdate: (taskId: string, status: LabTaskStatus) => void;
|
||||||
|
onToggleComments: (taskId: string) => void;
|
||||||
|
onCommentError: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||||
|
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskRow({
|
||||||
|
task,
|
||||||
|
locale,
|
||||||
|
flatMode,
|
||||||
|
canEdit,
|
||||||
|
statusOptions,
|
||||||
|
updatingTaskId,
|
||||||
|
commentsOpen,
|
||||||
|
prosthesisCatalog,
|
||||||
|
onStatusUpdate,
|
||||||
|
onToggleComments,
|
||||||
|
onCommentError,
|
||||||
|
}: TaskRowProps) {
|
||||||
|
const t = useTranslations('tasks');
|
||||||
|
const taskDate = new Intl.DateTimeFormat(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(new Date(task.createdAt));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={flatMode ? undefined : 'border-b border-border/40 last:border-b-0'}>
|
||||||
|
<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 ${
|
||||||
|
flatMode ? '' : 'ps-5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<p className="text-sm font-medium text-text-primary">
|
||||||
|
{task.stepOrder}. {task.stepLabel}
|
||||||
|
</p>
|
||||||
|
{flatMode && task.isImportant ? (
|
||||||
|
<Badge variant="warning" fixedWidth={false}>
|
||||||
|
{t('importantBadge')}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{flatMode ? (
|
||||||
|
<p className="text-[11px] text-text-secondary truncate">
|
||||||
|
{t('fromClinic', { name: task.clinic.name })} · {formatPatientName(task.patient)}{' '}
|
||||||
|
· {t('teethLabel', { teeth: formatToothList(task.teeth) })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="text-[11px] text-text-muted truncate">
|
||||||
|
<span>{t('taskDate', { date: taskDate })}</span>
|
||||||
|
{task.lastStatusChangedBy ? (
|
||||||
|
<>
|
||||||
|
<span aria-hidden> · </span>
|
||||||
|
<span>{t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex sm:justify-center">
|
||||||
|
{canEdit ? (
|
||||||
|
<select
|
||||||
|
value={task.status}
|
||||||
|
disabled={updatingTaskId === task.id}
|
||||||
|
onChange={(e) => onStatusUpdate(task.id, e.target.value as LabTaskStatus)}
|
||||||
|
className={`${FORM_SELECT_CLASS} w-full sm:max-w-[132px] font-medium`}
|
||||||
|
style={labTaskStatusSelectStyle(task.status)}
|
||||||
|
>
|
||||||
|
{statusOptions.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
|
||||||
|
{statusOptions.find((opt) => opt.value === task.status)?.label ?? task.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0 justify-between sm:justify-end">
|
||||||
|
{canEdit ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleComments(task.id)}
|
||||||
|
className={`p-1.5 rounded border ${
|
||||||
|
commentsOpen
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border text-text-muted hover:border-primary/40'
|
||||||
|
}`}
|
||||||
|
title={t('commentsButton')}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{flatMode ? (
|
||||||
|
<Badge
|
||||||
|
fixedWidth={false}
|
||||||
|
truncate
|
||||||
|
title={task.prosthesisTypeLabel}
|
||||||
|
style={prosthesisTypeBadgeStyleFromCatalog(
|
||||||
|
task.prosthesisTypeCode,
|
||||||
|
prosthesisCatalog,
|
||||||
|
)}
|
||||||
|
className="w-full max-w-[8rem] sm:w-[7rem]"
|
||||||
|
>
|
||||||
|
{task.prosthesisTypeLabel}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{commentsOpen && canEdit ? (
|
||||||
|
<div className="px-3 pb-3 border-t border-border/50">
|
||||||
|
<LabCaseCommentsPanel
|
||||||
|
caseId={task.labCaseId}
|
||||||
|
canPost
|
||||||
|
canToggleVisibility
|
||||||
|
loadComments={async () => {
|
||||||
|
const r = await tasksApi.listComments(task.labCaseId);
|
||||||
|
return r.data;
|
||||||
|
}}
|
||||||
|
onPost={async (body, visibleToClinic) => {
|
||||||
|
const r = await tasksApi.addComment(task.labCaseId, {
|
||||||
|
body,
|
||||||
|
visibleToClinic,
|
||||||
|
});
|
||||||
|
return r.data;
|
||||||
|
}}
|
||||||
|
onToggleVisibility={async (commentId, visible) => {
|
||||||
|
const r = await tasksApi.setCommentVisibility(commentId, visible);
|
||||||
|
return r.data;
|
||||||
|
}}
|
||||||
|
onError={onCommentError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,39 +2,31 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { MessageSquare } from 'lucide-react';
|
|
||||||
import { Badge } from '@/components/ui/shared/Badge';
|
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
import { TaskCaseGroupHeader } from '@/components/ui/lab/TaskCaseGroupHeader';
|
||||||
import {
|
import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGroupHeader';
|
||||||
labTaskStatusSelectStyle,
|
import { TaskRow } from '@/components/ui/lab/TaskRow';
|
||||||
labTaskStatusVariant,
|
import { groupTasksForDisplay } from '@/components/lab/taskListGrouping';
|
||||||
} from '@/components/lab/labTaskStatusDisplay';
|
|
||||||
import {
|
|
||||||
formatToothList,
|
|
||||||
prosthesisTypeBadgeStyle,
|
|
||||||
} from '@/components/treatment/prosthesisTypeDisplay';
|
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
|
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||||
import { tasksApi } from '@/lib/api/tasks';
|
import { tasksApi } from '@/lib/api/tasks';
|
||||||
import type {
|
import type {
|
||||||
LabTaskListItem,
|
LabTaskListItem,
|
||||||
LabTaskStatus,
|
LabTaskStatus,
|
||||||
ListLabTasksParams,
|
ListLabTasksParams,
|
||||||
PaginatedLabTasks,
|
PaginatedLabTasks,
|
||||||
|
TaskFilterOptions,
|
||||||
TaskSortField,
|
TaskSortField,
|
||||||
} from '@/types/cases';
|
} from '@/types/cases';
|
||||||
|
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
|
||||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TasksPage() {
|
export function TasksPage() {
|
||||||
const t = useTranslations('tasks');
|
const t = useTranslations('tasks');
|
||||||
const tErrors = useTranslations('errors');
|
const tErrors = useTranslations('errors');
|
||||||
@@ -48,6 +40,11 @@ export function TasksPage() {
|
|||||||
total: 0,
|
total: 0,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
});
|
});
|
||||||
|
const [filterOptions, setFilterOptions] = useState<TaskFilterOptions>({
|
||||||
|
clinics: [],
|
||||||
|
workflowSteps: [],
|
||||||
|
});
|
||||||
|
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||||
@@ -56,8 +53,7 @@ export function TasksPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [clinicId, setClinicId] = useState('');
|
const [clinicId, setClinicId] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS');
|
const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS');
|
||||||
const [sentFrom, setSentFrom] = useState('');
|
const [stepCompleted, setStepCompleted] = useState('');
|
||||||
const [sentTo, setSentTo] = useState('');
|
|
||||||
const [sortBy, setSortBy] = useState<TaskSortField>('date');
|
const [sortBy, setSortBy] = useState<TaskSortField>('date');
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
@@ -86,18 +82,16 @@ export function TasksPage() {
|
|||||||
if (search.trim()) params.q = search.trim();
|
if (search.trim()) params.q = search.trim();
|
||||||
if (clinicId) params.clinicOrganizationId = clinicId;
|
if (clinicId) params.clinicOrganizationId = clinicId;
|
||||||
if (statusFilter) params.status = statusFilter;
|
if (statusFilter) params.status = statusFilter;
|
||||||
if (sentFrom) params.sentFrom = sentFrom;
|
if (stepCompleted) params.stepCompleted = stepCompleted;
|
||||||
if (sentTo) params.sentTo = sentTo;
|
|
||||||
return params;
|
return params;
|
||||||
}, [page, search, clinicId, statusFilter, sentFrom, sentTo, sortBy, sortDir]);
|
}, [page, search, clinicId, statusFilter, stepCompleted, sortBy, sortDir]);
|
||||||
|
|
||||||
const clinicOptions = useMemo(() => {
|
const displayModel = useMemo(
|
||||||
const map = new Map<string, string>();
|
() => groupTasksForDisplay(tasks, sortBy),
|
||||||
for (const task of tasks) {
|
[tasks, sortBy],
|
||||||
map.set(task.clinic.id, task.clinic.name);
|
);
|
||||||
}
|
|
||||||
return [...map.entries()].map(([id, name]) => ({ id, name }));
|
const groupingDisabled = sortBy !== 'date';
|
||||||
}, [tasks]);
|
|
||||||
|
|
||||||
const loadTasks = useCallback(async () => {
|
const loadTasks = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -111,7 +105,7 @@ export function TasksPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [listParams, showError, setError]);
|
}, [listParams, showError, setError, tErrors]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canView) return;
|
if (!canView) return;
|
||||||
@@ -119,7 +113,24 @@ export function TasksPage() {
|
|||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [canView, loadTasks, search]);
|
}, [canView, loadTasks, search]);
|
||||||
|
|
||||||
async function handleStatusUpdate(taskId: string, status: LabTaskStatus) {
|
useEffect(() => {
|
||||||
|
if (!canView) return;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const [optionsRes, catalogRes] = await Promise.all([
|
||||||
|
tasksApi.filterOptions(),
|
||||||
|
prosthesisCatalogApi.list(),
|
||||||
|
]);
|
||||||
|
setFilterOptions(optionsRes.data);
|
||||||
|
setProsthesisCatalog(catalogRes.data);
|
||||||
|
} catch {
|
||||||
|
// Non-blocking — filters fall back to empty options.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [canView]);
|
||||||
|
|
||||||
|
const handleStatusUpdate = useCallback(
|
||||||
|
async (taskId: string, status: LabTaskStatus) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
setUpdatingTaskId(taskId);
|
setUpdatingTaskId(taskId);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -131,18 +142,29 @@ export function TasksPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setUpdatingTaskId(null);
|
setUpdatingTaskId(null);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[canEdit, loadTasks, setError, showError, t, tErrors],
|
||||||
function formatTaskDate(value: string) {
|
);
|
||||||
return new Intl.DateTimeFormat(locale, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`;
|
const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`;
|
||||||
|
|
||||||
|
const sortHintKey = useMemo(() => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'clinic':
|
||||||
|
return 'groupingOffClinic';
|
||||||
|
case 'patient':
|
||||||
|
return 'groupingOffPatient';
|
||||||
|
case 'prosthesis':
|
||||||
|
return 'groupingOffProsthesis';
|
||||||
|
case 'taskType':
|
||||||
|
return 'groupingOffTaskType';
|
||||||
|
case 'status':
|
||||||
|
return 'groupingOffStatus';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [sortBy]);
|
||||||
|
|
||||||
if (!isAuthReady) {
|
if (!isAuthReady) {
|
||||||
return <div className="text-sm text-text-muted">{t('loading')}</div>;
|
return <div className="text-sm text-text-muted">{t('loading')}</div>;
|
||||||
}
|
}
|
||||||
@@ -173,7 +195,7 @@ export function TasksPage() {
|
|||||||
}}
|
}}
|
||||||
placeholder={t('searchPlaceholder')}
|
placeholder={t('searchPlaceholder')}
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
|
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
|
||||||
<select
|
<select
|
||||||
@@ -185,7 +207,7 @@ export function TasksPage() {
|
|||||||
className={filterSelectClass}
|
className={filterSelectClass}
|
||||||
>
|
>
|
||||||
<option value="">{t('filterClinicAll')}</option>
|
<option value="">{t('filterClinicAll')}</option>
|
||||||
{clinicOptions.map((c) => (
|
{filterOptions.clinics.map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{c.name}
|
{c.name}
|
||||||
</option>
|
</option>
|
||||||
@@ -210,6 +232,24 @@ export function TasksPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-text-muted">{t('filterStepCompleted')}</span>
|
||||||
|
<select
|
||||||
|
value={stepCompleted}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStepCompleted(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={filterSelectClass}
|
||||||
|
>
|
||||||
|
<option value="">{t('filterStepCompletedAll')}</option>
|
||||||
|
{filterOptions.workflowSteps.map((step) => (
|
||||||
|
<option key={step.code} value={step.code}>
|
||||||
|
{step.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className="text-xs text-text-muted">{t('sortBy')}</span>
|
<span className="text-xs text-text-muted">{t('sortBy')}</span>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
@@ -236,6 +276,9 @@ export function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{groupingDisabled && sortHintKey ? (
|
||||||
|
<p className="text-[11px] text-text-muted">{t(sortHintKey)}</p>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="surface-card min-h-[280px]">
|
<section className="surface-card min-h-[280px]">
|
||||||
@@ -243,125 +286,65 @@ export function TasksPage() {
|
|||||||
<p className="p-3 text-sm text-text-muted">{t('loading')}</p>
|
<p className="p-3 text-sm text-text-muted">{t('loading')}</p>
|
||||||
) : tasks.length === 0 ? (
|
) : tasks.length === 0 ? (
|
||||||
<p className="p-3 text-sm text-text-muted">{t('emptyList')}</p>
|
<p className="p-3 text-sm text-text-muted">{t('emptyList')}</p>
|
||||||
|
) : displayModel.mode === 'grouped' ? (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{displayModel.cases.map((caseGroup) => (
|
||||||
|
<section key={caseGroup.labCaseId} className="border-b border-border last:border-b-0">
|
||||||
|
<TaskCaseGroupHeader caseGroup={caseGroup} locale={locale} />
|
||||||
|
{caseGroup.prosthesisGroups.map((prosthesisGroup) => (
|
||||||
|
<div
|
||||||
|
key={prosthesisGroup.key}
|
||||||
|
className="border-t border-border/50 first:border-t-0"
|
||||||
|
>
|
||||||
|
<TaskProsthesisGroupHeader
|
||||||
|
group={prosthesisGroup}
|
||||||
|
prosthesisCatalog={prosthesisCatalog}
|
||||||
|
/>
|
||||||
|
<ul>
|
||||||
|
{prosthesisGroup.tasks.map((task) => (
|
||||||
|
<TaskRow
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
locale={locale}
|
||||||
|
flatMode={false}
|
||||||
|
canEdit={canEdit}
|
||||||
|
statusOptions={statusOptions}
|
||||||
|
updatingTaskId={updatingTaskId}
|
||||||
|
commentsOpen={expandedCommentsTaskId === task.id}
|
||||||
|
prosthesisCatalog={prosthesisCatalog}
|
||||||
|
onStatusUpdate={(id, status) => void handleStatusUpdate(id, status)}
|
||||||
|
onToggleComments={(id) =>
|
||||||
|
setExpandedCommentsTaskId((prev) => (prev === id ? null : id))
|
||||||
|
}
|
||||||
|
onCommentError={showError}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border">
|
<ul className="divide-y divide-border">
|
||||||
{tasks.map((task, index) => {
|
{displayModel.tasks.map((task) => (
|
||||||
const commentsOpen = expandedCommentsTaskId === task.id;
|
<TaskRow
|
||||||
|
key={task.id}
|
||||||
return (
|
task={task}
|
||||||
<li key={task.id}>
|
locale={locale}
|
||||||
<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">
|
flatMode
|
||||||
<div className="min-w-0">
|
canEdit={canEdit}
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
statusOptions={statusOptions}
|
||||||
<p className="text-sm font-medium text-text-primary">
|
updatingTaskId={updatingTaskId}
|
||||||
{task.stepOrder}. {task.stepLabel}
|
commentsOpen={expandedCommentsTaskId === task.id}
|
||||||
</p>
|
prosthesisCatalog={prosthesisCatalog}
|
||||||
{task.isImportant ? (
|
onStatusUpdate={(id, status) => void handleStatusUpdate(id, status)}
|
||||||
<Badge variant="warning" fixedWidth={false}>
|
onToggleComments={(id) =>
|
||||||
{t('importantBadge')}
|
setExpandedCommentsTaskId((prev) => (prev === id ? null : id))
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-text-secondary truncate">
|
|
||||||
{t('fromClinic', { name: task.clinic.name })} ·{' '}
|
|
||||||
{formatPatientName(task.patient)} ·{' '}
|
|
||||||
{t('teethLabel', { teeth: formatToothList(task.teeth) })}
|
|
||||||
</p>
|
|
||||||
<p className="text-[11px] text-text-muted truncate">
|
|
||||||
<span>{t('taskDate', { date: formatTaskDate(task.createdAt) })}</span>
|
|
||||||
{task.lastStatusChangedBy ? (
|
|
||||||
<>
|
|
||||||
<span aria-hidden> · </span>
|
|
||||||
<span>
|
|
||||||
{t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex sm:justify-center">
|
|
||||||
{canEdit ? (
|
|
||||||
<select
|
|
||||||
value={task.status}
|
|
||||||
disabled={updatingTaskId === task.id}
|
|
||||||
onChange={(e) =>
|
|
||||||
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
|
|
||||||
}
|
}
|
||||||
className={`${FORM_SELECT_CLASS} w-full sm:max-w-[132px] font-medium`}
|
onCommentError={showError}
|
||||||
style={labTaskStatusSelectStyle(task.status)}
|
|
||||||
>
|
|
||||||
{statusOptions.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
|
|
||||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
|
||||||
task.status}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0 justify-between sm:justify-end">
|
|
||||||
{canEdit ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setExpandedCommentsTaskId(commentsOpen ? null : task.id)
|
|
||||||
}
|
|
||||||
className={`p-1.5 rounded border ${
|
|
||||||
commentsOpen
|
|
||||||
? 'border-primary bg-primary/10 text-primary'
|
|
||||||
: 'border-border text-text-muted hover:border-primary/40'
|
|
||||||
}`}
|
|
||||||
title={t('commentsButton')}
|
|
||||||
>
|
|
||||||
<MessageSquare className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<Badge
|
|
||||||
fixedWidth={false}
|
|
||||||
truncate
|
|
||||||
title={task.prosthesisTypeLabel}
|
|
||||||
style={prosthesisTypeBadgeStyle(task.prosthesisTypeCode, index)}
|
|
||||||
className="w-full max-w-[8rem] sm:w-[7rem]"
|
|
||||||
>
|
|
||||||
{task.prosthesisTypeLabel}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{commentsOpen && canEdit ? (
|
|
||||||
<div className="px-3 pb-3 border-t border-border/50">
|
|
||||||
<LabCaseCommentsPanel
|
|
||||||
caseId={task.labCaseId}
|
|
||||||
canPost
|
|
||||||
canToggleVisibility
|
|
||||||
loadComments={async () => {
|
|
||||||
const r = await tasksApi.listComments(task.labCaseId);
|
|
||||||
return r.data;
|
|
||||||
}}
|
|
||||||
onPost={async (body, visibleToClinic) => {
|
|
||||||
const r = await tasksApi.addComment(task.labCaseId, {
|
|
||||||
body,
|
|
||||||
visibleToClinic,
|
|
||||||
});
|
|
||||||
return r.data;
|
|
||||||
}}
|
|
||||||
onToggleVisibility={async (commentId, visible) => {
|
|
||||||
const r = await tasksApi.setCommentVisibility(commentId, visible);
|
|
||||||
return r.data;
|
|
||||||
}}
|
|
||||||
onError={showError}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
))}
|
||||||
) : null}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -395,7 +378,6 @@ export function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import {
|
import {
|
||||||
@@ -43,8 +43,10 @@ import {
|
|||||||
type TodayDashboardCell,
|
type TodayDashboardCell,
|
||||||
} from '@/components/today/today-dashboard-layout';
|
} from '@/components/today/today-dashboard-layout';
|
||||||
import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry';
|
import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry';
|
||||||
import { prosthesisTypeColor } from '@/components/treatment/prosthesisTypeDisplay';
|
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
|
||||||
|
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||||
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
|
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
|
||||||
|
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type {
|
import type {
|
||||||
TodayCompletionGauge,
|
TodayCompletionGauge,
|
||||||
TodaySubscriptionSnapshot,
|
TodaySubscriptionSnapshot,
|
||||||
@@ -77,6 +79,15 @@ export function TodayDashboard({
|
|||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
const orgType = currentOrganization?.type;
|
const orgType = currentOrganization?.type;
|
||||||
const isOwner = Boolean(currentOrganization?.isOwner);
|
const isOwner = Boolean(currentOrganization?.isOwner);
|
||||||
|
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (orgType !== 'LAB') return;
|
||||||
|
void prosthesisCatalogApi
|
||||||
|
.list()
|
||||||
|
.then((response) => setProsthesisCatalog(response.data))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [orgType]);
|
||||||
|
|
||||||
const showUpcoming =
|
const showUpcoming =
|
||||||
orgType === 'CLINIC' &&
|
orgType === 'CLINIC' &&
|
||||||
@@ -159,6 +170,7 @@ export function TodayDashboard({
|
|||||||
orgType,
|
orgType,
|
||||||
isOwner,
|
isOwner,
|
||||||
currentOrganization,
|
currentOrganization,
|
||||||
|
prosthesisCatalog,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
@@ -177,6 +189,7 @@ export function TodayDashboard({
|
|||||||
actions,
|
actions,
|
||||||
subscription,
|
subscription,
|
||||||
currentOrganization,
|
currentOrganization,
|
||||||
|
prosthesisCatalog,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (hasError && !loading && cells.length === 0) {
|
if (hasError && !loading && cells.length === 0) {
|
||||||
@@ -297,6 +310,7 @@ function buildDashboardCells(options: {
|
|||||||
orgType?: 'CLINIC' | 'LAB';
|
orgType?: 'CLINIC' | 'LAB';
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
currentOrganization: ReturnType<typeof useAuth>['currentOrganization'];
|
currentOrganization: ReturnType<typeof useAuth>['currentOrganization'];
|
||||||
|
prosthesisCatalog: ProsthesisCatalogEntry[];
|
||||||
}): TodayDashboardCell[] {
|
}): TodayDashboardCell[] {
|
||||||
const cells: TodayDashboardCell[] = [];
|
const cells: TodayDashboardCell[] = [];
|
||||||
|
|
||||||
@@ -318,6 +332,7 @@ function buildDashboardCells(options: {
|
|||||||
(options.orgType === 'LAB' &&
|
(options.orgType === 'LAB' &&
|
||||||
canEditCases(options.currentOrganization))),
|
canEditCases(options.currentOrganization))),
|
||||||
dayLabelFormatter: options.dayLabelFormatter,
|
dayLabelFormatter: options.dayLabelFormatter,
|
||||||
|
prosthesisCatalog: options.prosthesisCatalog,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -405,6 +420,7 @@ function buildChartCells(options: {
|
|||||||
showMyAppointmentsWeekChart: boolean;
|
showMyAppointmentsWeekChart: boolean;
|
||||||
showCasePartnersChart: boolean;
|
showCasePartnersChart: boolean;
|
||||||
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
|
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
|
||||||
|
prosthesisCatalog: ProsthesisCatalogEntry[];
|
||||||
}): TodayDashboardCell[] {
|
}): TodayDashboardCell[] {
|
||||||
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options;
|
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options;
|
||||||
const cells: TodayDashboardCell[] = [];
|
const cells: TodayDashboardCell[] = [];
|
||||||
@@ -580,7 +596,7 @@ function buildChartCells(options: {
|
|||||||
>
|
>
|
||||||
<TodayBarChart
|
<TodayBarChart
|
||||||
data={tasksByProsthesisData}
|
data={tasksByProsthesisData}
|
||||||
colorForCode={(code, index) => prosthesisTypeColor(code, index)}
|
colorForCode={(code) => prosthesisTypeColorFromCatalog(code, options.prosthesisCatalog)}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
LabTaskStatus,
|
LabTaskStatus,
|
||||||
ListLabTasksParams,
|
ListLabTasksParams,
|
||||||
PaginatedLabTasks,
|
PaginatedLabTasks,
|
||||||
|
TaskFilterOptions,
|
||||||
} from '@/types/cases';
|
} from '@/types/cases';
|
||||||
|
|
||||||
export const tasksApi = {
|
export const tasksApi = {
|
||||||
@@ -15,6 +16,11 @@ export const tasksApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
filterOptions: async (): Promise<{ success: boolean; data: TaskFilterOptions }> => {
|
||||||
|
const response = await apiClient.get('/tasks/filter-options');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
updateStatus: async (
|
updateStatus: async (
|
||||||
taskId: string,
|
taskId: string,
|
||||||
status: LabTaskStatus,
|
status: LabTaskStatus,
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export interface ListLabTasksParams {
|
|||||||
important?: boolean;
|
important?: boolean;
|
||||||
sentFrom?: string;
|
sentFrom?: string;
|
||||||
sentTo?: string;
|
sentTo?: string;
|
||||||
|
stepCompleted?: string;
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
sortDir?: 'asc' | 'desc';
|
sortDir?: 'asc' | 'desc';
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -172,10 +173,16 @@ export interface LabTaskListItem {
|
|||||||
lastStatusChangedAt: string | null;
|
lastStatusChangedAt: string | null;
|
||||||
lastStatusChangedBy: LabTaskUser | null;
|
lastStatusChangedBy: LabTaskUser | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
caseSentAt: string | null;
|
||||||
clinic: { id: string; name: string };
|
clinic: { id: string; name: string };
|
||||||
patient: { id: string; firstName: string; lastName: string };
|
patient: { id: string; firstName: string; lastName: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskFilterOptions {
|
||||||
|
clinics: { id: string; name: string }[];
|
||||||
|
workflowSteps: { code: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedLabTasks {
|
export interface PaginatedLabTasks {
|
||||||
items: LabTaskListItem[];
|
items: LabTaskListItem[];
|
||||||
pagination: {
|
pagination: {
|
||||||
|
|||||||
Reference in New Issue
Block a user