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
+3
View File
@@ -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
}
+200 -4
View File
@@ -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;
+5 -1
View File
@@ -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
}
+7
View File
@@ -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[]
}