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

View File

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

View File

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

View File

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

View File

@@ -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<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,
query: ListLabTasksDto,
): Prisma.LabCaseTaskWhereInput {
): Promise<Prisma.LabCaseTaskWhereInput> {
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,

View File

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

View File

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

View File

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

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> = {
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',

View File

@@ -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<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 {
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). */
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<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
): 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<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
): CSSProperties {
return prosthesisTypeBadgeStyle(code, prosthesisCatalogColorIndex(code, catalog));
}
export function formatToothList(teeth: string[]): string {
return teeth.join(', ');
}

View File

@@ -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<ProsthesisCatalogEntry[]>([]);
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({
<CaseToothChartPanel
details={labCase.detail ? [{ teeth: labCase.detail.teeth }] : []}
prosthesisRows={prosthesisRows}
prosthesisCatalog={prosthesisCatalog}
className="w-full"
/>
@@ -177,7 +188,7 @@ export function CaseDetailPanel({
{labCase.tasksByTooth.length === 0 ? (
<p className="text-sm text-text-muted">{t('noTasks')}</p>
) : (
labCase.tasksByTooth.map((group, groupIndex) => (
labCase.tasksByTooth.map((group) => (
<div
key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
className="rounded-md border border-border p-3 space-y-2"
@@ -186,7 +197,10 @@ export function CaseDetailPanel({
<Badge
truncate
title={group.prosthesisTypeLabel}
style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
style={prosthesisTypeBadgeStyleFromCatalog(
group.prosthesisTypeCode,
prosthesisCatalog,
)}
>
{group.prosthesisTypeLabel}
</Badge>

View File

@@ -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<Record<FdiToothId, string>> = {};
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;

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 { 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<TaskFilterOptions>({
clinics: [],
workflowSteps: [],
});
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(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<TaskSortField>('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<string, string>();
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 <div className="text-sm text-text-muted">{t('loading')}</div>;
}
@@ -173,7 +195,7 @@ export function TasksPage() {
}}
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">
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
<select
@@ -185,7 +207,7 @@ export function TasksPage() {
className={filterSelectClass}
>
<option value="">{t('filterClinicAll')}</option>
{clinicOptions.map((c) => (
{filterOptions.clinics.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
@@ -210,6 +232,24 @@ export function TasksPage() {
))}
</select>
</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">
<span className="text-xs text-text-muted">{t('sortBy')}</span>
<div className="flex gap-1.5">
@@ -236,6 +276,9 @@ export function TasksPage() {
</div>
</label>
</div>
{groupingDisabled && sortHintKey ? (
<p className="text-[11px] text-text-muted">{t(sortHintKey)}</p>
) : null}
</section>
<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>
) : tasks.length === 0 ? (
<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">
{tasks.map((task, index) => {
const commentsOpen = expandedCommentsTaskId === task.id;
return (
<li key={task.id}>
<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">
<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>
{task.isImportant ? (
<Badge variant="warning" fixedWidth={false}>
{t('importantBadge')}
</Badge>
) : null}
</div>
<p className="text-[11px] text-text-secondary truncate">
{t('fromClinic', { name: task.clinic.name })} ·{' '}
{formatPatientName(task.patient)} ·{' '}
{t('teethLabel', { teeth: formatToothList(task.teeth) })}
</p>
<p className="text-[11px] text-text-muted truncate">
<span>{t('taskDate', { date: formatTaskDate(task.createdAt) })}</span>
{task.lastStatusChangedBy ? (
<>
<span aria-hidden> · </span>
<span>
{t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}
</span>
</>
) : null}
</p>
</div>
<div className="flex sm:justify-center">
{canEdit ? (
<select
value={task.status}
disabled={updatingTaskId === task.id}
onChange={(e) =>
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
}
className={`${FORM_SELECT_CLASS} w-full sm:max-w-[132px] font-medium`}
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>
);
})}
{displayModel.tasks.map((task) => (
<TaskRow
key={task.id}
task={task}
locale={locale}
flatMode
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>
)}
</section>
@@ -395,7 +378,6 @@ export function TasksPage() {
</div>
</div>
)}
</div>
);
}

View File

@@ -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<ProsthesisCatalogEntry[]>([]);
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<typeof useAuth>['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<typeof useTodayDayLabelFormatter>;
prosthesisCatalog: ProsthesisCatalogEntry[];
}): TodayDashboardCell[] {
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options;
const cells: TodayDashboardCell[] = [];
@@ -580,7 +596,7 @@ function buildChartCells(options: {
>
<TodayBarChart
data={tasksByProsthesisData}
colorForCode={(code, index) => prosthesisTypeColor(code, index)}
colorForCode={(code) => prosthesisTypeColorFromCatalog(code, options.prosthesisCatalog)}
/>
</ChartCard>
),

View File

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

View File

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