feat: 수정 요청 시 할 일 항목별 완료 체크/X 검토 추가
지시자가 승인을 검토하며 수정 요청할 때, 단순 텍스트 입력에 더해 할 일 목록에서 어떤 항목이 완성/미완성인지 체크(✓)·X로 표시할 수 있게 한다. - 백엔드: ApprovalChangesDto 에 항목별 검토 결과(checklist) 추가, requestChanges 가 전달된 항목만 done 상태로 반영(미전달 항목은 보존) - 프론트: 수정 요청 모달에 카테고리별 체크/X 토글 UI 추가, 검토 결과를 note 와 함께 전송 - 테스트: 전달된 항목만 완료 체크가 반영되는지 검증(9 tests) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,15 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 승인 요청(담당자 → 지시자) — 메시지 선택
|
||||
@@ -14,7 +25,18 @@ export class ApprovalRequestDto {
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// 수정 요청(지시자 → 담당자) — 보완 내용 필수
|
||||
// 수정 요청 시 항목별 완료 검토 결과(지시자가 체크/X 표시)
|
||||
export class ChecklistReviewDto {
|
||||
@ApiProperty({ description: '체크리스트 항목 ID' })
|
||||
@IsUUID('4')
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '완료 여부 — true=완성(체크) / false=미완성(X)' })
|
||||
@IsBoolean()
|
||||
done!: boolean;
|
||||
}
|
||||
|
||||
// 수정 요청(지시자 → 담당자) — 보완 내용 필수 + 항목별 완료 체크(선택)
|
||||
export class ApprovalChangesDto {
|
||||
@ApiProperty({
|
||||
description: '수정 요청 메시지(보완이 필요한 내용)',
|
||||
@@ -24,4 +46,16 @@ export class ApprovalChangesDto {
|
||||
@IsNotEmpty({ message: '수정 요청 내용을 입력해 주세요.' })
|
||||
@MaxLength(2000)
|
||||
note!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '항목별 완료 검토 결과(체크/X). 생략 시 체크리스트 변경 없음',
|
||||
type: [ChecklistReviewDto],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100, { message: '체크리스트 항목이 너무 많습니다.' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ChecklistReviewDto)
|
||||
checklist?: ChecklistReviewDto[];
|
||||
}
|
||||
|
||||
@@ -323,6 +323,7 @@ export class TaskController {
|
||||
user.id,
|
||||
taskId,
|
||||
dto.note,
|
||||
dto.checklist,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,47 @@ describe('TaskService', () => {
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('수정 요청 시 전달된 항목만 완료 체크(체크/X)를 반영한다', async () => {
|
||||
const { svc, taskRepo, checklistRepo, projectRepo, memberRepo } =
|
||||
makeService();
|
||||
projectRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne.mockResolvedValue({
|
||||
roleType: 'admin',
|
||||
user: { id: 'admin', name: 'A' },
|
||||
});
|
||||
const task = {
|
||||
seq: 9,
|
||||
status: 'review',
|
||||
title: '배너',
|
||||
content: [],
|
||||
issuer: { id: 'admin', email: 'a@b.c', name: 'A', role: null },
|
||||
assignees: [],
|
||||
checklist: [
|
||||
{ id: 'c1', text: '가로형', done: true, sortOrder: 0 },
|
||||
{ id: 'c2', text: '세로형', done: false, sortOrder: 1 },
|
||||
{ id: 'c3', text: '모바일', done: true, sortOrder: 2 },
|
||||
],
|
||||
dueDate: null,
|
||||
createdAt: new Date('2026-06-08T00:00:00Z'),
|
||||
};
|
||||
taskRepo.findOne.mockResolvedValue(task);
|
||||
taskRepo.save.mockResolvedValue(task);
|
||||
checklistRepo.save.mockResolvedValue(task.checklist);
|
||||
|
||||
const result = await svc.requestChanges('q3-launch', 'admin', 9, '보완 바랍니다', [
|
||||
{ id: 'c1', done: false }, // 완성 → 미완성(X)
|
||||
{ id: 'c2', done: true }, // 미완성 → 완성(체크)
|
||||
// c3 은 미전달 → 기존값(true) 유지
|
||||
]);
|
||||
|
||||
expect(checklistRepo.save).toHaveBeenCalledTimes(1);
|
||||
expect(task.checklist.find((c) => c.id === 'c1')?.done).toBe(false);
|
||||
expect(task.checklist.find((c) => c.id === 'c2')?.done).toBe(true);
|
||||
expect(task.checklist.find((c) => c.id === 'c3')?.done).toBe(true);
|
||||
expect(result.status).toBe('changes');
|
||||
expect(result.changesNote).toBe('보완 바랍니다');
|
||||
});
|
||||
|
||||
it('승인 요청(prog→review)은 담당자가 가능', async () => {
|
||||
const {
|
||||
svc,
|
||||
|
||||
@@ -885,12 +885,13 @@ export class TaskService {
|
||||
);
|
||||
}
|
||||
|
||||
// 수정 요청(지시자/admin): review → changes. 보완 메시지 보관
|
||||
// 수정 요청(지시자/admin): review → changes. 보완 메시지 + 항목별 완료 체크 보관
|
||||
async requestChanges(
|
||||
projectId: string,
|
||||
actingUserId: string,
|
||||
seq: number,
|
||||
note: string,
|
||||
reviews?: { id: string; done: boolean }[],
|
||||
): Promise<TaskDetailResponse> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
const membership = await this.assertMember(project.id, actingUserId);
|
||||
@@ -900,6 +901,20 @@ export class TaskService {
|
||||
task.approvalNote = null;
|
||||
task.approvalRequestedAt = null;
|
||||
|
||||
// 항목별 완료 검토 결과 반영(체크=완성 / X=미완성) — 전달된 항목만 갱신
|
||||
if (reviews?.length && task.checklist?.length) {
|
||||
const reviewMap = new Map(reviews.map((r) => [r.id, r.done]));
|
||||
let checklistChanged = false;
|
||||
for (const item of task.checklist) {
|
||||
const reviewed = reviewMap.get(item.id);
|
||||
if (reviewed !== undefined && item.done !== reviewed) {
|
||||
item.done = reviewed;
|
||||
checklistChanged = true;
|
||||
}
|
||||
}
|
||||
if (checklistChanged) await this.checklistRepo.save(task.checklist);
|
||||
}
|
||||
|
||||
await this.transition(project, task, membership, actingUserId, 'changes');
|
||||
return this.buildDetail(
|
||||
await this.getTaskOrThrow(project.id, seq),
|
||||
|
||||
Reference in New Issue
Block a user