From 4e6ed7584459adef6c7bddf784cf46f29a56731c Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 13 Jul 2026 02:09:49 +0330 Subject: [PATCH] improvement: tasks feature UX fully overhauled. --- .cursor/rules/lab-tasks.mdc | 14 + .cursor/skills/lab-tasks/SKILL.md | 55 +++ AGENTS.md | 3 + backend/src/modules/tasks/dto/tasks.dto.ts | 5 + backend/src/modules/tasks/tasks.controller.ts | 11 + backend/src/modules/tasks/tasks.module.ts | 2 + backend/src/modules/tasks/tasks.service.ts | 136 ++++++-- frontend/messages/en.json | 9 + frontend/messages/fa.json | 9 + frontend/messages/nl.json | 9 + .../src/components/lab/taskListGrouping.ts | 89 +++++ .../components/shared/catalog-type-colors.ts | 18 +- .../treatment/prosthesisTypeDisplay.ts | 33 ++ .../src/components/ui/lab/CaseDetailPanel.tsx | 22 +- .../components/ui/lab/CaseToothChartPanel.tsx | 11 +- .../components/ui/lab/TaskCaseGroupHeader.tsx | 54 +++ .../ui/lab/TaskProsthesisGroupHeader.tsx | 36 ++ frontend/src/components/ui/lab/TaskRow.tsx | 174 ++++++++++ frontend/src/components/ui/lab/TasksPage.tsx | 318 +++++++++--------- .../components/ui/today/TodayDashboard.tsx | 22 +- frontend/src/lib/api/tasks.ts | 6 + frontend/src/types/cases.ts | 7 + 22 files changed, 833 insertions(+), 210 deletions(-) create mode 100644 .cursor/rules/lab-tasks.mdc create mode 100644 .cursor/skills/lab-tasks/SKILL.md create mode 100644 frontend/src/components/lab/taskListGrouping.ts create mode 100644 frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx create mode 100644 frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx create mode 100644 frontend/src/components/ui/lab/TaskRow.tsx diff --git a/.cursor/rules/lab-tasks.mdc b/.cursor/rules/lab-tasks.mdc new file mode 100644 index 0000000..7049047 --- /dev/null +++ b/.cursor/rules/lab-tasks.mdc @@ -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` diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md new file mode 100644 index 0000000..92a7d4a --- /dev/null +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 62e2675..f21ba25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - **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 ``` @@ -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/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/api-errors/` | New backend errors + frontend translations | diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts index c1ffe75..f93dc12 100644 --- a/backend/src/modules/tasks/dto/tasks.dto.ts +++ b/backend/src/modules/tasks/dto/tasks.dto.ts @@ -65,6 +65,11 @@ export class ListLabTasksDto { @IsDateString() sentTo?: string; + /** Workflow step code (e.g. design) completed within the prosthesis group. */ + @IsOptional() + @IsString() + stepCompleted?: string; + @IsOptional() @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType']) sortBy?: TaskSortField; diff --git a/backend/src/modules/tasks/tasks.controller.ts b/backend/src/modules/tasks/tasks.controller.ts index 279d86c..7ea7788 100644 --- a/backend/src/modules/tasks/tasks.controller.ts +++ b/backend/src/modules/tasks/tasks.controller.ts @@ -19,6 +19,17 @@ export class TasksController { 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') @ApiOperation({ summary: 'Update task status' }) updateStatus( diff --git a/backend/src/modules/tasks/tasks.module.ts b/backend/src/modules/tasks/tasks.module.ts index 76ba304..d65d0b7 100644 --- a/backend/src/modules/tasks/tasks.module.ts +++ b/backend/src/modules/tasks/tasks.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { CatalogModule } from '../catalog/catalog.module'; import { TasksController } from './tasks.controller'; import { TasksService } from './tasks.service'; @Module({ + imports: [CatalogModule], controllers: [TasksController], providers: [TasksService], }) diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 5b76b17..7a5c8b8 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -55,7 +55,7 @@ export class TasksService { const limit = Math.min(Math.max(query.limit ?? 50, 1), 100); const skip = (page - 1) * limit; - const where = this.buildListWhere(labOrganizationId, query); + const where = await this.buildListWhere(labOrganizationId, query); const [items, total] = await Promise.all([ this.prisma.labCaseTask.findMany({ @@ -149,10 +149,60 @@ export class TasksService { 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(); + 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, query: ListLabTasksDto, - ): Prisma.LabCaseTaskWhereInput { + ): Promise { const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null }; if (query.sentFrom) { @@ -181,18 +231,47 @@ export class TasksService { status = LabTaskStatus.IN_PROGRESS; } - return { - labCase: { - sentAt: sentAtFilter, - sends: { some: { organizationId: labOrganizationId } }, - ...(query.clinicOrganizationId - ? { treatment: { organizationId: query.clinicOrganizationId } } - : {}), - ...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}), - ...(query.important !== undefined ? { isImportant: query.important } : {}), - }, + const labCaseScope: Prisma.LabCaseWhereInput = { + sentAt: sentAtFilter, + sends: { some: { organizationId: labOrganizationId } }, + ...(query.clinicOrganizationId + ? { treatment: { organizationId: query.clinicOrganizationId } } + : {}), + ...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}), + ...(query.important !== undefined ? { isImportant: query.important } : {}), + }; + + const base: Prisma.LabCaseTaskWhereInput = { + labCase: labCaseScope, ...(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 { @@ -214,40 +293,50 @@ export class TasksService { private buildOrderBy(query: ListLabTasksDto): Prisma.LabCaseTaskOrderByWithRelationInput[] { const dir = query.sortDir ?? 'desc'; + const stepTiebreakers: Prisma.LabCaseTaskOrderByWithRelationInput[] = [ + { stepOrder: 'asc' }, + { id: 'asc' }, + ]; + switch (query.sortBy) { case 'status': - return [{ status: dir }, { createdAt: 'desc' }, { id: 'asc' }]; + return [{ status: dir }, { createdAt: 'desc' }, ...stepTiebreakers]; case 'clinic': return [ { labCase: { treatment: { organization: { name: dir } } } }, { createdAt: 'desc' }, - { id: 'asc' }, + ...stepTiebreakers, ]; case 'patient': return [ { labCase: { treatment: { patient: { lastName: dir } } } }, { labCase: { treatment: { patient: { firstName: dir } } } }, - { id: 'asc' }, + ...stepTiebreakers, ]; case 'important': - return [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, { id: 'asc' }]; + return [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, ...stepTiebreakers]; case 'prosthesis': - return [{ prosthesisTypeCode: dir }, { createdAt: 'desc' }, { id: 'asc' }]; + return [ + { prosthesisTypeCode: dir }, + { createdAt: 'desc' }, + ...stepTiebreakers, + ]; case 'taskType': return [ { workflowStepCode: dir }, { stepOrder: 'asc' }, { createdAt: 'desc' }, - { id: 'asc' }, + ...stepTiebreakers, ]; case 'date': default: - // date / caseId / taskId / stepId — newest first by default. return [ { labCase: { sentAt: dir } }, - { labCaseId: dir }, - { id: dir }, - { stepOrder: dir }, + { labCaseId: 'asc' }, + { treatmentDetailId: 'asc' }, + { prosthesisTypeCode: 'asc' }, + { stepOrder: 'asc' }, + { id: 'asc' }, ]; } } @@ -275,6 +364,7 @@ export class TasksService { ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name } : null, createdAt: task.createdAt.toISOString(), + caseSentAt: task.labCase.sentAt?.toISOString() ?? null, clinic: task.labCase.treatment.organization, patient: { id: task.labCase.treatment.patient.id, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index fc13a73..7b9abb9 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -479,6 +479,8 @@ "filterClinicAll": "All clinics", "filterStatus": "Status", "filterStatusAll": "All statuses", + "filterStepCompleted": "Step completed", + "filterStepCompletedAll": "Any step", "showCompleted": "Show completed", "importantOnly": "Important only", "filterSentFrom": "From", @@ -492,6 +494,13 @@ "sortProsthesis": "Prosthesis type", "sortTaskType": "Task type", "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", "commentsButton": "Comments", "errorLoadList": "Failed to load tasks.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index c9ed74f..4710503 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -480,6 +480,8 @@ "filterClinicAll": "همه کلینیک‌ها", "filterStatus": "وضعیت", "filterStatusAll": "همه وضعیت‌ها", + "filterStepCompleted": "مرحله تکمیل‌شده", + "filterStepCompletedAll": "هر مرحله‌ای", "showCompleted": "نمایش تکمیل‌شده‌ها", "importantOnly": "فقط مهم‌ها", "filterSentFrom": "از", @@ -493,6 +495,13 @@ "sortProsthesis": "نوع پروتز", "sortTaskType": "نوع کار", "sortDirection": "جهت مرتب‌سازی", + "groupingOffClinic": "مرتب‌سازی بر اساس کلینیک — گروه‌بندی پرونده غیرفعال است.", + "groupingOffPatient": "مرتب‌سازی بر اساس بیمار — گروه‌بندی پرونده غیرفعال است.", + "groupingOffProsthesis": "مرتب‌سازی بر اساس نوع پروتز — گروه‌بندی پرونده غیرفعال است.", + "groupingOffTaskType": "مرتب‌سازی بر اساس نوع کار — گروه‌بندی پرونده غیرفعال است.", + "groupingOffStatus": "مرتب‌سازی بر اساس وضعیت — گروه‌بندی پرونده غیرفعال است.", + "caseReceivedAt": "دریافت {date}", + "caseTaskProgress": "{completed}/{total} کار در این صفحه", "clearFilters": "پاک کردن فیلترها", "commentsButton": "نظرات", "errorLoadList": "بارگذاری وظایف ناموفق بود.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index f9bf5fd..4cb1339 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -480,6 +480,8 @@ "filterClinicAll": "Alle klinieken", "filterStatus": "Status", "filterStatusAll": "Alle statussen", + "filterStepCompleted": "Stap voltooid", + "filterStepCompletedAll": "Elke stap", "showCompleted": "Voltooide tonen", "importantOnly": "Alleen belangrijk", "filterSentFrom": "Vanaf", @@ -493,6 +495,13 @@ "sortProsthesis": "Prothesetype", "sortTaskType": "Taaktype", "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", "commentsButton": "Opmerkingen", "errorLoadList": "Taken laden mislukt.", diff --git a/frontend/src/components/lab/taskListGrouping.ts b/frontend/src/components/lab/taskListGrouping.ts new file mode 100644 index 0000000..14d52f0 --- /dev/null +++ b/frontend/src/components/lab/taskListGrouping.ts @@ -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(); + + 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 }; +} diff --git a/frontend/src/components/shared/catalog-type-colors.ts b/frontend/src/components/shared/catalog-type-colors.ts index 5ae0452..9f8be02 100644 --- a/frontend/src/components/shared/catalog-type-colors.ts +++ b/frontend/src/components/shared/catalog-type-colors.ts @@ -24,22 +24,22 @@ export const TREATMENT_TYPE_COLORS: Record = { export const PROSTHESIS_TYPE_COLORS: Record = { pfm_crown: '#e2e8f0', pfz_crown: '#bbf7d0', - monolithic_zirconia: '#e0f2fe', + monolithic_zirconia: '#bae6fd', glass_ceramic_crown: '#fef08a', full_metal_crown: '#d4d4d8', - temporary_resin_crown: '#bae6fd', - pmma: '#7dd3fc', - peek_crown: '#5eead4', - veneer_zirconia: '#6ee7b7', + temporary_resin_crown: '#ddd6fe', + pmma: '#fcd34d', + peek_crown: '#fda4af', + veneer_zirconia: '#86efac', veneer_ips_press: '#fed7aa', veneer_ips_cad: '#fdba74', - soft_structure: '#ddd6fe', + soft_structure: '#e9d5ff', customized_abutment: '#a5b4fc', prefabricated_abutment: '#c7d2fe', - ti_base_abutment: '#bfdbfe', + ti_base_abutment: '#93c5fd', multi_unit_abutment: '#818cf8', - zirconia_abutment: '#34d399', - screw_retained: '#e9d5ff', + zirconia_abutment: '#4ade80', + screw_retained: '#f0abfc', zirconia_overlay: '#2dd4bf', ips_overlay: '#fef3c7', smile_design: '#f9a8d4', diff --git a/frontend/src/components/treatment/prosthesisTypeDisplay.ts b/frontend/src/components/treatment/prosthesisTypeDisplay.ts index 4f46f07..0592030 100644 --- a/frontend/src/components/treatment/prosthesisTypeDisplay.ts +++ b/frontend/src/components/treatment/prosthesisTypeDisplay.ts @@ -1,4 +1,5 @@ import type { CSSProperties } from 'react'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; import { PROSTHESIS_FALLBACK_COLORS, PROSTHESIS_TYPE_COLORS, @@ -15,15 +16,40 @@ import { /** Dark ink that stays readable on every pastel in the palette. */ const BADGE_INK = '#14253d'; +/** Stable catalog index for fallback colors — use sortOrder, not list row position. */ +export function prosthesisCatalogColorIndex( + code: string, + catalog: readonly Pick[], +): 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 { return resolveCatalogTypeColor(code, PROSTHESIS_TYPE_COLORS, index, PROSTHESIS_FALLBACK_COLORS); } +export function prosthesisTypeColorFromCatalog( + code: string, + catalog: readonly Pick[], +): string { + return prosthesisTypeColor(code, prosthesisCatalogColorIndex(code, catalog)); +} + /** Filled swatch (small indicator dots). */ export function prosthesisTypeSwatchStyle(code: string, index = 0): CSSProperties { return { backgroundColor: prosthesisTypeColor(code, index), borderColor: 'rgba(0, 0, 0, 0.18)' }; } +export function prosthesisTypeSwatchStyleFromCatalog( + code: string, + catalog: readonly Pick[], +): CSSProperties { + return prosthesisTypeSwatchStyle(code, prosthesisCatalogColorIndex(code, catalog)); +} + /** Pastel pill / banner fill with readable dark text (group headers, badges). */ export function prosthesisTypeBadgeStyle(code: string, index = 0): CSSProperties { return { @@ -33,6 +59,13 @@ export function prosthesisTypeBadgeStyle(code: string, index = 0): CSSProperties }; } +export function prosthesisTypeBadgeStyleFromCatalog( + code: string, + catalog: readonly Pick[], +): CSSProperties { + return prosthesisTypeBadgeStyle(code, prosthesisCatalogColorIndex(code, catalog)); +} + export function formatToothList(teeth: string[]): string { return teeth.join(', '); } diff --git a/frontend/src/components/ui/lab/CaseDetailPanel.tsx b/frontend/src/components/ui/lab/CaseDetailPanel.tsx index 99d0ab5..5229a8d 100644 --- a/frontend/src/components/ui/lab/CaseDetailPanel.tsx +++ b/frontend/src/components/ui/lab/CaseDetailPanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useTranslations } from 'next-intl'; import { MessageSquare } from 'lucide-react'; 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 { formatToothList, - prosthesisTypeBadgeStyle, + prosthesisTypeBadgeStyleFromCatalog, } from '@/components/treatment/prosthesisTypeDisplay'; +import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import { buildCaseProsthesisRows, formatCaseDateTime, @@ -21,6 +22,7 @@ import { latestCaseAttachment, } from '@/components/lab/caseDetailUtils'; import type { LabCaseDetail, LabTaskStatus } from '@/types/cases'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; function CaseTaskProgressBar({ completed, total }: { completed: number; total: number }) { const pct = total > 0 ? Math.round((completed / total) * 100) : 0; @@ -77,6 +79,14 @@ export function CaseDetailPanel({ }: CaseDetailPanelProps) { const t = useTranslations('cases'); const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false); + const [prosthesisCatalog, setProsthesisCatalog] = useState([]); + + useEffect(() => { + void prosthesisCatalogApi + .list() + .then((response) => setProsthesisCatalog(response.data)) + .catch(() => {}); + }, []); const prosthesisRows = useMemo(() => buildCaseProsthesisRows(labCase), [labCase]); const previewAttachment = useMemo(() => latestCaseAttachment(labCase), [labCase]); @@ -154,6 +164,7 @@ export function CaseDetailPanel({ @@ -177,7 +188,7 @@ export function CaseDetailPanel({ {labCase.tasksByTooth.length === 0 ? (

{t('noTasks')}

) : ( - labCase.tasksByTooth.map((group, groupIndex) => ( + labCase.tasksByTooth.map((group) => (
{group.prosthesisTypeLabel} diff --git a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx index ea51b15..aabbd3a 100644 --- a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx +++ b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx @@ -2,7 +2,8 @@ import { useMemo } from 'react'; 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'; export interface CaseToothChartDetail { @@ -18,6 +19,7 @@ interface CaseToothChartPanelProps { details: CaseToothChartDetail[]; /** Prosthesis mapping from case tasks or toothProsthesis rows. */ prosthesisRows: CaseToothChartProsthesisRow[]; + prosthesisCatalog?: readonly ProsthesisCatalogEntry[]; scale?: number; compact?: boolean; className?: string; @@ -27,6 +29,7 @@ interface CaseToothChartPanelProps { export function CaseToothChartPanel({ details, prosthesisRows, + prosthesisCatalog, scale = 1, compact = true, className = '', @@ -42,13 +45,15 @@ export function CaseToothChartPanel({ const toothColors = useMemo(() => { const colors: Partial> = {}; 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) { colors[tooth as FdiToothId] = color; } }); return colors; - }, [prosthesisRows]); + }, [prosthesisRows, prosthesisCatalog]); if (selected.size === 0) return null; diff --git a/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx b/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx new file mode 100644 index 0000000..a235d5d --- /dev/null +++ b/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx @@ -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 ( +
+
+
+

+ {t('fromClinic', { name: caseGroup.clinic.name })} ·{' '} + {formatPatientName(caseGroup.patient)} +

+ {caseGroup.isImportant ? ( + + {t('importantBadge')} + + ) : null} +
+ {sentLabel ? ( +

+ {t('caseReceivedAt', { date: sentLabel })} +

+ ) : null} +
+

+ {t('caseTaskProgress', { completed: progress.completed, total: progress.total })} +

+
+ ); +} diff --git a/frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx b/frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx new file mode 100644 index 0000000..6198839 --- /dev/null +++ b/frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx @@ -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 ( +
+ + {group.prosthesisTypeLabel} + + + {t('teethLabel', { teeth: formatToothList(group.teeth) })} + +
+ ); +} diff --git a/frontend/src/components/ui/lab/TaskRow.tsx b/frontend/src/components/ui/lab/TaskRow.tsx new file mode 100644 index 0000000..8fc63e5 --- /dev/null +++ b/frontend/src/components/ui/lab/TaskRow.tsx @@ -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 ( +
  • +
    +
    +
    +

    + {task.stepOrder}. {task.stepLabel} +

    + {flatMode && task.isImportant ? ( + + {t('importantBadge')} + + ) : null} +
    + {flatMode ? ( +

    + {t('fromClinic', { name: task.clinic.name })} · {formatPatientName(task.patient)}{' '} + · {t('teethLabel', { teeth: formatToothList(task.teeth) })} +

    + ) : null} +

    + {t('taskDate', { date: taskDate })} + {task.lastStatusChangedBy ? ( + <> + · + {t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })} + + ) : null} +

    +
    + +
    + {canEdit ? ( + + ) : ( + + {statusOptions.find((opt) => opt.value === task.status)?.label ?? task.status} + + )} +
    + +
    + {canEdit ? ( + + ) : null} + {flatMode ? ( + + {task.prosthesisTypeLabel} + + ) : null} +
    +
    + + {commentsOpen && canEdit ? ( +
    + { + 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} + /> +
    + ) : null} +
  • + ); +} diff --git a/frontend/src/components/ui/lab/TasksPage.tsx b/frontend/src/components/ui/lab/TasksPage.tsx index 005f8f3..8aead1e 100644 --- a/frontend/src/components/ui/lab/TasksPage.tsx +++ b/frontend/src/components/ui/lab/TasksPage.tsx @@ -2,39 +2,31 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { SearchBar } from '@/components/ui/shared/SearchBar'; -import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; -import { - labTaskStatusSelectStyle, - labTaskStatusVariant, -} from '@/components/lab/labTaskStatusDisplay'; -import { - formatToothList, - prosthesisTypeBadgeStyle, -} from '@/components/treatment/prosthesisTypeDisplay'; +import { TaskCaseGroupHeader } from '@/components/ui/lab/TaskCaseGroupHeader'; +import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGroupHeader'; +import { TaskRow } from '@/components/ui/lab/TaskRow'; +import { groupTasksForDisplay } from '@/components/lab/taskListGrouping'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { canEditTasks, canViewTasks } from '@/components/shared/permissions'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; +import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import { tasksApi } from '@/lib/api/tasks'; import type { LabTaskListItem, LabTaskStatus, ListLabTasksParams, PaginatedLabTasks, + TaskFilterOptions, TaskSortField, } from '@/types/cases'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; const PAGE_SIZE = 50; -function formatPatientName(patient: { firstName: string; lastName: string }) { - return `${patient.firstName} ${patient.lastName}`.trim(); -} - export function TasksPage() { const t = useTranslations('tasks'); const tErrors = useTranslations('errors'); @@ -48,6 +40,11 @@ export function TasksPage() { total: 0, totalPages: 1, }); + const [filterOptions, setFilterOptions] = useState({ + clinics: [], + workflowSteps: [], + }); + const [prosthesisCatalog, setProsthesisCatalog] = useState([]); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [updatingTaskId, setUpdatingTaskId] = useState(null); @@ -56,8 +53,7 @@ export function TasksPage() { const [search, setSearch] = useState(''); const [clinicId, setClinicId] = useState(''); const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS'); - const [sentFrom, setSentFrom] = useState(''); - const [sentTo, setSentTo] = useState(''); + const [stepCompleted, setStepCompleted] = useState(''); const [sortBy, setSortBy] = useState('date'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); @@ -86,18 +82,16 @@ export function TasksPage() { if (search.trim()) params.q = search.trim(); if (clinicId) params.clinicOrganizationId = clinicId; if (statusFilter) params.status = statusFilter; - if (sentFrom) params.sentFrom = sentFrom; - if (sentTo) params.sentTo = sentTo; + if (stepCompleted) params.stepCompleted = stepCompleted; return params; - }, [page, search, clinicId, statusFilter, sentFrom, sentTo, sortBy, sortDir]); + }, [page, search, clinicId, statusFilter, stepCompleted, sortBy, sortDir]); - const clinicOptions = useMemo(() => { - const map = new Map(); - for (const task of tasks) { - map.set(task.clinic.id, task.clinic.name); - } - return [...map.entries()].map(([id, name]) => ({ id, name })); - }, [tasks]); + const displayModel = useMemo( + () => groupTasksForDisplay(tasks, sortBy), + [tasks, sortBy], + ); + + const groupingDisabled = sortBy !== 'date'; const loadTasks = useCallback(async () => { setLoading(true); @@ -111,7 +105,7 @@ export function TasksPage() { } finally { setLoading(false); } - }, [listParams, showError, setError]); + }, [listParams, showError, setError, tErrors]); useEffect(() => { if (!canView) return; @@ -119,30 +113,58 @@ export function TasksPage() { return () => clearTimeout(timeout); }, [canView, loadTasks, search]); - async function handleStatusUpdate(taskId: string, status: LabTaskStatus) { - if (!canEdit) return; - setUpdatingTaskId(taskId); - setError(''); - try { - await tasksApi.updateStatus(taskId, status); - await loadTasks(); - } catch (error: unknown) { - showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); - } finally { - setUpdatingTaskId(null); - } - } + 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]); - function formatTaskDate(value: string) { - return new Intl.DateTimeFormat(locale, { - year: 'numeric', - month: 'short', - day: 'numeric', - }).format(new Date(value)); - } + const handleStatusUpdate = useCallback( + async (taskId: string, status: LabTaskStatus) => { + if (!canEdit) return; + setUpdatingTaskId(taskId); + setError(''); + try { + await tasksApi.updateStatus(taskId, status); + await loadTasks(); + } catch (error: unknown) { + showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); + } finally { + setUpdatingTaskId(null); + } + }, + [canEdit, loadTasks, setError, showError, t, tErrors], + ); 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) { return
    {t('loading')}
    ; } @@ -173,7 +195,7 @@ export function TasksPage() { }} placeholder={t('searchPlaceholder')} /> -
    +
    +
    + {groupingDisabled && sortHintKey ? ( +

    {t(sortHintKey)}

    + ) : null}
    @@ -243,125 +286,65 @@ export function TasksPage() {

    {t('loading')}

    ) : tasks.length === 0 ? (

    {t('emptyList')}

    + ) : displayModel.mode === 'grouped' ? ( +
    + {displayModel.cases.map((caseGroup) => ( +
    + + {caseGroup.prosthesisGroups.map((prosthesisGroup) => ( +
    + +
      + {prosthesisGroup.tasks.map((task) => ( + void handleStatusUpdate(id, status)} + onToggleComments={(id) => + setExpandedCommentsTaskId((prev) => (prev === id ? null : id)) + } + onCommentError={showError} + /> + ))} +
    +
    + ))} +
    + ))} +
    ) : (
      - {tasks.map((task, index) => { - const commentsOpen = expandedCommentsTaskId === task.id; - - return ( -
    • -
      -
      -
      -

      - {task.stepOrder}. {task.stepLabel} -

      - {task.isImportant ? ( - - {t('importantBadge')} - - ) : null} -
      -

      - {t('fromClinic', { name: task.clinic.name })} ·{' '} - {formatPatientName(task.patient)} ·{' '} - {t('teethLabel', { teeth: formatToothList(task.teeth) })} -

      -

      - {t('taskDate', { date: formatTaskDate(task.createdAt) })} - {task.lastStatusChangedBy ? ( - <> - · - - {t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })} - - - ) : null} -

      -
      - -
      - {canEdit ? ( - - ) : ( - - {statusOptions.find((opt) => opt.value === task.status)?.label ?? - task.status} - - )} -
      - -
      - {canEdit ? ( - - ) : null} - - {task.prosthesisTypeLabel} - -
      -
      - - {commentsOpen && canEdit ? ( -
      - { - 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} - /> -
      - ) : null} -
    • - ); - })} + {displayModel.tasks.map((task) => ( + void handleStatusUpdate(id, status)} + onToggleComments={(id) => + setExpandedCommentsTaskId((prev) => (prev === id ? null : id)) + } + onCommentError={showError} + /> + ))}
    )}
    @@ -395,7 +378,6 @@ export function TasksPage() {
    )} - ); } diff --git a/frontend/src/components/ui/today/TodayDashboard.tsx b/frontend/src/components/ui/today/TodayDashboard.tsx index c953b34..f0ee49b 100644 --- a/frontend/src/components/ui/today/TodayDashboard.tsx +++ b/frontend/src/components/ui/today/TodayDashboard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useAuth } from '@/lib/hooks/useAuth'; import { @@ -43,8 +43,10 @@ import { type TodayDashboardCell, } from '@/components/today/today-dashboard-layout'; 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 type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; import type { TodayCompletionGauge, TodaySubscriptionSnapshot, @@ -77,6 +79,15 @@ export function TodayDashboard({ const { currentOrganization } = useAuth(); const orgType = currentOrganization?.type; const isOwner = Boolean(currentOrganization?.isOwner); + const [prosthesisCatalog, setProsthesisCatalog] = useState([]); + + useEffect(() => { + if (orgType !== 'LAB') return; + void prosthesisCatalogApi + .list() + .then((response) => setProsthesisCatalog(response.data)) + .catch(() => {}); + }, [orgType]); const showUpcoming = orgType === 'CLINIC' && @@ -159,6 +170,7 @@ export function TodayDashboard({ orgType, isOwner, currentOrganization, + prosthesisCatalog, }); }, [ isInitialLoad, @@ -177,6 +189,7 @@ export function TodayDashboard({ actions, subscription, currentOrganization, + prosthesisCatalog, ]); if (hasError && !loading && cells.length === 0) { @@ -297,6 +310,7 @@ function buildDashboardCells(options: { orgType?: 'CLINIC' | 'LAB'; isOwner: boolean; currentOrganization: ReturnType['currentOrganization']; + prosthesisCatalog: ProsthesisCatalogEntry[]; }): TodayDashboardCell[] { const cells: TodayDashboardCell[] = []; @@ -318,6 +332,7 @@ function buildDashboardCells(options: { (options.orgType === 'LAB' && canEditCases(options.currentOrganization))), dayLabelFormatter: options.dayLabelFormatter, + prosthesisCatalog: options.prosthesisCatalog, }), ); } @@ -405,6 +420,7 @@ function buildChartCells(options: { showMyAppointmentsWeekChart: boolean; showCasePartnersChart: boolean; dayLabelFormatter: ReturnType; + prosthesisCatalog: ProsthesisCatalogEntry[]; }): TodayDashboardCell[] { const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options; const cells: TodayDashboardCell[] = []; @@ -580,7 +596,7 @@ function buildChartCells(options: { > prosthesisTypeColor(code, index)} + colorForCode={(code) => prosthesisTypeColorFromCatalog(code, options.prosthesisCatalog)} /> ), diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts index fa3ecc2..de12817 100644 --- a/frontend/src/lib/api/tasks.ts +++ b/frontend/src/lib/api/tasks.ts @@ -5,6 +5,7 @@ import type { LabTaskStatus, ListLabTasksParams, PaginatedLabTasks, + TaskFilterOptions, } from '@/types/cases'; export const tasksApi = { @@ -15,6 +16,11 @@ export const tasksApi = { return response.data; }, + filterOptions: async (): Promise<{ success: boolean; data: TaskFilterOptions }> => { + const response = await apiClient.get('/tasks/filter-options'); + return response.data; + }, + updateStatus: async ( taskId: string, status: LabTaskStatus, diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts index 9a4fe86..0672e46 100644 --- a/frontend/src/types/cases.ts +++ b/frontend/src/types/cases.ts @@ -150,6 +150,7 @@ export interface ListLabTasksParams { important?: boolean; sentFrom?: string; sentTo?: string; + stepCompleted?: string; sortBy?: TaskSortField; sortDir?: 'asc' | 'desc'; page?: number; @@ -172,10 +173,16 @@ export interface LabTaskListItem { lastStatusChangedAt: string | null; lastStatusChangedBy: LabTaskUser | null; createdAt: string; + caseSentAt: string | null; clinic: { id: string; name: string }; patient: { id: string; firstName: string; lastName: string }; } +export interface TaskFilterOptions { + clinics: { id: string; name: string }[]; + workflowSteps: { code: string; label: string }[]; +} + export interface PaginatedLabTasks { items: LabTaskListItem[]; pagination: {