feat: 할 일 항목별 보완 내용 개별 입력·표시 추가

공용 수정 요청 메시지는 그대로 두고, 검토 모달에서 각 보완 항목 아래에
항목별 보완 내용을 개별 등록할 수 있게 하고 상세 화면에 함께 표시한다.

- 백엔드: ChecklistItem.reviewNote 컬럼 추가, ChecklistReviewDto.note 수용,
  requestChanges 가 미완성 항목에 보완 내용 보관(완료/승인 시 비움), 상세 응답 동봉
- 프론트: 검토 모달 보완 항목 아래 보완 내용 textarea(개별), 상세 체크리스트
  보완 항목 아래 보완 내용 표시. 공용 메시지 입력은 유지
- 테스트: 미완성 항목 보완 내용 보관·완료 항목 비움 검증(9 tests)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 18:26:48 +09:00
parent c32df3cf39
commit 9d004dd64e
8 changed files with 199 additions and 85 deletions
+10 -1
View File
@@ -25,7 +25,7 @@ export class ApprovalRequestDto {
note?: string;
}
// 수정 요청 시 항목별 완료 검토 결과(지시자가 체크/X 표시)
// 수정 요청 시 항목별 완료 검토 결과(지시자가 체크/X 표시 + 항목별 보완 내용)
export class ChecklistReviewDto {
@ApiProperty({ description: '체크리스트 항목 ID' })
@IsUUID('4')
@@ -34,6 +34,15 @@ export class ChecklistReviewDto {
@ApiProperty({ description: '완료 여부 — true=완성(체크) / false=미완성(X)' })
@IsBoolean()
done!: boolean;
@ApiProperty({
description: '항목별 보완 내용(미완성 항목에만 적용). 생략 시 보완 내용 없음',
required: false,
})
@IsOptional()
@IsString()
@MaxLength(2000)
note?: string;
}
// 수정 요청(지시자 → 담당자) — 보완 내용 필수 + 항목별 완료 체크(선택)
@@ -35,6 +35,10 @@ export class ChecklistItem {
@Column({ default: false })
done!: boolean;
// 보완 내용(수정 요청 시 항목별로 입력) — 완료 처리 시 비워짐
@Column({ name: 'review_note', type: 'text', nullable: true })
reviewNote!: string | null;
// 표시 순서(생성 순)
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder!: number;
+10 -5
View File
@@ -189,15 +189,20 @@ describe('TaskService', () => {
checklistRepo.save.mockResolvedValue(task.checklist);
const result = await svc.requestChanges('q3-launch', 'admin', 9, '보완 바랍니다', [
{ id: 'c1', done: false }, // 완성 → 미완성(X)
{ id: 'c2', done: true }, // 미완성 → 완성(체크)
{ id: 'c1', done: false, note: '여백을 더 주세요' }, // 완성 → 미완성(X) + 보완 내용
{ id: 'c2', done: true, note: '무시됨' }, // 미완성 → 완성(체크): 보완 내용 비움
// 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);
const c1 = task.checklist.find((c) => c.id === 'c1');
const c2 = task.checklist.find((c) => c.id === 'c2');
const c3 = task.checklist.find((c) => c.id === 'c3');
expect(c1?.done).toBe(false);
expect(c1?.reviewNote).toBe('여백을 더 주세요'); // 미완성 항목은 보완 내용 보관
expect(c2?.done).toBe(true);
expect(c2?.reviewNote).toBeNull(); // 완료 항목은 보완 내용 비움
expect(c3?.done).toBe(true);
expect(result.status).toBe('changes');
expect(result.changesNote).toBe('보완 바랍니다');
});
+17 -7
View File
@@ -56,6 +56,8 @@ export interface ChecklistItemResponse {
text: string;
done: boolean;
category: ChecklistCategory;
// 보완 내용(수정 요청 시 항목별 입력) — 없으면 null
reviewNote: string | null;
}
// 프로젝트 업무 목록의 세그먼트별 카운트(전체/진행/대기/완료) — 검색어 반영
@@ -891,7 +893,7 @@ export class TaskService {
actingUserId: string,
seq: number,
note: string,
reviews?: { id: string; done: boolean }[],
reviews?: { id: string; done: boolean; note?: string }[],
): Promise<TaskDetailResponse> {
const project = await this.getProjectOrThrow(projectId);
const membership = await this.assertMember(project.id, actingUserId);
@@ -901,14 +903,18 @@ export class TaskService {
task.approvalNote = null;
task.approvalRequestedAt = null;
// 항목별 완료 검토 결과 반영(체크=완성 / X=미완성) — 전달된 항목만 갱신
// 항목별 완료 검토 결과 반영(체크=완성 / X=미완성 + 항목별 보완 내용) — 전달된 항목만 갱신
if (reviews?.length && task.checklist?.length) {
const reviewMap = new Map(reviews.map((r) => [r.id, r.done]));
const reviewMap = new Map(reviews.map((r) => [r.id, r]));
let checklistChanged = false;
for (const item of task.checklist) {
const reviewed = reviewMap.get(item.id);
if (reviewed !== undefined && item.done !== reviewed) {
item.done = reviewed;
if (!reviewed) continue;
// 완료 처리된 항목은 보완 내용 비움, 미완성 항목만 보완 내용 보관
const nextNote = reviewed.done ? null : (reviewed.note?.trim() || null);
if (item.done !== reviewed.done || item.reviewNote !== nextNote) {
item.done = reviewed.done;
item.reviewNote = nextNote;
checklistChanged = true;
}
}
@@ -1041,9 +1047,12 @@ export class TaskService {
task.status = next;
await this.taskRepo.save(task);
// 승인 완료(→done) 시 체크리스트 항목을 모두 완료 처리(부수효과로 영속)
// 승인 완료(→done) 시 체크리스트 항목을 모두 완료 처리 + 보완 내용 비움(부수효과로 영속)
if (next === 'done' && task.checklist?.length) {
for (const item of task.checklist) item.done = true;
for (const item of task.checklist) {
item.done = true;
item.reviewNote = null;
}
await this.checklistRepo.save(task.checklist);
}
@@ -1262,6 +1271,7 @@ export class TaskService {
text: c.text,
done: c.done,
category: c.category,
reviewNote: c.reviewNote ?? null,
}));
// 승인 정보는 승인 대기(review) 상태이고 요청 메타가 있을 때만 노출