improvement: duedate added for shipped cases. cases and tasks ui and ux updated accordingly.

This commit is contained in:
2026-07-13 16:30:36 +03:30
parent a391eee15f
commit 08a1f34c4f
29 changed files with 502 additions and 30 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]
? {