improvement/ux-overhaul up #61

Merged
rameen merged 24 commits from improvement/ux-overhaul into master 2026-07-14 22:46:19 +03:30
22 changed files with 833 additions and 210 deletions
Showing only changes of commit 4e6ed75844 - Show all commits

View 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`

View 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.

View File

@@ -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 |

View File

@@ -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;

View File

@@ -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(

View File

@@ -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],
}) })

View File

@@ -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,18 +231,47 @@ 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 ? { treatment: { organizationId: query.clinicOrganizationId } }
? { treatment: { organizationId: query.clinicOrganizationId } } : {}),
: {}), ...(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,

View File

@@ -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.",

View File

@@ -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": "بارگذاری وظایف ناموفق بود.",

View File

@@ -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.",

View 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 };
}

View File

@@ -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',

View File

@@ -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(', ');
} }

View File

@@ -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>

View File

@@ -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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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,30 +113,58 @@ export function TasksPage() {
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [canView, loadTasks, search]); }, [canView, loadTasks, search]);
async function handleStatusUpdate(taskId: string, status: LabTaskStatus) { useEffect(() => {
if (!canEdit) return; if (!canView) return;
setUpdatingTaskId(taskId); void (async () => {
setError(''); try {
try { const [optionsRes, catalogRes] = await Promise.all([
await tasksApi.updateStatus(taskId, status); tasksApi.filterOptions(),
await loadTasks(); prosthesisCatalogApi.list(),
} catch (error: unknown) { ]);
showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); setFilterOptions(optionsRes.data);
} finally { setProsthesisCatalog(catalogRes.data);
setUpdatingTaskId(null); } catch {
} // Non-blocking — filters fall back to empty options.
} }
})();
}, [canView]);
function formatTaskDate(value: string) { const handleStatusUpdate = useCallback(
return new Intl.DateTimeFormat(locale, { async (taskId: string, status: LabTaskStatus) => {
year: 'numeric', if (!canEdit) return;
month: 'short', setUpdatingTaskId(taskId);
day: 'numeric', setError('');
}).format(new Date(value)); 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 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} onCommentError={showError}
</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`}
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>
); );
} }

View File

@@ -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>
), ),

View File

@@ -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,

View File

@@ -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: {