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),
|
||||
|
||||
@@ -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<ApiTaskDetail> {
|
||||
return (await api.post(`/projects/${projectId}/tasks/${taskId}/approval/changes`, {
|
||||
note,
|
||||
checklist,
|
||||
})) as unknown as ApiTaskDetail
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Record<string, boolean>>({})
|
||||
const reviewDoneCount = computed(
|
||||
() => Object.values(reviewChecks.value).filter(Boolean).length,
|
||||
)
|
||||
// 수정 요청 모달 열기 — 체크리스트 검토 상태를 현재 완료값으로 초기화
|
||||
function openChanges() {
|
||||
const map: Record<string, boolean> = {}
|
||||
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 } {
|
||||
<button
|
||||
class="ap-btn reject"
|
||||
type="button"
|
||||
@click="showChanges = true"
|
||||
@click="openChanges"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -1289,8 +1324,68 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
수정 요청
|
||||
</h2>
|
||||
<p class="modal-desc spaced">
|
||||
보완이 필요한 내용을 적어주세요. 담당자에게 전달되며 업무가 다시 <b>진행 중</b>으로 돌아갑니다.
|
||||
완성된 항목은 체크(✓), 보완이 필요한 항목은 X로 표시한 뒤 메시지를 적어주세요.
|
||||
담당자에게 전달되며 업무가 다시 <b>진행 중</b>으로 돌아갑니다.
|
||||
</p>
|
||||
|
||||
<!-- 할 일 완료 검토 — 항목별 체크/X -->
|
||||
<div
|
||||
v-if="task && task.checklist.length"
|
||||
class="review-block"
|
||||
>
|
||||
<div class="review-head">
|
||||
<span class="review-title">할 일 완료 검토</span>
|
||||
<span class="review-count">
|
||||
{{ reviewDoneCount }} / {{ task.checklist.length }} 완성
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="group in checklistGroups"
|
||||
:key="group.category"
|
||||
class="review-group"
|
||||
>
|
||||
<div
|
||||
class="cat-group-head"
|
||||
:class="categoryClass(group.category)"
|
||||
>
|
||||
<span class="cat-dot" />
|
||||
<span class="cat-name">{{ group.category }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="review-item"
|
||||
:class="reviewState(item) ? 'ok' : 'no'"
|
||||
@click="toggleReview(item)"
|
||||
>
|
||||
<span class="review-box">
|
||||
<svg
|
||||
v-if="reviewState(item)"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M20 6 9 17l-5-5" /></svg>
|
||||
<svg
|
||||
v-else
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</span>
|
||||
<span class="review-text">{{ item.text }}</span>
|
||||
<span class="review-tag">{{ reviewState(item) ? '완성' : '미완성' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="review-field-label">수정 요청 메시지</label>
|
||||
<textarea
|
||||
v-model="changesNote"
|
||||
class="modal-textarea"
|
||||
@@ -1511,6 +1606,107 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 수정 요청 모달 — 할 일 완료 검토(체크/X) */
|
||||
.review-block {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--bg-soft, #f9fafb);
|
||||
}
|
||||
.review-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.review-title {
|
||||
font-size: 0.813rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.review-count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.review-group + .review-group {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.review-group .cat-group-head {
|
||||
padding: 0.25rem 0 0.125rem;
|
||||
}
|
||||
.review-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.4375rem 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition:
|
||||
background 0.12s,
|
||||
border-color 0.12s;
|
||||
}
|
||||
.review-item:hover {
|
||||
background: #fff;
|
||||
}
|
||||
.review-box {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 0.375rem;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
background: #fff;
|
||||
color: #fff;
|
||||
}
|
||||
.review-box svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
.review-item.ok .review-box {
|
||||
background: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
.review-item.no .review-box {
|
||||
background: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
.review-text {
|
||||
flex: 1;
|
||||
font-size: 0.813rem;
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.review-tag {
|
||||
font-size: 0.688rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
padding: 0.0625rem 0.4375rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.review-item.ok .review-tag {
|
||||
color: #1b7a47;
|
||||
background: #e7f3ee;
|
||||
}
|
||||
.review-item.no .review-tag {
|
||||
color: #c0394f;
|
||||
background: #fdeef0;
|
||||
}
|
||||
.review-field-label {
|
||||
display: block;
|
||||
font-size: 0.781rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
/* 체크리스트(읽기 전용) */
|
||||
.checklist {
|
||||
display: flex;
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
ApiComment,
|
||||
ApiProjectTask,
|
||||
ApiTaskDetail,
|
||||
ChecklistReviewItem,
|
||||
CreateTaskPayload,
|
||||
TaskListQuery,
|
||||
TaskSegmentCounts,
|
||||
@@ -368,8 +369,11 @@ export const useTaskStore = defineStore('task', () => {
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
note: string,
|
||||
checklist?: ChecklistReviewItem[],
|
||||
): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.requestChanges(projectId, taskId, note))
|
||||
const view = toTaskDetailView(
|
||||
await taskApi.requestChanges(projectId, taskId, note, checklist),
|
||||
)
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
@@ -136,6 +136,13 @@ export interface AddReplyPayload {
|
||||
export interface ApprovalRequestPayload {
|
||||
note?: string
|
||||
}
|
||||
// 수정 요청 시 항목별 완료 검토(체크/X)
|
||||
export interface ChecklistReviewItem {
|
||||
id: string
|
||||
done: boolean
|
||||
}
|
||||
export interface ApprovalChangesPayload {
|
||||
note: string
|
||||
// 항목별 완료 검토 결과(생략 시 체크리스트 변경 없음)
|
||||
checklist?: ChecklistReviewItem[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user