From 79e0c820ba37b874567401fe5cdf67eae80941f8 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sun, 21 Jun 2026 16:46:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=88=98=EC=A0=95=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=20=EC=8B=9C=20=ED=95=A0=20=EC=9D=BC=20=ED=95=AD=EB=AA=A9?= =?UTF-8?q?=EB=B3=84=20=EC=99=84=EB=A3=8C=20=EC=B2=B4=ED=81=AC/X=20?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지시자가 승인을 검토하며 수정 요청할 때, 단순 텍스트 입력에 더해 할 일 목록에서 어떤 항목이 완성/미완성인지 체크(✓)·X로 표시할 수 있게 한다. - 백엔드: ApprovalChangesDto 에 항목별 검토 결과(checklist) 추가, requestChanges 가 전달된 항목만 done 상태로 반영(미전달 항목은 보존) - 프론트: 수정 요청 모달에 카테고리별 체크/X 토글 UI 추가, 검토 결과를 note 와 함께 전송 - 테스트: 전달된 항목만 완료 체크가 반영되는지 검증(9 tests) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/modules/task/dto/approval.dto.ts | 38 +++- backend/src/modules/task/task.controller.ts | 1 + backend/src/modules/task/task.service.spec.ts | 41 ++++ backend/src/modules/task/task.service.ts | 17 +- frontend/src/composables/useTask.ts | 3 + frontend/src/pages/relay/TaskDetailPage.vue | 204 +++++++++++++++++- frontend/src/stores/task.store.ts | 6 +- frontend/src/types/task.ts | 7 + 8 files changed, 309 insertions(+), 8 deletions(-) diff --git a/backend/src/modules/task/dto/approval.dto.ts b/backend/src/modules/task/dto/approval.dto.ts index 57439d3..403c102 100644 --- a/backend/src/modules/task/dto/approval.dto.ts +++ b/backend/src/modules/task/dto/approval.dto.ts @@ -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[]; } diff --git a/backend/src/modules/task/task.controller.ts b/backend/src/modules/task/task.controller.ts index 2de1b57..df159e3 100644 --- a/backend/src/modules/task/task.controller.ts +++ b/backend/src/modules/task/task.controller.ts @@ -323,6 +323,7 @@ export class TaskController { user.id, taskId, dto.note, + dto.checklist, ); } } diff --git a/backend/src/modules/task/task.service.spec.ts b/backend/src/modules/task/task.service.spec.ts index 1947494..8fd5fa1 100644 --- a/backend/src/modules/task/task.service.spec.ts +++ b/backend/src/modules/task/task.service.spec.ts @@ -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, diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index 294e34d..d3e0ef1 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -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 { 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), diff --git a/frontend/src/composables/useTask.ts b/frontend/src/composables/useTask.ts index 9bafdcb..8c10489 100644 --- a/frontend/src/composables/useTask.ts +++ b/frontend/src/composables/useTask.ts @@ -5,6 +5,7 @@ import type { ApiCommentReply, ApiProjectTaskListResult, ApiTaskDetail, + ChecklistReviewItem, CreateTaskPayload, TaskListQuery, UpdateTaskPayload, @@ -140,9 +141,11 @@ export function useTask() { projectId: string, taskId: number, note: string, + checklist?: ChecklistReviewItem[], ): Promise { return (await api.post(`/projects/${projectId}/tasks/${taskId}/approval/changes`, { note, + checklist, })) as unknown as ApiTaskDetail } diff --git a/frontend/src/pages/relay/TaskDetailPage.vue b/frontend/src/pages/relay/TaskDetailPage.vue index 3338e8d..1e522b5 100644 --- a/frontend/src/pages/relay/TaskDetailPage.vue +++ b/frontend/src/pages/relay/TaskDetailPage.vue @@ -16,11 +16,13 @@ import { CHECKLIST_CATEGORIES, type Attachment, type ChecklistCategory, + type ChecklistItem, type Comment, type TaskDetail, type TaskStatus, type User, } from '@/mock/relay.mock' +import type { ChecklistReviewItem } from '@/types/task' import { useTaskStore } from '@/stores/task.store' import { useAuthStore } from '@/stores/auth.store' import UserAvatar from '@/components/UserAvatar.vue' @@ -144,14 +146,47 @@ async function approveTask() { showApprove.value = false } } -// 수정 요청(review→changes) — 지시자 액션. 보완 메시지 필수 +// 수정 요청(review→changes) — 지시자 액션. 보완 메시지 필수 + 항목별 완료 체크 const changesNote = ref('') +// 항목 ID → 완성 여부(체크=true / X=false). 모달을 열 때 현재 완료 상태로 초기화 +const reviewChecks = ref>({}) +const reviewDoneCount = computed( + () => Object.values(reviewChecks.value).filter(Boolean).length, +) +// 수정 요청 모달 열기 — 체크리스트 검토 상태를 현재 완료값으로 초기화 +function openChanges() { + const map: Record = {} + for (const item of task.value?.checklist ?? []) { + if (item.id) map[item.id] = item.done + } + reviewChecks.value = map + changesNote.value = '' + showChanges.value = true +} +function reviewState(item: ChecklistItem): boolean { + return item.id ? !!reviewChecks.value[item.id] : false +} +function toggleReview(item: ChecklistItem) { + if (!item.id) return + reviewChecks.value = { + ...reviewChecks.value, + [item.id]: !reviewChecks.value[item.id], + } +} async function submitChanges() { if (!task.value) return const note = changesNote.value.trim() if (!note) return + 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] })) try { - task.value = await taskStore.requestChanges(projectId.value, task.value.id, note) + task.value = await taskStore.requestChanges( + projectId.value, + task.value.id, + note, + checklist.length ? checklist : undefined, + ) } catch { // 인터셉터가 알림 처리 } finally { @@ -823,7 +858,7 @@ function badgeFor(name: string | undefined): { label: string; cls: string } { + + + +