From 08a1f34c4fabdf6b8ade38133022bcbe576867ed Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 13 Jul 2026 16:30:36 +0330 Subject: [PATCH] improvement: duedate added for shipped cases. cases and tasks ui and ux updated accordingly. --- .cursor/rules/lab-tasks.mdc | 3 +- .cursor/skills/lab-tasks/SKILL.md | 14 ++- AGENTS.md | 2 +- .../migration.sql | 5 + backend/prisma/schema.prisma | 1 + backend/src/common/lab-case-due-date.ts | 42 ++++++++ backend/src/modules/cases/cases.service.ts | 8 ++ backend/src/modules/tasks/dto/tasks.dto.ts | 19 +++- backend/src/modules/tasks/tasks.service.ts | 26 ++++- .../modules/treatments/dto/treatment.dto.ts | 11 ++ .../treatments/treatments.controller.ts | 18 ++++ .../modules/treatments/treatments.service.ts | 102 +++++++++++++++++- frontend/messages/en.json | 11 ++ frontend/messages/fa.json | 11 ++ frontend/messages/nl.json | 10 ++ .../components/lab/LabCaseDueDateBadge.tsx | 29 +++++ .../components/lab/labCaseDueDateDisplay.ts | 46 ++++++++ .../src/components/lab/taskListGrouping.ts | 4 + .../src/components/lab/tasksViewDefaults.ts | 3 + .../src/components/ui/lab/CaseDetailPanel.tsx | 10 +- frontend/src/components/ui/lab/CasesPage.tsx | 6 ++ .../components/ui/lab/TaskCaseGroupHeader.tsx | 6 ++ frontend/src/components/ui/lab/TaskRow.tsx | 8 ++ frontend/src/components/ui/lab/TasksPage.tsx | 18 +++- .../ui/treatment/LabCasesDispatchPanel.tsx | 89 ++++++++++++--- .../ui/treatment/TreatmentWorkspace.tsx | 3 + frontend/src/lib/api/treatments.ts | 10 ++ frontend/src/types/cases.ts | 10 +- frontend/src/types/treatment.ts | 7 ++ 29 files changed, 502 insertions(+), 30 deletions(-) create mode 100644 backend/prisma/migrations/20260713140000_lab_case_due_date/migration.sql create mode 100644 backend/src/common/lab-case-due-date.ts create mode 100644 frontend/src/components/lab/LabCaseDueDateBadge.tsx create mode 100644 frontend/src/components/lab/labCaseDueDateDisplay.ts diff --git a/.cursor/rules/lab-tasks.mdc b/.cursor/rules/lab-tasks.mdc index feb420b..af5e113 100644 --- a/.cursor/rules/lab-tasks.mdc +++ b/.cursor/rules/lab-tasks.mdc @@ -1,6 +1,6 @@ --- 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 --- @@ -11,6 +11,7 @@ alwaysApply: false - **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeBadgeStyleFromCatalog` — never row index. - **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}”. +- **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. Full map: `.cursor/skills/lab-tasks/SKILL.md` diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md index 9cbd83f..7908bb3 100644 --- a/.cursor/skills/lab-tasks/SKILL.md +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -40,6 +40,8 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. | `stepCompleted` | `GET /tasks` | Workflow step dropdown | | `pinImportant` | `GET /tasks` | Important first (sort pin) | | `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) | **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. - **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`. - **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. @@ -63,7 +67,15 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. - `GET /cases/assignable-staff` — staff eligible for task assignment - `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 diff --git a/AGENTS.md b/AGENTS.md index b800cb8..d96b183 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - **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 diff --git a/backend/prisma/migrations/20260713140000_lab_case_due_date/migration.sql b/backend/prisma/migrations/20260713140000_lab_case_due_date/migration.sql new file mode 100644 index 0000000..7e7a24b --- /dev/null +++ b/backend/prisma/migrations/20260713140000_lab_case_due_date/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e503da2..6191431 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -214,6 +214,7 @@ model LabCase { sortOrder Int destinationOrganizationId String? sentAt DateTime? + dueDate DateTime? isImportant Boolean @default(false) treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) diff --git a/backend/src/common/lab-case-due-date.ts b/backend/src/common/lab-case-due-date.ts new file mode 100644 index 0000000..5400b16 --- /dev/null +++ b/backend/src/common/lab-case-due-date.ts @@ -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); +} diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 072c97a..4a1325a 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -15,6 +15,9 @@ import { import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { normalizeTeeth } from '../treatments/treatment.utils'; import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto'; +import { + isLabCaseOverdue, +} from '../../common/lab-case-due-date'; import { normalizeTaskTeeth } from './lab-case-task.util'; import { hasEffectivePermission } from '../../common/membership-permissions'; @@ -534,6 +537,7 @@ export class CasesService { private mapLabCaseListItem(lc: { id: string; sentAt: Date | null; + dueDate: Date | null; isImportant: boolean; treatment: { organization: { id: string; name: string }; @@ -548,6 +552,8 @@ export class CasesService { return { id: lc.id, sentAt: lc.sentAt?.toISOString() ?? null, + dueDate: lc.dueDate?.toISOString() ?? null, + isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks), isImportant: lc.isImportant, clinic: lc.treatment.organization, patient: { @@ -582,6 +588,8 @@ export class CasesService { return { id: lc.id, sentAt: lc.sentAt?.toISOString() ?? null, + dueDate: lc.dueDate?.toISOString() ?? null, + isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks), isImportant: lc.isImportant, clinic: lc.treatment.organization, patient: lc.treatment.patient, diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts index dcffd93..1ce3e0e 100644 --- a/backend/src/modules/tasks/dto/tasks.dto.ts +++ b/backend/src/modules/tasks/dto/tasks.dto.ts @@ -32,7 +32,8 @@ export type TaskSortField = | 'patient' | 'important' | 'prosthesis' - | 'taskType'; + | 'taskType' + | 'dueDate'; export class ListLabTasksDto { @IsOptional() @@ -87,8 +88,14 @@ export class ListLabTasksDto { @IsBoolean() assignedToMe?: boolean; + /** Past due date with at least one in-progress task on the case. */ @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; @IsOptional() @@ -159,8 +166,14 @@ export class LocateTaskPageDto { @IsBoolean() assignedToMe?: boolean; + /** Past due date with at least one in-progress task on the case. */ @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; @IsOptional() diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 0460720..454ea08 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -12,6 +12,7 @@ import { normalizeCatalogLocale, } from '../catalog/catalog-label.service'; 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 { hasEffectivePermission } from '../../common/membership-permissions'; @@ -20,6 +21,7 @@ const taskListInclude = { assignee: { select: { id: true, name: true } }, labCase: { include: { + tasks: { select: { status: true } }, treatment: { include: { organization: { select: { id: true, name: true } }, @@ -310,7 +312,15 @@ export class TasksService { const base: Prisma.LabCaseTaskWhereInput = { ...(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 } : {}), ...(query.assignedToMe ? { assigneeUserId: actorUserId } : {}), }; @@ -360,6 +370,7 @@ export class TasksService { sentTo: query.sentTo, stepCompleted: query.stepCompleted, assignedToMe: query.assignedToMe, + overdue: query.overdue, sortBy: query.sortBy, sortDir: query.sortDir, limit: query.limit, @@ -501,6 +512,17 @@ export class TasksService { ...stepTiebreakers, ]; break; + case 'dueDate': + orderBy = [ + { labCase: { dueDate: dir } }, + { labCase: { sentAt: 'desc' } }, + { labCaseId: 'asc' }, + { treatmentDetailId: 'asc' }, + { prosthesisTypeCode: 'asc' }, + { stepOrder: 'asc' }, + { id: 'asc' }, + ]; + break; case 'date': default: orderBy = [ @@ -539,6 +561,8 @@ export class TasksService { stepLabel: task.stepLabel, status: task.status, isImportant: task.labCase.isImportant, + caseDueDate: task.labCase.dueDate?.toISOString() ?? null, + isCaseOverdue: isLabCaseOverdue(task.labCase.dueDate, task.labCase.tasks), assignee: task.assignee ? { id: task.assignee.id, name: task.assignee.name } : null, diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts index a2fa750..f8a0e97 100644 --- a/backend/src/modules/treatments/dto/treatment.dto.ts +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -1,6 +1,7 @@ import { ArrayMinSize, IsArray, + IsDateString, IsOptional, IsString, IsUUID, @@ -84,6 +85,16 @@ export class SaveLabCaseDto { @IsArray() @IsUUID(undefined, { each: true }) attachmentIds?: string[]; + + @IsOptional() + @IsDateString() + dueDate?: string | null; +} + +export class UpdateLabCaseDueDateDto { + @IsOptional() + @IsDateString() + dueDate?: string | null; } export class SaveTreatmentLabCasesDto { diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts index 59c7e54..991ca1c 100644 --- a/backend/src/modules/treatments/treatments.controller.ts +++ b/backend/src/modules/treatments/treatments.controller.ts @@ -4,6 +4,7 @@ import { Get, Param, ParseIntPipe, + Patch, Post, Put, Query, @@ -22,6 +23,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { SaveTreatmentDraftDto, SaveTreatmentLabCasesDto, + UpdateLabCaseDueDateDto, } from './dto/treatment.dto'; import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto'; 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') @ApiOperation({ summary: 'List comments for a lab case during treatment dispatch' }) listLabCaseComments( diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index 8f11505..14372c0 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { LabTaskStatus, LinkStatus } from '@prisma/client'; +import { LabTaskStatus, LinkStatus, Prisma } from '@prisma/client'; import { createReadStream, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; @@ -15,7 +15,12 @@ import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog. import { SaveTreatmentDraftDto, SaveTreatmentLabCasesDto, + UpdateLabCaseDueDateDto, } from './dto/treatment.dto'; +import { + isLabCaseFullyCompleted, + parseDueDateInput, +} from '../../common/lab-case-due-date'; import { generateTreatmentTitle, normalizeTeeth, @@ -408,9 +413,15 @@ export class TreatmentsService { for (const [index, lc] of dto.labCases.entries()) { if (lc.id && sentLabCaseIds.has(lc.id)) { + if (lc.dueDate !== undefined) { + await this.updateLabCaseDueDateInTx(tx, lc.id, organizationId, lc.dueDate); + } continue; } + const dueDate = + lc.dueDate !== undefined ? parseDueDateInput(lc.dueDate) : undefined; + const row = lc.id ? await tx.labCase.update({ where: { id: lc.id }, @@ -418,6 +429,7 @@ export class TreatmentsService { clientKey: lc.clientId, sortOrder: index, destinationOrganizationId: lc.destinationOrganizationId ?? null, + ...(dueDate !== undefined ? { dueDate } : {}), }, }) : await tx.labCase.create({ @@ -426,6 +438,7 @@ export class TreatmentsService { clientKey: lc.clientId, sortOrder: index, destinationOrganizationId: lc.destinationOrganizationId ?? null, + ...(dueDate !== undefined ? { dueDate } : {}), }, }); @@ -586,6 +599,88 @@ export class TreatmentsService { 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( appointmentId: string, detailClientKey: string, @@ -804,6 +899,7 @@ export class TreatmentsService { sortOrder?: number; destinationOrganizationId?: string | null; sentAt?: Date | null; + dueDate?: Date | null; details?: Array<{ treatmentDetailId: string; detail?: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown }; @@ -827,12 +923,16 @@ export class TreatmentsService { createdAt: Date; }; }>; + tasks?: Array<{ id: string; status: LabTaskStatus }>; }) { + const taskProgress = this.mapTaskProgress(lc.tasks ?? []); return { id: lc.id, clientId: lc.clientKey ?? lc.id, destinationOrganizationId: lc.destinationOrganizationId ?? null, sentAt: lc.sentAt?.toISOString() ?? null, + dueDate: lc.dueDate?.toISOString() ?? null, + taskProgress, treatmentDetailId: lc.details?.[0]?.treatmentDetailId ?? null, detail: lc.details?.[0] ? { diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2012dd1..d7fa3b9 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -416,6 +416,8 @@ "selectCaseHint": "Select a case from the list to view tasks.", "fromClinic": "From {name}", "sentAt": "Sent {date}", + "dueDateLabel": "Due {date}", + "overdueBadge": "Overdue", "taskProgressLabel": "Tasks: {completed} of {total} completed", "taskProgressShort": "{progress} tasks", "treatmentDetails": "Treatment details", @@ -488,6 +490,7 @@ "showCompleted": "Show completed", "importantOnly": "Important first", "assignedToMe": "Assigned to me", + "overdueOnly": "Overdue cases only", "assignedToStaff": "Assigned to {name}", "resetView": "Reset filters & sort", "showInCase": "Show in case", @@ -500,6 +503,7 @@ "filterSentTo": "To", "sortBy": "Sort by", "sortDate": "Date", + "sortDueDate": "Case due date", "sortStatus": "Status", "sortClinic": "Clinic", "sortPatient": "Patient", @@ -512,7 +516,10 @@ "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.", + "groupingOffDueDate": "Sorted by due date — case grouping is off.", "caseReceivedAt": "Received {date}", + "caseDueDate": "Due {date}", + "overdueBadge": "Overdue", "caseTaskProgress": "{completed}/{total} tasks on this page", "clearFilters": "Clear filters", "commentsButton": "Comments", @@ -678,6 +685,10 @@ "selectLab": "Destination lab", "selectLabPlaceholder": "Choose a linked 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", "labDispatchSaveHint": "Saves shipment grouping without sending.", "successLabShipmentsSaved": "Lab shipments saved.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index d41236e..82448f4 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -416,6 +416,8 @@ "selectCaseHint": "برای مشاهده وظایف، یک پرونده از فهرست انتخاب کنید.", "fromClinic": "از {name}", "sentAt": "ارسال {date}", + "dueDateLabel": "موعد {date}", + "overdueBadge": "عقب‌افتاده", "taskProgressLabel": "وظایف: {completed} از {total} انجام شده", "taskProgressShort": "{progress} وظیفه", "treatmentDetails": "جزئیات درمان", @@ -489,6 +491,7 @@ "showCompleted": "نمایش تکمیل‌شده‌ها", "importantOnly": "مهم‌ها در ابتدا", "assignedToMe": "واگذار شده به من", + "overdueOnly": "فقط پرونده‌های عقب‌افتاده", "assignedToStaff": "واگذار شده به {name}", "resetView": "بازنشانی فیلترها و مرتب‌سازی", "showInCase": "نمایش در پرونده", @@ -501,6 +504,7 @@ "filterSentTo": "تا", "sortBy": "مرتب‌سازی بر اساس", "sortDate": "تاریخ", + "sortDueDate": "موعد پرونده", "sortStatus": "وضعیت", "sortClinic": "کلینیک", "sortPatient": "بیمار", @@ -513,7 +517,10 @@ "groupingOffProsthesis": "مرتب‌سازی بر اساس نوع پروتز — گروه‌بندی پرونده غیرفعال است.", "groupingOffTaskType": "مرتب‌سازی بر اساس نوع کار — گروه‌بندی پرونده غیرفعال است.", "groupingOffStatus": "مرتب‌سازی بر اساس وضعیت — گروه‌بندی پرونده غیرفعال است.", + "groupingOffDueDate": "مرتب‌سازی بر اساس موعد — گروه‌بندی پرونده غیرفعال است.", "caseReceivedAt": "دریافت {date}", + "caseDueDate": "موعد {date}", + "overdueBadge": "عقب‌افتاده", "caseTaskProgress": "{completed}/{total} کار در این صفحه", "clearFilters": "پاک کردن فیلترها", "commentsButton": "نظرات", @@ -679,6 +686,10 @@ "selectLab": "لابراتوار مقصد", "selectLabPlaceholder": "یک لابراتوار متصل انتخاب کنید…", "sendToLab": "ارسال به لابراتوار", + "dueDateLabel": "موعد تحویل", + "dueDateOptional": "اختیاری", + "dueDateLockedCompleted": "پس از تکمیل همه وظایف، موعد قابل تغییر نیست.", + "dueDateUpdateError": "به‌روزرسانی موعد ناموفق بود.", "saveLabShipments": "ذخیره محموله‌های لاب", "labDispatchSaveHint": "گروه‌بندی محموله را بدون ارسال ذخیره می‌کند.", "successLabShipmentsSaved": "محموله‌های لاب ذخیره شد.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 7b26ce4..315298f 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -416,6 +416,8 @@ "selectCaseHint": "Selecteer een dossier uit de lijst om taken te bekijken.", "fromClinic": "Van {name}", "sentAt": "Verzonden {date}", + "dueDateLabel": "Vervaldatum {date}", + "overdueBadge": "Te laat", "taskProgressLabel": "Taken: {completed} van {total} voltooid", "taskProgressShort": "{progress} taken", "treatmentDetails": "Behandeldetails", @@ -489,6 +491,7 @@ "showCompleted": "Voltooide tonen", "importantOnly": "Belangrijke cases eerst", "assignedToMe": "Toegewezen aan mij", + "overdueOnly": "Alleen te late cases", "assignedToStaff": "Toegewezen aan {name}", "resetView": "Filters en sortering resetten", "showInCase": "In case tonen", @@ -501,6 +504,7 @@ "filterSentTo": "Tot", "sortBy": "Sorteren op", "sortDate": "Datum", + "sortDueDate": "Vervaldatum case", "sortStatus": "Status", "sortClinic": "Kliniek", "sortPatient": "Patiënt", @@ -513,7 +517,9 @@ "groupingOffProsthesis": "Gesorteerd op prothesetype — casagroepering is uit.", "groupingOffTaskType": "Gesorteerd op taaktype — casagroepering is uit.", "groupingOffStatus": "Gesorteerd op status — casagroepering is uit.", + "groupingOffDueDate": "Gesorteerd op vervaldatum — casagroepering is uit.", "caseReceivedAt": "Ontvangen {date}", + "caseDueDate": "Vervaldatum {date}", "caseTaskProgress": "{completed}/{total} taken op deze pagina", "clearFilters": "Filters wissen", "commentsButton": "Opmerkingen", @@ -679,6 +685,10 @@ "selectLab": "Bestemmingslab", "selectLabPlaceholder": "Kies een gekoppeld 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", "labDispatchSaveHint": "Slaat groepering op zonder te verzenden.", "successLabShipmentsSaved": "Labzendingen opgeslagen.", diff --git a/frontend/src/components/lab/LabCaseDueDateBadge.tsx b/frontend/src/components/lab/LabCaseDueDateBadge.tsx new file mode 100644 index 0000000..100f6ad --- /dev/null +++ b/frontend/src/components/lab/LabCaseDueDateBadge.tsx @@ -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 ( + + {label} + + ); +} diff --git a/frontend/src/components/lab/labCaseDueDateDisplay.ts b/frontend/src/components/lab/labCaseDueDateDisplay.ts new file mode 100644 index 0000000..6eab896 --- /dev/null +++ b/frontend/src/components/lab/labCaseDueDateDisplay.ts @@ -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'; +} diff --git a/frontend/src/components/lab/taskListGrouping.ts b/frontend/src/components/lab/taskListGrouping.ts index 14d52f0..95710f2 100644 --- a/frontend/src/components/lab/taskListGrouping.ts +++ b/frontend/src/components/lab/taskListGrouping.ts @@ -14,6 +14,8 @@ export type CaseTaskGroup = { clinic: LabTaskListItem['clinic']; patient: LabTaskListItem['patient']; caseSentAt: string | null; + caseDueDate: string | null; + isCaseOverdue: boolean; isImportant: boolean; prosthesisGroups: ProsthesisTaskGroup[]; }; @@ -47,6 +49,8 @@ export function groupTasksForDisplay( clinic: task.clinic, patient: task.patient, caseSentAt: task.caseSentAt ?? null, + caseDueDate: task.caseDueDate ?? null, + isCaseOverdue: task.isCaseOverdue, isImportant: task.isImportant, prosthesisGroups: [], }); diff --git a/frontend/src/components/lab/tasksViewDefaults.ts b/frontend/src/components/lab/tasksViewDefaults.ts index 4d2d285..6d8be13 100644 --- a/frontend/src/components/lab/tasksViewDefaults.ts +++ b/frontend/src/components/lab/tasksViewDefaults.ts @@ -9,6 +9,7 @@ export type TasksViewState = { sortDir: 'asc' | 'desc'; importantOnly: boolean; assignedToMe: boolean; + overdueOnly: boolean; page: number; highlightTaskId: string | null; }; @@ -22,6 +23,7 @@ export const DEFAULT_TASKS_VIEW: TasksViewState = { sortDir: 'desc', importantOnly: false, assignedToMe: false, + overdueOnly: false, page: 1, highlightTaskId: null, }; @@ -38,6 +40,7 @@ export function isDefaultTasksView(state: TasksViewState): boolean { state.sortDir === DEFAULT_TASKS_VIEW.sortDir && state.importantOnly === DEFAULT_TASKS_VIEW.importantOnly && state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe && + state.overdueOnly === DEFAULT_TASKS_VIEW.overdueOnly && state.page === DEFAULT_TASKS_VIEW.page && state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId ); diff --git a/frontend/src/components/ui/lab/CaseDetailPanel.tsx b/frontend/src/components/ui/lab/CaseDetailPanel.tsx index 1159de4..4aad002 100644 --- a/frontend/src/components/ui/lab/CaseDetailPanel.tsx +++ b/frontend/src/components/ui/lab/CaseDetailPanel.tsx @@ -11,6 +11,7 @@ import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview'; import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog'; import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay'; +import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge'; import { formatToothList, prosthesisTypeBadgeStyleFromCatalog, @@ -104,9 +105,12 @@ export function CaseDetailPanel({
-

- {formatPatientName(labCase.patient)} -

+
+

+ {formatPatientName(labCase.patient)} +

+ +
{!canEditImportant && labCase.isImportant ? ( {t('importantLabel')} diff --git a/frontend/src/components/ui/lab/CasesPage.tsx b/frontend/src/components/ui/lab/CasesPage.tsx index 32ef830..f3601e8 100644 --- a/frontend/src/components/ui/lab/CasesPage.tsx +++ b/frontend/src/components/ui/lab/CasesPage.tsx @@ -13,6 +13,7 @@ import { formatCaseDateTime, formatPatientName, } from '@/components/lab/caseDetailUtils'; +import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge'; import { casesApi } from '@/lib/api/cases'; import { tasksApi } from '@/lib/api/tasks'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; @@ -376,6 +377,11 @@ export function CasesPage() {
{formatPatientName(item.patient)}
+ {item.isImportant ? ( {t('importantLabel')} diff --git a/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx b/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx index a235d5d..d104523 100644 --- a/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx +++ b/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx @@ -2,6 +2,7 @@ import { useTranslations } from 'next-intl'; import { Badge } from '@/components/ui/shared/Badge'; +import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge'; import type { CaseTaskGroup } 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 })} ·{' '} {formatPatientName(caseGroup.patient)}

+ {caseGroup.isImportant ? ( {t('importantBadge')} diff --git a/frontend/src/components/ui/lab/TaskRow.tsx b/frontend/src/components/ui/lab/TaskRow.tsx index 7c1137b..f39b458 100644 --- a/frontend/src/components/ui/lab/TaskRow.tsx +++ b/frontend/src/components/ui/lab/TaskRow.tsx @@ -11,6 +11,7 @@ import { labTaskStatusSelectStyle, labTaskStatusVariant, } from '@/components/lab/labTaskStatusDisplay'; +import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge'; import { formatToothList, prosthesisTypeBadgeStyleFromCatalog, @@ -101,6 +102,13 @@ export function TaskRow({ {t('importantBadge')} ) : null} + {flatMode && task.caseDueDate && !exiting ? ( + + ) : null}
{flatMode ? (

diff --git a/frontend/src/components/ui/lab/TasksPage.tsx b/frontend/src/components/ui/lab/TasksPage.tsx index 2d41a28..eba00f4 100644 --- a/frontend/src/components/ui/lab/TasksPage.tsx +++ b/frontend/src/components/ui/lab/TasksPage.tsx @@ -68,6 +68,7 @@ export function TasksPage() { const [stepCompleted, setStepCompleted] = useState(DEFAULT_TASKS_VIEW.stepCompleted); const [importantOnly, setImportantOnly] = useState(DEFAULT_TASKS_VIEW.importantOnly); const [assignedToMe, setAssignedToMe] = useState(DEFAULT_TASKS_VIEW.assignedToMe); + const [overdueOnly, setOverdueOnly] = useState(DEFAULT_TASKS_VIEW.overdueOnly); const [sortBy, setSortBy] = useState(DEFAULT_TASKS_VIEW.sortBy); const [sortDir, setSortDir] = useState<'asc' | 'desc'>(DEFAULT_TASKS_VIEW.sortDir); const [highlightTaskId, setHighlightTaskId] = useState( @@ -103,8 +104,9 @@ export function TasksPage() { if (stepCompleted) params.stepCompleted = stepCompleted; if (importantOnly) params.pinImportant = true; if (assignedToMe) params.assignedToMe = true; + if (overdueOnly) params.overdue = true; 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]); @@ -120,6 +122,7 @@ export function TasksPage() { sortDir, importantOnly, assignedToMe, + overdueOnly, page, highlightTaskId, }), @@ -132,6 +135,7 @@ export function TasksPage() { sortDir, importantOnly, assignedToMe, + overdueOnly, page, highlightTaskId, ], @@ -197,6 +201,7 @@ export function TasksPage() { setStepCompleted(DEFAULT_TASKS_VIEW.stepCompleted); setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly); setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe); + setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly); setSortBy(DEFAULT_TASKS_VIEW.sortBy); setSortDir(DEFAULT_TASKS_VIEW.sortDir); setPage(DEFAULT_TASKS_VIEW.page); @@ -213,6 +218,8 @@ export function TasksPage() { setStatusFilter(DEFAULT_TASKS_VIEW.statusFilter); setStepCompleted(DEFAULT_TASKS_VIEW.stepCompleted); setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly); + setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe); + setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly); setSortBy(DEFAULT_TASKS_VIEW.sortBy); setSortDir(DEFAULT_TASKS_VIEW.sortDir); setExpandedCommentsTaskId(null); @@ -289,6 +296,8 @@ export function TasksPage() { return 'groupingOffTaskType'; case 'status': return 'groupingOffStatus'; + case 'dueDate': + return 'groupingOffDueDate'; default: return null; } @@ -410,6 +419,7 @@ export function TasksPage() { className={`${filterSelectClass} min-w-0 flex-1`} > + @@ -440,6 +450,12 @@ export function TasksPage() { label={t('assignedToMe')} className="text-xs [&_span:last-child]:text-xs" /> + applyFilterChange(() => setOverdueOnly(checked))} + label={t('overdueOnly')} + className="text-xs [&_span:last-child]:text-xs" + /> {showReset ? (