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:
2026-06-21 16:46:03 +09:00
parent c86c04f99e
commit 79e0c820ba
8 changed files with 309 additions and 8 deletions
+36 -2
View File
@@ -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,
+16 -1
View File
@@ -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),