From 9d004dd64eebcaf3e4dc51d9085dfa0fa7283b01 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sun, 21 Jun 2026 18:26:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=95=A0=20=EC=9D=BC=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=EB=B3=84=20=EB=B3=B4=EC=99=84=20=EB=82=B4=EC=9A=A9=20?= =?UTF-8?q?=EA=B0=9C=EB=B3=84=20=EC=9E=85=EB=A0=A5=C2=B7=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공용 수정 요청 메시지는 그대로 두고, 검토 모달에서 각 보완 항목 아래에 항목별 보완 내용을 개별 등록할 수 있게 하고 상세 화면에 함께 표시한다. - 백엔드: ChecklistItem.reviewNote 컬럼 추가, ChecklistReviewDto.note 수용, requestChanges 가 미완성 항목에 보완 내용 보관(완료/승인 시 비움), 상세 응답 동봉 - 프론트: 검토 모달 보완 항목 아래 보완 내용 textarea(개별), 상세 체크리스트 보완 항목 아래 보완 내용 표시. 공용 메시지 입력은 유지 - 테스트: 미완성 항목 보완 내용 보관·완료 항목 비움 검증(9 tests) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/modules/task/dto/approval.dto.ts | 11 +- .../task/entities/checklist-item.entity.ts | 4 + backend/src/modules/task/task.service.spec.ts | 15 +- backend/src/modules/task/task.service.ts | 24 +- frontend/src/mock/relay.mock.ts | 2 + frontend/src/pages/relay/TaskDetailPage.vue | 221 ++++++++++++------ frontend/src/stores/task.store.ts | 1 + frontend/src/types/task.ts | 6 +- 8 files changed, 199 insertions(+), 85 deletions(-) diff --git a/backend/src/modules/task/dto/approval.dto.ts b/backend/src/modules/task/dto/approval.dto.ts index 403c102..f5e6207 100644 --- a/backend/src/modules/task/dto/approval.dto.ts +++ b/backend/src/modules/task/dto/approval.dto.ts @@ -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; } // 수정 요청(지시자 → 담당자) — 보완 내용 필수 + 항목별 완료 체크(선택) diff --git a/backend/src/modules/task/entities/checklist-item.entity.ts b/backend/src/modules/task/entities/checklist-item.entity.ts index bd77787..02ace26 100644 --- a/backend/src/modules/task/entities/checklist-item.entity.ts +++ b/backend/src/modules/task/entities/checklist-item.entity.ts @@ -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; diff --git a/backend/src/modules/task/task.service.spec.ts b/backend/src/modules/task/task.service.spec.ts index 8fd5fa1..c5234f3 100644 --- a/backend/src/modules/task/task.service.spec.ts +++ b/backend/src/modules/task/task.service.spec.ts @@ -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('보완 바랍니다'); }); diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index d3e0ef1..a8e5821 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -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 { 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) 상태이고 요청 메타가 있을 때만 노출 diff --git a/frontend/src/mock/relay.mock.ts b/frontend/src/mock/relay.mock.ts index 4ef0836..1aa6ef8 100644 --- a/frontend/src/mock/relay.mock.ts +++ b/frontend/src/mock/relay.mock.ts @@ -43,6 +43,8 @@ export interface ChecklistItem { text: string done: boolean category: ChecklistCategory + /** 보완 내용(수정 요청 시 항목별 입력) — 없으면 null */ + reviewNote?: string | null } /** 프로젝트 — 작업의 기본 단위(우선) */ diff --git a/frontend/src/pages/relay/TaskDetailPage.vue b/frontend/src/pages/relay/TaskDetailPage.vue index 8ebd411..cb4933a 100644 --- a/frontend/src/pages/relay/TaskDetailPage.vue +++ b/frontend/src/pages/relay/TaskDetailPage.vue @@ -140,6 +140,8 @@ async function confirmDelete() { const changesNote = ref('') // 항목 ID → 완료 여부(완료=true / 보완=false) const reviewChecks = ref>({}) +// 항목 ID → 보완 내용(항목별 개별 입력) +const reviewNotes = ref>({}) const reviewTotal = computed(() => task.value?.checklist.length ?? 0) const reviewDoneCount = computed( () => Object.values(reviewChecks.value).filter(Boolean).length, @@ -165,11 +167,15 @@ const assigneeNames = computed(() => { }) // 수정 요청 모달 열기 — 모든 항목을 '완료'로 초기화(담당자가 완료를 주장하며 요청) function openChanges() { - const map: Record = {} + const checks: Record = {} + const notes: Record = {} for (const item of task.value?.checklist ?? []) { - if (item.id) map[item.id] = true + if (!item.id) continue + checks[item.id] = true + notes[item.id] = item.reviewNote ?? '' } - reviewChecks.value = map + reviewChecks.value = checks + reviewNotes.value = notes changesNote.value = '' showChanges.value = true } @@ -180,6 +186,13 @@ function setReview(item: ChecklistItem, done: boolean) { if (!item.id) return reviewChecks.value = { ...reviewChecks.value, [item.id]: done } } +function reviewNoteOf(item: ChecklistItem): string { + return item.id ? (reviewNotes.value[item.id] ?? '') : '' +} +function setReviewNote(item: ChecklistItem, val: string) { + if (!item.id) return + reviewNotes.value = { ...reviewNotes.value, [item.id]: val } +} // 카테고리 → 그룹 도트 색 클래스 function groupClass(cat: ChecklistCategory): string { const map: Record = { @@ -199,7 +212,13 @@ async function submitChanges() { } else { const checklist: ChecklistReviewItem[] = (task.value.checklist ?? []) .filter((c): c is ChecklistItem & { id: string } => !!c.id) - .map((c) => ({ id: c.id, done: !!reviewChecks.value[c.id] })) + .map((c) => { + const done = !!reviewChecks.value[c.id] + // 보완(미완료) 항목만 항목별 보완 내용 동봉 + if (done) return { id: c.id, done } + const note = (reviewNotes.value[c.id] ?? '').trim() + return { id: c.id, done, note: note || undefined } + }) task.value = await taskStore.requestChanges( projectId.value, task.value.id, @@ -580,37 +599,48 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
- - - - - {{ item.text }} - 보완 필요 + + + + + {{ item.text }} + 보완 필요 +
+ +
+ {{ item.reviewNote }} +
- - - - - {{ item.text }} -
- - +
+ + + + + {{ item.text }} +
+ + +
+ +