improvement: duedate added for shipped cases. cases and tasks ui and ux updated accordingly.
This commit is contained in:
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 { 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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user