improvement: duedate added for shipped cases. cases and tasks ui and ux updated accordingly.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description: Lab Tasks tab — sort, grouping, filters, prosthesis colors
|
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/**
|
globs: frontend/src/components/ui/lab/TasksPage.tsx,frontend/src/components/ui/lab/Task*.tsx,frontend/src/components/ui/lab/Case*.tsx,frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx,frontend/src/components/lab/taskListGrouping.ts,frontend/src/components/lab/labCaseDueDateDisplay.ts,frontend/src/components/treatment/prosthesisTypeDisplay.ts,frontend/src/components/shared/catalog-type-colors.ts,backend/src/modules/tasks/**,backend/src/common/lab-case-due-date.ts,backend/src/modules/treatments/**
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ alwaysApply: false
|
|||||||
- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeBadgeStyleFromCatalog` — never row index.
|
- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeBadgeStyleFromCatalog` — never row index.
|
||||||
- **Important first:** `pinImportant=true` (sort pin, not filter).
|
- **Important first:** `pinImportant=true` (sort pin, not filter).
|
||||||
- **Task assignment:** assign in Cases (`TAB_CASES_EDIT`); status edit on Tasks only for assignee or unassigned tasks; others see “Assigned to {name}”.
|
- **Task assignment:** assign in Cases (`TAB_CASES_EDIT`); status edit on Tasks only for assignee or unassigned tasks; others see “Assigned to {name}”.
|
||||||
|
- **Case due dates:** clinic sets in Treatment lab dispatch; lab sees on Cases/Tasks; `overdue` filter + `sortBy=dueDate` on Tasks.
|
||||||
- **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll.
|
- **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll.
|
||||||
|
|
||||||
Full map: `.cursor/skills/lab-tasks/SKILL.md`
|
Full map: `.cursor/skills/lab-tasks/SKILL.md`
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
|
|||||||
| `stepCompleted` | `GET /tasks` | Workflow step dropdown |
|
| `stepCompleted` | `GET /tasks` | Workflow step dropdown |
|
||||||
| `pinImportant` | `GET /tasks` | Important first (sort pin) |
|
| `pinImportant` | `GET /tasks` | Important first (sort pin) |
|
||||||
| `assignedToMe` | `GET /tasks` | Only tasks assigned to current user |
|
| `assignedToMe` | `GET /tasks` | Only tasks assigned to current user |
|
||||||
|
| `overdue` | `GET /tasks` | Cases with due date before today and at least one in-progress task |
|
||||||
|
| `sortBy=dueDate` | `GET /tasks` | Sort by `LabCase.dueDate` (flat list; grouping off) |
|
||||||
| Clinics + steps options | `GET /tasks/filter-options` | Populates dropdowns (not from current page) |
|
| Clinics + steps options | `GET /tasks/filter-options` | Populates dropdowns (not from current page) |
|
||||||
|
|
||||||
**Task assignment:** Managed in **Cases** (`TAB_CASES_EDIT`), not on Tasks tab. `PATCH /cases/:caseId/tasks/:taskId/assign`; assignable staff via `GET /cases/assignable-staff` (members with `TAB_TASKS_EDIT`, including participating owner). Case detail task row: step label, status badge, assign dropdown, and last-updated line on one compact row.
|
**Task assignment:** Managed in **Cases** (`TAB_CASES_EDIT`), not on Tasks tab. `PATCH /cases/:caseId/tasks/:taskId/assign`; assignable staff via `GET /cases/assignable-staff` (members with `TAB_TASKS_EDIT`, including participating owner). Case detail task row: step label, status badge, assign dropdown, and last-updated line on one compact row.
|
||||||
@@ -50,6 +52,8 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
|
|||||||
|
|
||||||
- **Important first:** `pinImportant=true` prepends important cases in sort order.
|
- **Important first:** `pinImportant=true` prepends important cases in sort order.
|
||||||
- **Assigned to me:** `assignedToMe=true` filters to current user's assigned tasks only.
|
- **Assigned to me:** `assignedToMe=true` filters to current user's assigned tasks only.
|
||||||
|
- **Overdue cases:** `overdue=true` — `LabCase.dueDate` before start of UTC day **and** at least one task still `IN_PROGRESS`. Shown with error badge on Cases list/detail and task case headers.
|
||||||
|
- **Sort by due date:** `sortBy=dueDate` — flat list (grouping off); tiebreakers match other non-date sorts.
|
||||||
- **Reset view:** `resetView` restores `DEFAULT_TASKS_VIEW` from `tasksViewDefaults.ts`.
|
- **Reset view:** `resetView` restores `DEFAULT_TASKS_VIEW` from `tasksViewDefaults.ts`.
|
||||||
- **Show in case:** flat-sort rows only; resets filters/sort, calls `GET /tasks/locate-page` to find the correct page in the full default-sorted list, then highlights + scrolls to the task.
|
- **Show in case:** flat-sort rows only; resets filters/sort, calls `GET /tasks/locate-page` to find the correct page in the full default-sorted list, then highlights + scrolls to the task.
|
||||||
- **Complete animation:** when marking done under in-progress filter, row plays exit animation + success toast before refetch.
|
- **Complete animation:** when marking done under in-progress filter, row plays exit animation + success toast before refetch.
|
||||||
@@ -63,7 +67,15 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
|
|||||||
- `GET /cases/assignable-staff` — staff eligible for task assignment
|
- `GET /cases/assignable-staff` — staff eligible for task assignment
|
||||||
- `PATCH /cases/:caseId/tasks/:taskId/assign` — assign or unassign (`assigneeUserId` nullable)
|
- `PATCH /cases/:caseId/tasks/:taskId/assign` — assign or unassign (`assigneeUserId` nullable)
|
||||||
|
|
||||||
List items include `caseSentAt`, `assignee`, `assignedAt` for case headers / flat rows.
|
List items include `caseSentAt`, `caseDueDate`, `isCaseOverdue`, `assignee`, `assignedAt` for case headers / flat rows.
|
||||||
|
|
||||||
|
## Case due dates (clinic → lab)
|
||||||
|
|
||||||
|
- **Schema:** `LabCase.dueDate` (optional `DateTime`).
|
||||||
|
- **Clinic set:** Treatment lab dispatch panel — date input on unsent shipment (saved with draft/send); on sent cases, blur saves via `PATCH /treatments/lab-cases/:labCaseId/due-date`.
|
||||||
|
- **Edit lock:** Clinic cannot change due date after **all** tasks are completed (`taskProgress.completed === taskProgress.total`).
|
||||||
|
- **Lab display:** Cases list + detail; Tasks case group header when grouped by date.
|
||||||
|
- **Utils:** `backend/src/common/lab-case-due-date.ts`, `frontend/src/components/lab/labCaseDueDateDisplay.ts`.
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ frontend/src/
|
|||||||
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
|
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
|
||||||
- **Lab case comments** on a detail when sent and lab case tasks are not all `COMPLETED` (`taskProgress` from API).
|
- **Lab case comments** on a detail when sent and lab case tasks are not all `COMPLETED` (`taskProgress` from API).
|
||||||
|
|
||||||
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from `PROSTHESIS_TYPE_COLORS` via catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown — see `.cursor/skills/lab-tasks/SKILL.md`.
|
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from `PROSTHESIS_TYPE_COLORS` via catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort — see `.cursor/skills/lab-tasks/SKILL.md`.
|
||||||
|
|
||||||
## Backend layout
|
## Backend layout
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Expected due date for lab cases (set by clinic at send / editable until complete)
|
||||||
|
|
||||||
|
ALTER TABLE "lab_cases" ADD COLUMN "dueDate" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE INDEX "lab_cases_dueDate_idx" ON "lab_cases"("dueDate");
|
||||||
@@ -214,6 +214,7 @@ model LabCase {
|
|||||||
sortOrder Int
|
sortOrder Int
|
||||||
destinationOrganizationId String?
|
destinationOrganizationId String?
|
||||||
sentAt DateTime?
|
sentAt DateTime?
|
||||||
|
dueDate DateTime?
|
||||||
isImportant Boolean @default(false)
|
isImportant Boolean @default(false)
|
||||||
|
|
||||||
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
42
backend/src/common/lab-case-due-date.ts
Normal file
42
backend/src/common/lab-case-due-date.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { LabTaskStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
/** Parse YYYY-MM-DD (or ISO) into UTC midnight for that calendar day. */
|
||||||
|
export function parseDueDateInput(value?: string | null): Date | null {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(trimmed);
|
||||||
|
const parsed = dateOnly ? new Date(`${trimmed}T00:00:00.000Z`) : new Date(trimmed);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
throw new Error('Invalid due date');
|
||||||
|
}
|
||||||
|
if (dateOnly) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
const normalized = new Date(parsed);
|
||||||
|
normalized.setUTCHours(0, 0, 0, 0);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfUtcDay(date = new Date()): Date {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setUTCHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLabCaseOverdue(
|
||||||
|
dueDate: Date | null | undefined,
|
||||||
|
tasks: Array<{ status: LabTaskStatus }>,
|
||||||
|
): boolean {
|
||||||
|
if (!dueDate) return false;
|
||||||
|
const hasInProgress = tasks.some((task) => task.status === LabTaskStatus.IN_PROGRESS);
|
||||||
|
if (!hasInProgress) return false;
|
||||||
|
return dueDate < startOfUtcDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLabCaseFullyCompleted(tasks: Array<{ status: LabTaskStatus }>): boolean {
|
||||||
|
return tasks.length > 0 && tasks.every((task) => task.status === LabTaskStatus.COMPLETED);
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||||
import { normalizeTeeth } from '../treatments/treatment.utils';
|
import { normalizeTeeth } from '../treatments/treatment.utils';
|
||||||
import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto';
|
import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto';
|
||||||
|
import {
|
||||||
|
isLabCaseOverdue,
|
||||||
|
} from '../../common/lab-case-due-date';
|
||||||
import { normalizeTaskTeeth } from './lab-case-task.util';
|
import { normalizeTaskTeeth } from './lab-case-task.util';
|
||||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||||
|
|
||||||
@@ -534,6 +537,7 @@ export class CasesService {
|
|||||||
private mapLabCaseListItem(lc: {
|
private mapLabCaseListItem(lc: {
|
||||||
id: string;
|
id: string;
|
||||||
sentAt: Date | null;
|
sentAt: Date | null;
|
||||||
|
dueDate: Date | null;
|
||||||
isImportant: boolean;
|
isImportant: boolean;
|
||||||
treatment: {
|
treatment: {
|
||||||
organization: { id: string; name: string };
|
organization: { id: string; name: string };
|
||||||
@@ -548,6 +552,8 @@ export class CasesService {
|
|||||||
return {
|
return {
|
||||||
id: lc.id,
|
id: lc.id,
|
||||||
sentAt: lc.sentAt?.toISOString() ?? null,
|
sentAt: lc.sentAt?.toISOString() ?? null,
|
||||||
|
dueDate: lc.dueDate?.toISOString() ?? null,
|
||||||
|
isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
|
||||||
isImportant: lc.isImportant,
|
isImportant: lc.isImportant,
|
||||||
clinic: lc.treatment.organization,
|
clinic: lc.treatment.organization,
|
||||||
patient: {
|
patient: {
|
||||||
@@ -582,6 +588,8 @@ export class CasesService {
|
|||||||
return {
|
return {
|
||||||
id: lc.id,
|
id: lc.id,
|
||||||
sentAt: lc.sentAt?.toISOString() ?? null,
|
sentAt: lc.sentAt?.toISOString() ?? null,
|
||||||
|
dueDate: lc.dueDate?.toISOString() ?? null,
|
||||||
|
isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
|
||||||
isImportant: lc.isImportant,
|
isImportant: lc.isImportant,
|
||||||
clinic: lc.treatment.organization,
|
clinic: lc.treatment.organization,
|
||||||
patient: lc.treatment.patient,
|
patient: lc.treatment.patient,
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ export type TaskSortField =
|
|||||||
| 'patient'
|
| 'patient'
|
||||||
| 'important'
|
| 'important'
|
||||||
| 'prosthesis'
|
| 'prosthesis'
|
||||||
| 'taskType';
|
| 'taskType'
|
||||||
|
| 'dueDate';
|
||||||
|
|
||||||
export class ListLabTasksDto {
|
export class ListLabTasksDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -87,8 +88,14 @@ export class ListLabTasksDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
assignedToMe?: boolean;
|
assignedToMe?: boolean;
|
||||||
|
|
||||||
|
/** Past due date with at least one in-progress task on the case. */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
|
@Transform(toBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
overdue?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate'])
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -159,8 +166,14 @@ export class LocateTaskPageDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
assignedToMe?: boolean;
|
assignedToMe?: boolean;
|
||||||
|
|
||||||
|
/** Past due date with at least one in-progress task on the case. */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
|
@Transform(toBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
overdue?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate'])
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
normalizeCatalogLocale,
|
normalizeCatalogLocale,
|
||||||
} from '../catalog/catalog-label.service';
|
} from '../catalog/catalog-label.service';
|
||||||
import { normalizeTaskTeeth } from '../cases/lab-case-task.util';
|
import { normalizeTaskTeeth } from '../cases/lab-case-task.util';
|
||||||
|
import { isLabCaseOverdue, startOfUtcDay } from '../../common/lab-case-due-date';
|
||||||
import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto';
|
import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto';
|
||||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ const taskListInclude = {
|
|||||||
assignee: { select: { id: true, name: true } },
|
assignee: { select: { id: true, name: true } },
|
||||||
labCase: {
|
labCase: {
|
||||||
include: {
|
include: {
|
||||||
|
tasks: { select: { status: true } },
|
||||||
treatment: {
|
treatment: {
|
||||||
include: {
|
include: {
|
||||||
organization: { select: { id: true, name: true } },
|
organization: { select: { id: true, name: true } },
|
||||||
@@ -310,7 +312,15 @@ export class TasksService {
|
|||||||
|
|
||||||
const base: Prisma.LabCaseTaskWhereInput = {
|
const base: Prisma.LabCaseTaskWhereInput = {
|
||||||
...(query.labCaseId ? { labCaseId: query.labCaseId } : {}),
|
...(query.labCaseId ? { labCaseId: query.labCaseId } : {}),
|
||||||
labCase: labCaseScope,
|
labCase: {
|
||||||
|
...labCaseScope,
|
||||||
|
...(query.overdue
|
||||||
|
? {
|
||||||
|
dueDate: { not: null, lt: startOfUtcDay() },
|
||||||
|
tasks: { some: { status: LabTaskStatus.IN_PROGRESS } },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
...(status !== undefined ? { status } : {}),
|
...(status !== undefined ? { status } : {}),
|
||||||
...(query.assignedToMe ? { assigneeUserId: actorUserId } : {}),
|
...(query.assignedToMe ? { assigneeUserId: actorUserId } : {}),
|
||||||
};
|
};
|
||||||
@@ -360,6 +370,7 @@ export class TasksService {
|
|||||||
sentTo: query.sentTo,
|
sentTo: query.sentTo,
|
||||||
stepCompleted: query.stepCompleted,
|
stepCompleted: query.stepCompleted,
|
||||||
assignedToMe: query.assignedToMe,
|
assignedToMe: query.assignedToMe,
|
||||||
|
overdue: query.overdue,
|
||||||
sortBy: query.sortBy,
|
sortBy: query.sortBy,
|
||||||
sortDir: query.sortDir,
|
sortDir: query.sortDir,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
@@ -501,6 +512,17 @@ export class TasksService {
|
|||||||
...stepTiebreakers,
|
...stepTiebreakers,
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
|
case 'dueDate':
|
||||||
|
orderBy = [
|
||||||
|
{ labCase: { dueDate: dir } },
|
||||||
|
{ labCase: { sentAt: 'desc' } },
|
||||||
|
{ labCaseId: 'asc' },
|
||||||
|
{ treatmentDetailId: 'asc' },
|
||||||
|
{ prosthesisTypeCode: 'asc' },
|
||||||
|
{ stepOrder: 'asc' },
|
||||||
|
{ id: 'asc' },
|
||||||
|
];
|
||||||
|
break;
|
||||||
case 'date':
|
case 'date':
|
||||||
default:
|
default:
|
||||||
orderBy = [
|
orderBy = [
|
||||||
@@ -539,6 +561,8 @@ export class TasksService {
|
|||||||
stepLabel: task.stepLabel,
|
stepLabel: task.stepLabel,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
isImportant: task.labCase.isImportant,
|
isImportant: task.labCase.isImportant,
|
||||||
|
caseDueDate: task.labCase.dueDate?.toISOString() ?? null,
|
||||||
|
isCaseOverdue: isLabCaseOverdue(task.labCase.dueDate, task.labCase.tasks),
|
||||||
assignee: task.assignee
|
assignee: task.assignee
|
||||||
? { id: task.assignee.id, name: task.assignee.name }
|
? { id: task.assignee.id, name: task.assignee.name }
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
@@ -84,6 +85,16 @@ export class SaveLabCaseDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsUUID(undefined, { each: true })
|
@IsUUID(undefined, { each: true })
|
||||||
attachmentIds?: string[];
|
attachmentIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dueDate?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateLabCaseDueDateDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dueDate?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SaveTreatmentLabCasesDto {
|
export class SaveTreatmentLabCasesDto {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
@@ -22,6 +23,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|||||||
import {
|
import {
|
||||||
SaveTreatmentDraftDto,
|
SaveTreatmentDraftDto,
|
||||||
SaveTreatmentLabCasesDto,
|
SaveTreatmentLabCasesDto,
|
||||||
|
UpdateLabCaseDueDateDto,
|
||||||
} from './dto/treatment.dto';
|
} from './dto/treatment.dto';
|
||||||
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
|
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
|
||||||
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
|
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
|
||||||
@@ -200,6 +202,22 @@ export class TreatmentsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('lab-cases/:labCaseId/due-date')
|
||||||
|
@ApiOperation({ summary: 'Update expected due date for a sent lab case' })
|
||||||
|
updateLabCaseDueDate(
|
||||||
|
@Param('labCaseId') labCaseId: string,
|
||||||
|
@Body() dto: UpdateLabCaseDueDateDto,
|
||||||
|
@Req() req: { user: { id: string; organizationId?: string } },
|
||||||
|
) {
|
||||||
|
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||||
|
return this.treatmentsService.updateLabCaseDueDate(
|
||||||
|
labCaseId,
|
||||||
|
dto,
|
||||||
|
organizationId,
|
||||||
|
req.user.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('lab-cases/:labCaseId/comments')
|
@Get('lab-cases/:labCaseId/comments')
|
||||||
@ApiOperation({ summary: 'List comments for a lab case during treatment dispatch' })
|
@ApiOperation({ summary: 'List comments for a lab case during treatment dispatch' })
|
||||||
listLabCaseComments(
|
listLabCaseComments(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { LabTaskStatus, LinkStatus } from '@prisma/client';
|
import { LabTaskStatus, LinkStatus, Prisma } from '@prisma/client';
|
||||||
import { createReadStream, existsSync, mkdirSync } from 'fs';
|
import { createReadStream, existsSync, mkdirSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
@@ -15,7 +15,12 @@ import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.
|
|||||||
import {
|
import {
|
||||||
SaveTreatmentDraftDto,
|
SaveTreatmentDraftDto,
|
||||||
SaveTreatmentLabCasesDto,
|
SaveTreatmentLabCasesDto,
|
||||||
|
UpdateLabCaseDueDateDto,
|
||||||
} from './dto/treatment.dto';
|
} from './dto/treatment.dto';
|
||||||
|
import {
|
||||||
|
isLabCaseFullyCompleted,
|
||||||
|
parseDueDateInput,
|
||||||
|
} from '../../common/lab-case-due-date';
|
||||||
import {
|
import {
|
||||||
generateTreatmentTitle,
|
generateTreatmentTitle,
|
||||||
normalizeTeeth,
|
normalizeTeeth,
|
||||||
@@ -408,9 +413,15 @@ export class TreatmentsService {
|
|||||||
|
|
||||||
for (const [index, lc] of dto.labCases.entries()) {
|
for (const [index, lc] of dto.labCases.entries()) {
|
||||||
if (lc.id && sentLabCaseIds.has(lc.id)) {
|
if (lc.id && sentLabCaseIds.has(lc.id)) {
|
||||||
|
if (lc.dueDate !== undefined) {
|
||||||
|
await this.updateLabCaseDueDateInTx(tx, lc.id, organizationId, lc.dueDate);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dueDate =
|
||||||
|
lc.dueDate !== undefined ? parseDueDateInput(lc.dueDate) : undefined;
|
||||||
|
|
||||||
const row = lc.id
|
const row = lc.id
|
||||||
? await tx.labCase.update({
|
? await tx.labCase.update({
|
||||||
where: { id: lc.id },
|
where: { id: lc.id },
|
||||||
@@ -418,6 +429,7 @@ export class TreatmentsService {
|
|||||||
clientKey: lc.clientId,
|
clientKey: lc.clientId,
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
destinationOrganizationId: lc.destinationOrganizationId ?? null,
|
destinationOrganizationId: lc.destinationOrganizationId ?? null,
|
||||||
|
...(dueDate !== undefined ? { dueDate } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: await tx.labCase.create({
|
: await tx.labCase.create({
|
||||||
@@ -426,6 +438,7 @@ export class TreatmentsService {
|
|||||||
clientKey: lc.clientId,
|
clientKey: lc.clientId,
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
destinationOrganizationId: lc.destinationOrganizationId ?? null,
|
destinationOrganizationId: lc.destinationOrganizationId ?? null,
|
||||||
|
...(dueDate !== undefined ? { dueDate } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -586,6 +599,88 @@ export class TreatmentsService {
|
|||||||
return { success: true, data: this.mapLabCase(refreshed) };
|
return { success: true, data: this.mapLabCase(refreshed) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateLabCaseDueDate(
|
||||||
|
labCaseId: string,
|
||||||
|
dto: UpdateLabCaseDueDateDto,
|
||||||
|
organizationId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
) {
|
||||||
|
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||||
|
|
||||||
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
await this.updateLabCaseDueDateInTx(tx, labCaseId, organizationId, dto.dueDate ?? null);
|
||||||
|
return tx.labCase.findFirstOrThrow({
|
||||||
|
where: { id: labCaseId },
|
||||||
|
include: {
|
||||||
|
details: {
|
||||||
|
include: {
|
||||||
|
detail: {
|
||||||
|
select: { id: true, clientKey: true, treatmentType: true, teeth: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sends: {
|
||||||
|
orderBy: [{ sentAt: 'asc' }],
|
||||||
|
include: { organization: { select: { id: true, name: true } } },
|
||||||
|
},
|
||||||
|
toothProsthesis: true,
|
||||||
|
tasks: { select: { id: true, status: true } },
|
||||||
|
attachments: {
|
||||||
|
include: {
|
||||||
|
attachment: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fileName: true,
|
||||||
|
mimeType: true,
|
||||||
|
sizeBytes: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: this.mapLabCase(updated) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateLabCaseDueDateInTx(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
labCaseId: string,
|
||||||
|
organizationId: string,
|
||||||
|
dueDateInput?: string | null,
|
||||||
|
) {
|
||||||
|
const labCase = await tx.labCase.findFirst({
|
||||||
|
where: {
|
||||||
|
id: labCaseId,
|
||||||
|
treatment: { organizationId },
|
||||||
|
sentAt: { not: null },
|
||||||
|
},
|
||||||
|
include: { tasks: { select: { status: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!labCase) {
|
||||||
|
throw new NotFoundException('Lab case not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLabCaseFullyCompleted(labCase.tasks)) {
|
||||||
|
throw new BadRequestException('Due date cannot be changed after all tasks are completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
let dueDate: Date | null;
|
||||||
|
try {
|
||||||
|
dueDate = parseDueDateInput(dueDateInput ?? null);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('Invalid due date');
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.labCase.update({
|
||||||
|
where: { id: labCaseId },
|
||||||
|
data: { dueDate },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async uploadDetailAttachments(
|
async uploadDetailAttachments(
|
||||||
appointmentId: string,
|
appointmentId: string,
|
||||||
detailClientKey: string,
|
detailClientKey: string,
|
||||||
@@ -804,6 +899,7 @@ export class TreatmentsService {
|
|||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
destinationOrganizationId?: string | null;
|
destinationOrganizationId?: string | null;
|
||||||
sentAt?: Date | null;
|
sentAt?: Date | null;
|
||||||
|
dueDate?: Date | null;
|
||||||
details?: Array<{
|
details?: Array<{
|
||||||
treatmentDetailId: string;
|
treatmentDetailId: string;
|
||||||
detail?: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown };
|
detail?: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown };
|
||||||
@@ -827,12 +923,16 @@ export class TreatmentsService {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
tasks?: Array<{ id: string; status: LabTaskStatus }>;
|
||||||
}) {
|
}) {
|
||||||
|
const taskProgress = this.mapTaskProgress(lc.tasks ?? []);
|
||||||
return {
|
return {
|
||||||
id: lc.id,
|
id: lc.id,
|
||||||
clientId: lc.clientKey ?? lc.id,
|
clientId: lc.clientKey ?? lc.id,
|
||||||
destinationOrganizationId: lc.destinationOrganizationId ?? null,
|
destinationOrganizationId: lc.destinationOrganizationId ?? null,
|
||||||
sentAt: lc.sentAt?.toISOString() ?? null,
|
sentAt: lc.sentAt?.toISOString() ?? null,
|
||||||
|
dueDate: lc.dueDate?.toISOString() ?? null,
|
||||||
|
taskProgress,
|
||||||
treatmentDetailId: lc.details?.[0]?.treatmentDetailId ?? null,
|
treatmentDetailId: lc.details?.[0]?.treatmentDetailId ?? null,
|
||||||
detail: lc.details?.[0]
|
detail: lc.details?.[0]
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -416,6 +416,8 @@
|
|||||||
"selectCaseHint": "Select a case from the list to view tasks.",
|
"selectCaseHint": "Select a case from the list to view tasks.",
|
||||||
"fromClinic": "From {name}",
|
"fromClinic": "From {name}",
|
||||||
"sentAt": "Sent {date}",
|
"sentAt": "Sent {date}",
|
||||||
|
"dueDateLabel": "Due {date}",
|
||||||
|
"overdueBadge": "Overdue",
|
||||||
"taskProgressLabel": "Tasks: {completed} of {total} completed",
|
"taskProgressLabel": "Tasks: {completed} of {total} completed",
|
||||||
"taskProgressShort": "{progress} tasks",
|
"taskProgressShort": "{progress} tasks",
|
||||||
"treatmentDetails": "Treatment details",
|
"treatmentDetails": "Treatment details",
|
||||||
@@ -488,6 +490,7 @@
|
|||||||
"showCompleted": "Show completed",
|
"showCompleted": "Show completed",
|
||||||
"importantOnly": "Important first",
|
"importantOnly": "Important first",
|
||||||
"assignedToMe": "Assigned to me",
|
"assignedToMe": "Assigned to me",
|
||||||
|
"overdueOnly": "Overdue cases only",
|
||||||
"assignedToStaff": "Assigned to {name}",
|
"assignedToStaff": "Assigned to {name}",
|
||||||
"resetView": "Reset filters & sort",
|
"resetView": "Reset filters & sort",
|
||||||
"showInCase": "Show in case",
|
"showInCase": "Show in case",
|
||||||
@@ -500,6 +503,7 @@
|
|||||||
"filterSentTo": "To",
|
"filterSentTo": "To",
|
||||||
"sortBy": "Sort by",
|
"sortBy": "Sort by",
|
||||||
"sortDate": "Date",
|
"sortDate": "Date",
|
||||||
|
"sortDueDate": "Case due date",
|
||||||
"sortStatus": "Status",
|
"sortStatus": "Status",
|
||||||
"sortClinic": "Clinic",
|
"sortClinic": "Clinic",
|
||||||
"sortPatient": "Patient",
|
"sortPatient": "Patient",
|
||||||
@@ -512,7 +516,10 @@
|
|||||||
"groupingOffProsthesis": "Sorted by prosthesis type — case grouping is off.",
|
"groupingOffProsthesis": "Sorted by prosthesis type — case grouping is off.",
|
||||||
"groupingOffTaskType": "Sorted by task type — case grouping is off.",
|
"groupingOffTaskType": "Sorted by task type — case grouping is off.",
|
||||||
"groupingOffStatus": "Sorted by status — case grouping is off.",
|
"groupingOffStatus": "Sorted by status — case grouping is off.",
|
||||||
|
"groupingOffDueDate": "Sorted by due date — case grouping is off.",
|
||||||
"caseReceivedAt": "Received {date}",
|
"caseReceivedAt": "Received {date}",
|
||||||
|
"caseDueDate": "Due {date}",
|
||||||
|
"overdueBadge": "Overdue",
|
||||||
"caseTaskProgress": "{completed}/{total} tasks on this page",
|
"caseTaskProgress": "{completed}/{total} tasks on this page",
|
||||||
"clearFilters": "Clear filters",
|
"clearFilters": "Clear filters",
|
||||||
"commentsButton": "Comments",
|
"commentsButton": "Comments",
|
||||||
@@ -678,6 +685,10 @@
|
|||||||
"selectLab": "Destination lab",
|
"selectLab": "Destination lab",
|
||||||
"selectLabPlaceholder": "Choose a linked lab…",
|
"selectLabPlaceholder": "Choose a linked lab…",
|
||||||
"sendToLab": "Send to lab",
|
"sendToLab": "Send to lab",
|
||||||
|
"dueDateLabel": "Due date",
|
||||||
|
"dueDateOptional": "optional",
|
||||||
|
"dueDateLockedCompleted": "Due date cannot be changed after all tasks are completed.",
|
||||||
|
"dueDateUpdateError": "Failed to update due date.",
|
||||||
"saveLabShipments": "Save lab shipments",
|
"saveLabShipments": "Save lab shipments",
|
||||||
"labDispatchSaveHint": "Saves shipment grouping without sending.",
|
"labDispatchSaveHint": "Saves shipment grouping without sending.",
|
||||||
"successLabShipmentsSaved": "Lab shipments saved.",
|
"successLabShipmentsSaved": "Lab shipments saved.",
|
||||||
|
|||||||
@@ -416,6 +416,8 @@
|
|||||||
"selectCaseHint": "برای مشاهده وظایف، یک پرونده از فهرست انتخاب کنید.",
|
"selectCaseHint": "برای مشاهده وظایف، یک پرونده از فهرست انتخاب کنید.",
|
||||||
"fromClinic": "از {name}",
|
"fromClinic": "از {name}",
|
||||||
"sentAt": "ارسال {date}",
|
"sentAt": "ارسال {date}",
|
||||||
|
"dueDateLabel": "موعد {date}",
|
||||||
|
"overdueBadge": "عقبافتاده",
|
||||||
"taskProgressLabel": "وظایف: {completed} از {total} انجام شده",
|
"taskProgressLabel": "وظایف: {completed} از {total} انجام شده",
|
||||||
"taskProgressShort": "{progress} وظیفه",
|
"taskProgressShort": "{progress} وظیفه",
|
||||||
"treatmentDetails": "جزئیات درمان",
|
"treatmentDetails": "جزئیات درمان",
|
||||||
@@ -489,6 +491,7 @@
|
|||||||
"showCompleted": "نمایش تکمیلشدهها",
|
"showCompleted": "نمایش تکمیلشدهها",
|
||||||
"importantOnly": "مهمها در ابتدا",
|
"importantOnly": "مهمها در ابتدا",
|
||||||
"assignedToMe": "واگذار شده به من",
|
"assignedToMe": "واگذار شده به من",
|
||||||
|
"overdueOnly": "فقط پروندههای عقبافتاده",
|
||||||
"assignedToStaff": "واگذار شده به {name}",
|
"assignedToStaff": "واگذار شده به {name}",
|
||||||
"resetView": "بازنشانی فیلترها و مرتبسازی",
|
"resetView": "بازنشانی فیلترها و مرتبسازی",
|
||||||
"showInCase": "نمایش در پرونده",
|
"showInCase": "نمایش در پرونده",
|
||||||
@@ -501,6 +504,7 @@
|
|||||||
"filterSentTo": "تا",
|
"filterSentTo": "تا",
|
||||||
"sortBy": "مرتبسازی بر اساس",
|
"sortBy": "مرتبسازی بر اساس",
|
||||||
"sortDate": "تاریخ",
|
"sortDate": "تاریخ",
|
||||||
|
"sortDueDate": "موعد پرونده",
|
||||||
"sortStatus": "وضعیت",
|
"sortStatus": "وضعیت",
|
||||||
"sortClinic": "کلینیک",
|
"sortClinic": "کلینیک",
|
||||||
"sortPatient": "بیمار",
|
"sortPatient": "بیمار",
|
||||||
@@ -513,7 +517,10 @@
|
|||||||
"groupingOffProsthesis": "مرتبسازی بر اساس نوع پروتز — گروهبندی پرونده غیرفعال است.",
|
"groupingOffProsthesis": "مرتبسازی بر اساس نوع پروتز — گروهبندی پرونده غیرفعال است.",
|
||||||
"groupingOffTaskType": "مرتبسازی بر اساس نوع کار — گروهبندی پرونده غیرفعال است.",
|
"groupingOffTaskType": "مرتبسازی بر اساس نوع کار — گروهبندی پرونده غیرفعال است.",
|
||||||
"groupingOffStatus": "مرتبسازی بر اساس وضعیت — گروهبندی پرونده غیرفعال است.",
|
"groupingOffStatus": "مرتبسازی بر اساس وضعیت — گروهبندی پرونده غیرفعال است.",
|
||||||
|
"groupingOffDueDate": "مرتبسازی بر اساس موعد — گروهبندی پرونده غیرفعال است.",
|
||||||
"caseReceivedAt": "دریافت {date}",
|
"caseReceivedAt": "دریافت {date}",
|
||||||
|
"caseDueDate": "موعد {date}",
|
||||||
|
"overdueBadge": "عقبافتاده",
|
||||||
"caseTaskProgress": "{completed}/{total} کار در این صفحه",
|
"caseTaskProgress": "{completed}/{total} کار در این صفحه",
|
||||||
"clearFilters": "پاک کردن فیلترها",
|
"clearFilters": "پاک کردن فیلترها",
|
||||||
"commentsButton": "نظرات",
|
"commentsButton": "نظرات",
|
||||||
@@ -679,6 +686,10 @@
|
|||||||
"selectLab": "لابراتوار مقصد",
|
"selectLab": "لابراتوار مقصد",
|
||||||
"selectLabPlaceholder": "یک لابراتوار متصل انتخاب کنید…",
|
"selectLabPlaceholder": "یک لابراتوار متصل انتخاب کنید…",
|
||||||
"sendToLab": "ارسال به لابراتوار",
|
"sendToLab": "ارسال به لابراتوار",
|
||||||
|
"dueDateLabel": "موعد تحویل",
|
||||||
|
"dueDateOptional": "اختیاری",
|
||||||
|
"dueDateLockedCompleted": "پس از تکمیل همه وظایف، موعد قابل تغییر نیست.",
|
||||||
|
"dueDateUpdateError": "بهروزرسانی موعد ناموفق بود.",
|
||||||
"saveLabShipments": "ذخیره محمولههای لاب",
|
"saveLabShipments": "ذخیره محمولههای لاب",
|
||||||
"labDispatchSaveHint": "گروهبندی محموله را بدون ارسال ذخیره میکند.",
|
"labDispatchSaveHint": "گروهبندی محموله را بدون ارسال ذخیره میکند.",
|
||||||
"successLabShipmentsSaved": "محمولههای لاب ذخیره شد.",
|
"successLabShipmentsSaved": "محمولههای لاب ذخیره شد.",
|
||||||
|
|||||||
@@ -416,6 +416,8 @@
|
|||||||
"selectCaseHint": "Selecteer een dossier uit de lijst om taken te bekijken.",
|
"selectCaseHint": "Selecteer een dossier uit de lijst om taken te bekijken.",
|
||||||
"fromClinic": "Van {name}",
|
"fromClinic": "Van {name}",
|
||||||
"sentAt": "Verzonden {date}",
|
"sentAt": "Verzonden {date}",
|
||||||
|
"dueDateLabel": "Vervaldatum {date}",
|
||||||
|
"overdueBadge": "Te laat",
|
||||||
"taskProgressLabel": "Taken: {completed} van {total} voltooid",
|
"taskProgressLabel": "Taken: {completed} van {total} voltooid",
|
||||||
"taskProgressShort": "{progress} taken",
|
"taskProgressShort": "{progress} taken",
|
||||||
"treatmentDetails": "Behandeldetails",
|
"treatmentDetails": "Behandeldetails",
|
||||||
@@ -489,6 +491,7 @@
|
|||||||
"showCompleted": "Voltooide tonen",
|
"showCompleted": "Voltooide tonen",
|
||||||
"importantOnly": "Belangrijke cases eerst",
|
"importantOnly": "Belangrijke cases eerst",
|
||||||
"assignedToMe": "Toegewezen aan mij",
|
"assignedToMe": "Toegewezen aan mij",
|
||||||
|
"overdueOnly": "Alleen te late cases",
|
||||||
"assignedToStaff": "Toegewezen aan {name}",
|
"assignedToStaff": "Toegewezen aan {name}",
|
||||||
"resetView": "Filters en sortering resetten",
|
"resetView": "Filters en sortering resetten",
|
||||||
"showInCase": "In case tonen",
|
"showInCase": "In case tonen",
|
||||||
@@ -501,6 +504,7 @@
|
|||||||
"filterSentTo": "Tot",
|
"filterSentTo": "Tot",
|
||||||
"sortBy": "Sorteren op",
|
"sortBy": "Sorteren op",
|
||||||
"sortDate": "Datum",
|
"sortDate": "Datum",
|
||||||
|
"sortDueDate": "Vervaldatum case",
|
||||||
"sortStatus": "Status",
|
"sortStatus": "Status",
|
||||||
"sortClinic": "Kliniek",
|
"sortClinic": "Kliniek",
|
||||||
"sortPatient": "Patiënt",
|
"sortPatient": "Patiënt",
|
||||||
@@ -513,7 +517,9 @@
|
|||||||
"groupingOffProsthesis": "Gesorteerd op prothesetype — casagroepering is uit.",
|
"groupingOffProsthesis": "Gesorteerd op prothesetype — casagroepering is uit.",
|
||||||
"groupingOffTaskType": "Gesorteerd op taaktype — casagroepering is uit.",
|
"groupingOffTaskType": "Gesorteerd op taaktype — casagroepering is uit.",
|
||||||
"groupingOffStatus": "Gesorteerd op status — casagroepering is uit.",
|
"groupingOffStatus": "Gesorteerd op status — casagroepering is uit.",
|
||||||
|
"groupingOffDueDate": "Gesorteerd op vervaldatum — casagroepering is uit.",
|
||||||
"caseReceivedAt": "Ontvangen {date}",
|
"caseReceivedAt": "Ontvangen {date}",
|
||||||
|
"caseDueDate": "Vervaldatum {date}",
|
||||||
"caseTaskProgress": "{completed}/{total} taken op deze pagina",
|
"caseTaskProgress": "{completed}/{total} taken op deze pagina",
|
||||||
"clearFilters": "Filters wissen",
|
"clearFilters": "Filters wissen",
|
||||||
"commentsButton": "Opmerkingen",
|
"commentsButton": "Opmerkingen",
|
||||||
@@ -679,6 +685,10 @@
|
|||||||
"selectLab": "Bestemmingslab",
|
"selectLab": "Bestemmingslab",
|
||||||
"selectLabPlaceholder": "Kies een gekoppeld lab…",
|
"selectLabPlaceholder": "Kies een gekoppeld lab…",
|
||||||
"sendToLab": "Versturen naar lab",
|
"sendToLab": "Versturen naar lab",
|
||||||
|
"dueDateLabel": "Vervaldatum",
|
||||||
|
"dueDateOptional": "optioneel",
|
||||||
|
"dueDateLockedCompleted": "Vervaldatum kan niet worden gewijzigd nadat alle taken zijn voltooid.",
|
||||||
|
"dueDateUpdateError": "Vervaldatum bijwerken mislukt.",
|
||||||
"saveLabShipments": "Labzendingen opslaan",
|
"saveLabShipments": "Labzendingen opslaan",
|
||||||
"labDispatchSaveHint": "Slaat groepering op zonder te verzenden.",
|
"labDispatchSaveHint": "Slaat groepering op zonder te verzenden.",
|
||||||
"successLabShipmentsSaved": "Labzendingen opgeslagen.",
|
"successLabShipmentsSaved": "Labzendingen opgeslagen.",
|
||||||
|
|||||||
29
frontend/src/components/lab/LabCaseDueDateBadge.tsx
Normal file
29
frontend/src/components/lab/LabCaseDueDateBadge.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
|
import {
|
||||||
|
dueDateBadgeVariantFromIso,
|
||||||
|
formatLabCaseDueDate,
|
||||||
|
} from '@/components/lab/labCaseDueDateDisplay';
|
||||||
|
|
||||||
|
interface LabCaseDueDateBadgeProps {
|
||||||
|
dueDate: string | null | undefined;
|
||||||
|
locale: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabCaseDueDateBadge({ dueDate, locale, className }: LabCaseDueDateBadgeProps) {
|
||||||
|
const label = formatLabCaseDueDate(dueDate, locale);
|
||||||
|
if (!label) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={dueDateBadgeVariantFromIso(dueDate)}
|
||||||
|
fixedWidth={false}
|
||||||
|
className={className}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/components/lab/labCaseDueDateDisplay.ts
Normal file
46
frontend/src/components/lab/labCaseDueDateDisplay.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { BadgeVariant } from '@/components/ui/shared/Badge';
|
||||||
|
|
||||||
|
export function toDateInputValue(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfUtcDay(date = new Date()): Date {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setUTCHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLabCaseDueDate(
|
||||||
|
iso: string | null | undefined,
|
||||||
|
locale: string,
|
||||||
|
): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole calendar days from today (UTC) until due date. Negative = overdue. */
|
||||||
|
export function daysUntilDue(iso: string | null | undefined): number | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const due = new Date(iso);
|
||||||
|
if (Number.isNaN(due.getTime())) return null;
|
||||||
|
const msPerDay = 24 * 60 * 60 * 1000;
|
||||||
|
return Math.round((startOfUtcDay(due).getTime() - startOfUtcDay().getTime()) / msPerDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Green: >7 days · Yellow: 2–7 days · Red: ≤1 day (today, tomorrow, or overdue). */
|
||||||
|
export function dueDateBadgeVariantFromIso(iso: string | null | undefined): BadgeVariant {
|
||||||
|
const days = daysUntilDue(iso);
|
||||||
|
if (days === null) return 'default';
|
||||||
|
if (days <= 1) return 'danger';
|
||||||
|
if (days <= 7) return 'warning';
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ export type CaseTaskGroup = {
|
|||||||
clinic: LabTaskListItem['clinic'];
|
clinic: LabTaskListItem['clinic'];
|
||||||
patient: LabTaskListItem['patient'];
|
patient: LabTaskListItem['patient'];
|
||||||
caseSentAt: string | null;
|
caseSentAt: string | null;
|
||||||
|
caseDueDate: string | null;
|
||||||
|
isCaseOverdue: boolean;
|
||||||
isImportant: boolean;
|
isImportant: boolean;
|
||||||
prosthesisGroups: ProsthesisTaskGroup[];
|
prosthesisGroups: ProsthesisTaskGroup[];
|
||||||
};
|
};
|
||||||
@@ -47,6 +49,8 @@ export function groupTasksForDisplay(
|
|||||||
clinic: task.clinic,
|
clinic: task.clinic,
|
||||||
patient: task.patient,
|
patient: task.patient,
|
||||||
caseSentAt: task.caseSentAt ?? null,
|
caseSentAt: task.caseSentAt ?? null,
|
||||||
|
caseDueDate: task.caseDueDate ?? null,
|
||||||
|
isCaseOverdue: task.isCaseOverdue,
|
||||||
isImportant: task.isImportant,
|
isImportant: task.isImportant,
|
||||||
prosthesisGroups: [],
|
prosthesisGroups: [],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type TasksViewState = {
|
|||||||
sortDir: 'asc' | 'desc';
|
sortDir: 'asc' | 'desc';
|
||||||
importantOnly: boolean;
|
importantOnly: boolean;
|
||||||
assignedToMe: boolean;
|
assignedToMe: boolean;
|
||||||
|
overdueOnly: boolean;
|
||||||
page: number;
|
page: number;
|
||||||
highlightTaskId: string | null;
|
highlightTaskId: string | null;
|
||||||
};
|
};
|
||||||
@@ -22,6 +23,7 @@ export const DEFAULT_TASKS_VIEW: TasksViewState = {
|
|||||||
sortDir: 'desc',
|
sortDir: 'desc',
|
||||||
importantOnly: false,
|
importantOnly: false,
|
||||||
assignedToMe: false,
|
assignedToMe: false,
|
||||||
|
overdueOnly: false,
|
||||||
page: 1,
|
page: 1,
|
||||||
highlightTaskId: null,
|
highlightTaskId: null,
|
||||||
};
|
};
|
||||||
@@ -38,6 +40,7 @@ export function isDefaultTasksView(state: TasksViewState): boolean {
|
|||||||
state.sortDir === DEFAULT_TASKS_VIEW.sortDir &&
|
state.sortDir === DEFAULT_TASKS_VIEW.sortDir &&
|
||||||
state.importantOnly === DEFAULT_TASKS_VIEW.importantOnly &&
|
state.importantOnly === DEFAULT_TASKS_VIEW.importantOnly &&
|
||||||
state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe &&
|
state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe &&
|
||||||
|
state.overdueOnly === DEFAULT_TASKS_VIEW.overdueOnly &&
|
||||||
state.page === DEFAULT_TASKS_VIEW.page &&
|
state.page === DEFAULT_TASKS_VIEW.page &&
|
||||||
state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId
|
state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
|||||||
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
|
import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview';
|
||||||
import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog';
|
import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog';
|
||||||
import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay';
|
import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay';
|
||||||
|
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||||
import {
|
import {
|
||||||
formatToothList,
|
formatToothList,
|
||||||
prosthesisTypeBadgeStyleFromCatalog,
|
prosthesisTypeBadgeStyleFromCatalog,
|
||||||
@@ -104,9 +105,12 @@ export function CaseDetailPanel({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between border-b border-border pb-3">
|
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between border-b border-border pb-3">
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h2 className="text-lg font-semibold text-text-primary">
|
<h2 className="text-lg font-semibold text-text-primary">
|
||||||
{formatPatientName(labCase.patient)}
|
{formatPatientName(labCase.patient)}
|
||||||
</h2>
|
</h2>
|
||||||
|
<LabCaseDueDateBadge dueDate={labCase.dueDate} locale={locale} />
|
||||||
|
</div>
|
||||||
{!canEditImportant && labCase.isImportant ? (
|
{!canEditImportant && labCase.isImportant ? (
|
||||||
<Badge variant="warning" fixedWidth={false} className="mt-1">
|
<Badge variant="warning" fixedWidth={false} className="mt-1">
|
||||||
{t('importantLabel')}
|
{t('importantLabel')}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
formatCaseDateTime,
|
formatCaseDateTime,
|
||||||
formatPatientName,
|
formatPatientName,
|
||||||
} from '@/components/lab/caseDetailUtils';
|
} from '@/components/lab/caseDetailUtils';
|
||||||
|
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||||
import { casesApi } from '@/lib/api/cases';
|
import { casesApi } from '@/lib/api/cases';
|
||||||
import { tasksApi } from '@/lib/api/tasks';
|
import { tasksApi } from '@/lib/api/tasks';
|
||||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||||
@@ -376,6 +377,11 @@ export function CasesPage() {
|
|||||||
<div className="font-medium text-text-primary">
|
<div className="font-medium text-text-primary">
|
||||||
{formatPatientName(item.patient)}
|
{formatPatientName(item.patient)}
|
||||||
</div>
|
</div>
|
||||||
|
<LabCaseDueDateBadge
|
||||||
|
dueDate={item.dueDate}
|
||||||
|
locale={locale}
|
||||||
|
className="text-[10px]"
|
||||||
|
/>
|
||||||
{item.isImportant ? (
|
{item.isImportant ? (
|
||||||
<Badge variant="warning" fixedWidth={false}>
|
<Badge variant="warning" fixedWidth={false}>
|
||||||
{t('importantLabel')}
|
{t('importantLabel')}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Badge } from '@/components/ui/shared/Badge';
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
|
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||||
import type { CaseTaskGroup } from '@/components/lab/taskListGrouping';
|
import type { CaseTaskGroup } from '@/components/lab/taskListGrouping';
|
||||||
import { countCaseTaskProgress } from '@/components/lab/taskListGrouping';
|
import { countCaseTaskProgress } from '@/components/lab/taskListGrouping';
|
||||||
|
|
||||||
@@ -34,6 +35,11 @@ export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderPr
|
|||||||
{t('fromClinic', { name: caseGroup.clinic.name })} ·{' '}
|
{t('fromClinic', { name: caseGroup.clinic.name })} ·{' '}
|
||||||
{formatPatientName(caseGroup.patient)}
|
{formatPatientName(caseGroup.patient)}
|
||||||
</p>
|
</p>
|
||||||
|
<LabCaseDueDateBadge
|
||||||
|
dueDate={caseGroup.caseDueDate}
|
||||||
|
locale={locale}
|
||||||
|
className="text-[10px]"
|
||||||
|
/>
|
||||||
{caseGroup.isImportant ? (
|
{caseGroup.isImportant ? (
|
||||||
<Badge variant="warning" fixedWidth={false} className="text-[10px]">
|
<Badge variant="warning" fixedWidth={false} className="text-[10px]">
|
||||||
{t('importantBadge')}
|
{t('importantBadge')}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
labTaskStatusSelectStyle,
|
labTaskStatusSelectStyle,
|
||||||
labTaskStatusVariant,
|
labTaskStatusVariant,
|
||||||
} from '@/components/lab/labTaskStatusDisplay';
|
} from '@/components/lab/labTaskStatusDisplay';
|
||||||
|
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||||
import {
|
import {
|
||||||
formatToothList,
|
formatToothList,
|
||||||
prosthesisTypeBadgeStyleFromCatalog,
|
prosthesisTypeBadgeStyleFromCatalog,
|
||||||
@@ -101,6 +102,13 @@ export function TaskRow({
|
|||||||
{t('importantBadge')}
|
{t('importantBadge')}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
{flatMode && task.caseDueDate && !exiting ? (
|
||||||
|
<LabCaseDueDateBadge
|
||||||
|
dueDate={task.caseDueDate}
|
||||||
|
locale={locale}
|
||||||
|
className="text-[10px]"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{flatMode ? (
|
{flatMode ? (
|
||||||
<p className="text-[11px] text-text-secondary truncate">
|
<p className="text-[11px] text-text-secondary truncate">
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export function TasksPage() {
|
|||||||
const [stepCompleted, setStepCompleted] = useState(DEFAULT_TASKS_VIEW.stepCompleted);
|
const [stepCompleted, setStepCompleted] = useState(DEFAULT_TASKS_VIEW.stepCompleted);
|
||||||
const [importantOnly, setImportantOnly] = useState(DEFAULT_TASKS_VIEW.importantOnly);
|
const [importantOnly, setImportantOnly] = useState(DEFAULT_TASKS_VIEW.importantOnly);
|
||||||
const [assignedToMe, setAssignedToMe] = useState(DEFAULT_TASKS_VIEW.assignedToMe);
|
const [assignedToMe, setAssignedToMe] = useState(DEFAULT_TASKS_VIEW.assignedToMe);
|
||||||
|
const [overdueOnly, setOverdueOnly] = useState(DEFAULT_TASKS_VIEW.overdueOnly);
|
||||||
const [sortBy, setSortBy] = useState<TaskSortField>(DEFAULT_TASKS_VIEW.sortBy);
|
const [sortBy, setSortBy] = useState<TaskSortField>(DEFAULT_TASKS_VIEW.sortBy);
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>(DEFAULT_TASKS_VIEW.sortDir);
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>(DEFAULT_TASKS_VIEW.sortDir);
|
||||||
const [highlightTaskId, setHighlightTaskId] = useState<string | null>(
|
const [highlightTaskId, setHighlightTaskId] = useState<string | null>(
|
||||||
@@ -103,8 +104,9 @@ export function TasksPage() {
|
|||||||
if (stepCompleted) params.stepCompleted = stepCompleted;
|
if (stepCompleted) params.stepCompleted = stepCompleted;
|
||||||
if (importantOnly) params.pinImportant = true;
|
if (importantOnly) params.pinImportant = true;
|
||||||
if (assignedToMe) params.assignedToMe = true;
|
if (assignedToMe) params.assignedToMe = true;
|
||||||
|
if (overdueOnly) params.overdue = true;
|
||||||
return params;
|
return params;
|
||||||
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, assignedToMe, sortBy, sortDir]);
|
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, assignedToMe, overdueOnly, sortBy, sortDir]);
|
||||||
|
|
||||||
const displayModel = useMemo(() => groupTasksForDisplay(tasks, sortBy), [tasks, sortBy]);
|
const displayModel = useMemo(() => groupTasksForDisplay(tasks, sortBy), [tasks, sortBy]);
|
||||||
|
|
||||||
@@ -120,6 +122,7 @@ export function TasksPage() {
|
|||||||
sortDir,
|
sortDir,
|
||||||
importantOnly,
|
importantOnly,
|
||||||
assignedToMe,
|
assignedToMe,
|
||||||
|
overdueOnly,
|
||||||
page,
|
page,
|
||||||
highlightTaskId,
|
highlightTaskId,
|
||||||
}),
|
}),
|
||||||
@@ -132,6 +135,7 @@ export function TasksPage() {
|
|||||||
sortDir,
|
sortDir,
|
||||||
importantOnly,
|
importantOnly,
|
||||||
assignedToMe,
|
assignedToMe,
|
||||||
|
overdueOnly,
|
||||||
page,
|
page,
|
||||||
highlightTaskId,
|
highlightTaskId,
|
||||||
],
|
],
|
||||||
@@ -197,6 +201,7 @@ export function TasksPage() {
|
|||||||
setStepCompleted(DEFAULT_TASKS_VIEW.stepCompleted);
|
setStepCompleted(DEFAULT_TASKS_VIEW.stepCompleted);
|
||||||
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
|
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
|
||||||
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
|
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
|
||||||
|
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
|
||||||
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
||||||
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
||||||
setPage(DEFAULT_TASKS_VIEW.page);
|
setPage(DEFAULT_TASKS_VIEW.page);
|
||||||
@@ -213,6 +218,8 @@ export function TasksPage() {
|
|||||||
setStatusFilter(DEFAULT_TASKS_VIEW.statusFilter);
|
setStatusFilter(DEFAULT_TASKS_VIEW.statusFilter);
|
||||||
setStepCompleted(DEFAULT_TASKS_VIEW.stepCompleted);
|
setStepCompleted(DEFAULT_TASKS_VIEW.stepCompleted);
|
||||||
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
|
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
|
||||||
|
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
|
||||||
|
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
|
||||||
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
||||||
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
||||||
setExpandedCommentsTaskId(null);
|
setExpandedCommentsTaskId(null);
|
||||||
@@ -289,6 +296,8 @@ export function TasksPage() {
|
|||||||
return 'groupingOffTaskType';
|
return 'groupingOffTaskType';
|
||||||
case 'status':
|
case 'status':
|
||||||
return 'groupingOffStatus';
|
return 'groupingOffStatus';
|
||||||
|
case 'dueDate':
|
||||||
|
return 'groupingOffDueDate';
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -410,6 +419,7 @@ export function TasksPage() {
|
|||||||
className={`${filterSelectClass} min-w-0 flex-1`}
|
className={`${filterSelectClass} min-w-0 flex-1`}
|
||||||
>
|
>
|
||||||
<option value="date">{t('sortDate')}</option>
|
<option value="date">{t('sortDate')}</option>
|
||||||
|
<option value="dueDate">{t('sortDueDate')}</option>
|
||||||
<option value="clinic">{t('sortClinic')}</option>
|
<option value="clinic">{t('sortClinic')}</option>
|
||||||
<option value="patient">{t('sortPatient')}</option>
|
<option value="patient">{t('sortPatient')}</option>
|
||||||
<option value="prosthesis">{t('sortProsthesis')}</option>
|
<option value="prosthesis">{t('sortProsthesis')}</option>
|
||||||
@@ -440,6 +450,12 @@ export function TasksPage() {
|
|||||||
label={t('assignedToMe')}
|
label={t('assignedToMe')}
|
||||||
className="text-xs [&_span:last-child]:text-xs"
|
className="text-xs [&_span:last-child]:text-xs"
|
||||||
/>
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={overdueOnly}
|
||||||
|
onChange={(checked) => applyFilterChange(() => setOverdueOnly(checked))}
|
||||||
|
label={t('overdueOnly')}
|
||||||
|
className="text-xs [&_span:last-child]:text-xs"
|
||||||
|
/>
|
||||||
{showReset ? (
|
{showReset ? (
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={resetView}>
|
<Button type="button" variant="ghost" size="sm" onClick={resetView}>
|
||||||
{t('resetView')}
|
{t('resetView')}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
import { isDetailReadyForLabDispatch } from '@/components/treatment/treatmentDetailRules';
|
import { isDetailReadyForLabDispatch, isLabCaseCompleted } from '@/components/treatment/treatmentDetailRules';
|
||||||
|
import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay';
|
||||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||||
import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox';
|
import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox';
|
||||||
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
||||||
@@ -221,6 +222,73 @@ export function LabCasesDispatchPanel({
|
|||||||
setApplyAllProsthesis('');
|
setApplyAllProsthesis('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
|
||||||
|
const canEditDueDate = canEdit && !disabled && (!sent || !caseFullyComplete);
|
||||||
|
|
||||||
|
async function handleSentDueDateBlur(nextValue: string) {
|
||||||
|
if (!activeLabCase?.id || !sent || !canEditDueDate) return;
|
||||||
|
const dueDate = nextValue || null;
|
||||||
|
if (dueDate === (activeLabCase.dueDate?.slice(0, 10) ?? null)) return;
|
||||||
|
try {
|
||||||
|
const response = await treatmentsApi.updateLabCaseDueDate(activeLabCase.id, dueDate);
|
||||||
|
updateActiveLabCase({
|
||||||
|
dueDate: response.data.dueDate,
|
||||||
|
taskProgress: response.data.taskProgress ?? activeLabCase.taskProgress,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
onCommentError?.(error instanceof Error ? error.message : t('dueDateUpdateError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShipmentCardHeader() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 border-b border-border/60 pb-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<p className="text-xs font-semibold text-text-primary">{t('labShipmentIncludedDetails')}</p>
|
||||||
|
{renderDueDateField()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDueDateField() {
|
||||||
|
if (!activeLabCase) return null;
|
||||||
|
const inputValue = toDateInputValue(activeLabCase.dueDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="block shrink-0 sm:max-w-[11rem] sm:text-end">
|
||||||
|
<span className="block text-xs font-medium text-text-secondary sm:text-end">
|
||||||
|
{t('dueDateLabel')}{' '}
|
||||||
|
<span className="font-normal text-text-muted">({t('dueDateOptional')})</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={inputValue}
|
||||||
|
disabled={!canEditDueDate}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!sent) {
|
||||||
|
updateActiveLabCase({ dueDate: e.target.value || null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (sent) void handleSentDueDateBlur(e.target.value);
|
||||||
|
}}
|
||||||
|
className={`${FORM_SELECT_CLASS} mt-2 w-full rounded-md px-2 py-1.5 text-sm`}
|
||||||
|
/>
|
||||||
|
{sent && caseFullyComplete && activeLabCase.dueDate ? (
|
||||||
|
<p className="mt-1.5 text-[11px] text-text-muted sm:text-end">{t('dueDateLockedCompleted')}</p>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIncludedDetailSummary() {
|
||||||
|
if (!activeDetail) return null;
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2">
|
||||||
|
{detailSummary(activeDetail)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
|
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -248,16 +316,10 @@ export function LabCasesDispatchPanel({
|
|||||||
<p className="text-xs text-text-muted">{t('labDispatchEmpty')}</p>
|
<p className="text-xs text-text-muted">{t('labDispatchEmpty')}</p>
|
||||||
) : activeLabCase ? (
|
) : activeLabCase ? (
|
||||||
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
|
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
|
||||||
|
{renderShipmentCardHeader()}
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<>
|
<>
|
||||||
<div>
|
{renderIncludedDetailSummary()}
|
||||||
<p className="text-xs font-medium text-text-secondary mb-2">
|
|
||||||
{t('labShipmentIncludedDetails')}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2">
|
|
||||||
{detailSummary(activeDetail)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeLabCase.id ? (
|
{activeLabCase.id ? (
|
||||||
<LabCaseCommentsPanel
|
<LabCaseCommentsPanel
|
||||||
@@ -296,14 +358,7 @@ export function LabCasesDispatchPanel({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
{renderIncludedDetailSummary()}
|
||||||
<p className="text-xs font-medium text-text-secondary mb-2">
|
|
||||||
{t('labShipmentIncludedDetails')}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2">
|
|
||||||
{detailSummary(activeDetail)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!sent && activeDetailAttachments.length > 0 ? (
|
{!sent && activeDetailAttachments.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
|
|||||||
attachmentIds: (lc.attachments ?? []).map((a) => a.id),
|
attachmentIds: (lc.attachments ?? []).map((a) => a.id),
|
||||||
sentAt: lc.sentAt ?? null,
|
sentAt: lc.sentAt ?? null,
|
||||||
sends: lc.sends ?? [],
|
sends: lc.sends ?? [],
|
||||||
|
dueDate: lc.dueDate ?? null,
|
||||||
|
taskProgress: lc.taskProgress ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,6 +992,7 @@ export function TreatmentWorkspace({
|
|||||||
row !== null,
|
row !== null,
|
||||||
),
|
),
|
||||||
attachmentIds: lc.attachmentIds,
|
attachmentIds: lc.attachmentIds,
|
||||||
|
dueDate: lc.dueDate ?? null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((row): row is NonNullable<typeof row> => row !== null);
|
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ export const treatmentsApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateLabCaseDueDate: async (
|
||||||
|
labCaseId: string,
|
||||||
|
dueDate: string | null,
|
||||||
|
): Promise<{ success: boolean; data: LabCaseResponse }> => {
|
||||||
|
const response = await apiClient.patch(`/treatments/lab-cases/${labCaseId}/due-date`, {
|
||||||
|
dueDate,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
listLabCaseComments: async (
|
listLabCaseComments: async (
|
||||||
labCaseId: string,
|
labCaseId: string,
|
||||||
): Promise<{ success: boolean; data: LabCaseComment[] }> => {
|
): Promise<{ success: boolean; data: LabCaseComment[] }> => {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export type LabTaskStatus = 'IN_PROGRESS' | 'COMPLETED';
|
|||||||
export interface LabCaseListItem {
|
export interface LabCaseListItem {
|
||||||
id: string;
|
id: string;
|
||||||
sentAt: string | null;
|
sentAt: string | null;
|
||||||
|
dueDate: string | null;
|
||||||
|
isOverdue: boolean;
|
||||||
isImportant: boolean;
|
isImportant: boolean;
|
||||||
clinic: { id: string; name: string };
|
clinic: { id: string; name: string };
|
||||||
patient: {
|
patient: {
|
||||||
@@ -79,6 +81,8 @@ export interface LabCaseAttachmentMeta {
|
|||||||
export interface LabCaseDetail {
|
export interface LabCaseDetail {
|
||||||
id: string;
|
id: string;
|
||||||
sentAt: string | null;
|
sentAt: string | null;
|
||||||
|
dueDate: string | null;
|
||||||
|
isOverdue: boolean;
|
||||||
isImportant: boolean;
|
isImportant: boolean;
|
||||||
clinic: { id: string; name: string };
|
clinic: { id: string; name: string };
|
||||||
patient: {
|
patient: {
|
||||||
@@ -143,7 +147,8 @@ export type TaskSortField =
|
|||||||
| 'patient'
|
| 'patient'
|
||||||
| 'important'
|
| 'important'
|
||||||
| 'prosthesis'
|
| 'prosthesis'
|
||||||
| 'taskType';
|
| 'taskType'
|
||||||
|
| 'dueDate';
|
||||||
|
|
||||||
export interface ListLabTasksParams {
|
export interface ListLabTasksParams {
|
||||||
q?: string;
|
q?: string;
|
||||||
@@ -153,6 +158,7 @@ export interface ListLabTasksParams {
|
|||||||
important?: boolean;
|
important?: boolean;
|
||||||
pinImportant?: boolean;
|
pinImportant?: boolean;
|
||||||
assignedToMe?: boolean;
|
assignedToMe?: boolean;
|
||||||
|
overdue?: boolean;
|
||||||
sentFrom?: string;
|
sentFrom?: string;
|
||||||
sentTo?: string;
|
sentTo?: string;
|
||||||
stepCompleted?: string;
|
stepCompleted?: string;
|
||||||
@@ -196,6 +202,8 @@ export interface LabTaskListItem {
|
|||||||
stepLabel: string;
|
stepLabel: string;
|
||||||
status: LabTaskStatus;
|
status: LabTaskStatus;
|
||||||
isImportant: boolean;
|
isImportant: boolean;
|
||||||
|
caseDueDate: string | null;
|
||||||
|
isCaseOverdue: boolean;
|
||||||
assignee: LabTaskUser | null;
|
assignee: LabTaskUser | null;
|
||||||
assignedAt: string | null;
|
assignedAt: string | null;
|
||||||
lastStatusChangedAt: string | null;
|
lastStatusChangedAt: string | null;
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ export interface PastLabCase {
|
|||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
}>;
|
}>;
|
||||||
sends?: LabCaseSendInfo[];
|
sends?: LabCaseSendInfo[];
|
||||||
|
dueDate?: string | null;
|
||||||
|
taskProgress?: LabCaseTaskProgress | null;
|
||||||
attachments?: TreatmentAttachmentMeta[];
|
attachments?: TreatmentAttachmentMeta[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +157,8 @@ export interface LabCaseDraft {
|
|||||||
attachmentIds: string[];
|
attachmentIds: string[];
|
||||||
sentAt?: string | null;
|
sentAt?: string | null;
|
||||||
sends?: LabCaseSendInfo[];
|
sends?: LabCaseSendInfo[];
|
||||||
|
dueDate?: string | null;
|
||||||
|
taskProgress?: LabCaseTaskProgress | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SavedTreatmentDetailPayload = {
|
export type SavedTreatmentDetailPayload = {
|
||||||
@@ -180,6 +184,7 @@ export interface SaveLabCasePayload {
|
|||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
}>;
|
}>;
|
||||||
attachmentIds?: string[];
|
attachmentIds?: string[];
|
||||||
|
dueDate?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveTreatmentPayload {
|
export interface SaveTreatmentPayload {
|
||||||
@@ -201,6 +206,8 @@ export interface LabCaseResponse {
|
|||||||
teeth: string[];
|
teeth: string[];
|
||||||
} | null;
|
} | null;
|
||||||
sends: LabCaseSendInfo[];
|
sends: LabCaseSendInfo[];
|
||||||
|
dueDate: string | null;
|
||||||
|
taskProgress?: LabCaseTaskProgress | null;
|
||||||
toothProsthesis?: LabCaseToothProsthesisDraft[];
|
toothProsthesis?: LabCaseToothProsthesisDraft[];
|
||||||
attachments?: TreatmentAttachmentMeta[];
|
attachments?: TreatmentAttachmentMeta[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user