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) 상태이고 요청 메타가 있을 때만 노출
+2
View File
@@ -43,6 +43,8 @@ export interface ChecklistItem {
text: string
done: boolean
category: ChecklistCategory
/** 보완 내용(수정 요청 시 항목별 입력) — 없으면 null */
reviewNote?: string | null
}
/** 프로젝트 — 작업의 기본 단위(우선) */
+150 -71
View File
@@ -140,6 +140,8 @@ async function confirmDelete() {
const changesNote = ref('')
// 항목 ID → 완료 여부(완료=true / 보완=false)
const reviewChecks = ref<Record<string, boolean>>({})
// 항목 ID → 보완 내용(항목별 개별 입력)
const reviewNotes = ref<Record<string, string>>({})
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<string, boolean> = {}
const checks: Record<string, boolean> = {}
const notes: Record<string, string> = {}
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<ChecklistCategory, string> = {
@@ -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 } {
<div
v-for="(item, i) in group.items"
:key="item.id ?? i"
class="citem"
:class="{ done: item.done, todo: !item.done && isChangesReview }"
class="citem-wrap"
>
<span
class="cbox"
<div
class="citem"
:class="{ done: item.done, todo: !item.done && isChangesReview }"
>
<svg
v-if="item.done"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3.5"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
<svg
v-else-if="isChangesReview"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3.5"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</span>
<span class="ctext">{{ item.text }}</span>
<span
v-if="!item.done && isChangesReview"
class="citem-tag"
>보완 필요</span>
<span
class="cbox"
:class="{ done: item.done, todo: !item.done && isChangesReview }"
>
<svg
v-if="item.done"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3.5"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
<svg
v-else-if="isChangesReview"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3.5"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</span>
<span class="ctext">{{ item.text }}</span>
<span
v-if="!item.done && isChangesReview"
class="citem-tag"
>보완 필요</span>
</div>
<!-- 항목별 보완 내용(수정 요청 입력된 경우) -->
<div
v-if="!item.done && isChangesReview && item.reviewNote"
class="citem-note"
>
{{ item.reviewNote }}
</div>
</div>
</div>
<div
@@ -1361,46 +1391,58 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
<div
v-for="item in group.items"
:key="item.id"
class="ck-item"
:class="reviewState(item) ? 'done' : 'todo'"
class="ck-item-wrap"
>
<span class="ck-state">
<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="ck-text">{{ item.text }}</span>
<div class="seg">
<button
type="button"
class="done"
@click="setReview(item, true)"
>
완료
</button>
<button
type="button"
class="todo"
@click="setReview(item, false)"
>
보완
</button>
<div
class="ck-item"
:class="reviewState(item) ? 'done' : 'todo'"
>
<span class="ck-state">
<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="ck-text">{{ item.text }}</span>
<div class="seg">
<button
type="button"
class="done"
@click="setReview(item, true)"
>
완료
</button>
<button
type="button"
class="todo"
@click="setReview(item, false)"
>
보완
</button>
</div>
</div>
<!-- 보완 항목 항목별 보완 내용(선택) 개별 입력 -->
<textarea
v-if="!reviewState(item)"
class="ck-note-ta"
:value="reviewNoteOf(item)"
placeholder="이 항목의 보완 내용을 입력하세요 (선택)"
@input="setReviewNote(item, ($event.target as HTMLTextAreaElement).value)"
/>
</div>
</div>
</div>
@@ -1880,12 +1922,14 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
.g-ext .ck-dot {
background: var(--amber);
}
.ck-item-wrap {
margin-bottom: 0.5625rem;
}
.ck-item {
display: flex;
align-items: center;
gap: 0.8125rem;
padding: 0.6875rem 0.75rem;
margin-bottom: 0.5625rem;
border: 1px solid var(--border);
border-radius: 0.5625rem;
background: #fff;
@@ -1894,6 +1938,29 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
background 0.14s,
border-color 0.14s;
}
/* 항목별 보완 내용 입력(보완 항목 아래) */
.ck-note-ta {
width: 100%;
min-height: 2.75rem;
margin-top: 0.375rem;
border: 1px solid var(--red-border);
border-radius: 0.5rem;
padding: 0.5rem 0.625rem;
font-family: inherit;
font-size: 0.781rem;
color: var(--text);
line-height: 1.5;
resize: vertical;
background: #fffafb;
}
.ck-note-ta::placeholder {
color: var(--text-3);
}
.ck-note-ta:focus {
outline: none;
border-color: var(--red);
box-shadow: 0 0 0 3px var(--red-weak);
}
.ck-item::before {
content: '';
position: absolute;
@@ -2202,6 +2269,18 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
padding: 0.5rem 0;
border-radius: var(--radius-sm);
}
/* 항목별 보완 내용(읽기 전용) — 체크박스+간격(1.8125rem)만큼 들여쓰기 */
.citem-note {
margin: 0 0 0.375rem 1.8125rem;
padding: 0.5rem 0.6875rem;
font-size: 0.781rem;
color: var(--text-2);
line-height: 1.55;
background: var(--red-weak);
border: 1px solid var(--red-border);
border-radius: 0.5rem;
white-space: pre-wrap;
}
.cbox {
width: 1.125rem;
height: 1.125rem;
+1
View File
@@ -182,6 +182,7 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
text: c.text,
done: c.done,
category: c.category,
reviewNote: c.reviewNote,
})),
comments: api.comments.map((c) => toCommentView(c, issuerId)),
activities: api.activities.map(toTaskActivityView),
+5 -1
View File
@@ -23,6 +23,8 @@ export interface ApiChecklistItem {
text: string
done: boolean
category: ChecklistCategory
// 보완 내용(수정 요청 시 항목별 입력) — 없으면 null
reviewNote: string | null
}
// 첨부파일(API 원시) — 백엔드 AttachmentResponse 와 1:1
@@ -136,10 +138,12 @@ export interface AddReplyPayload {
export interface ApprovalRequestPayload {
note?: string
}
// 수정 요청 시 항목별 완료 검토(체크/X)
// 수정 요청 시 항목별 완료 검토(체크/X) + 항목별 보완 내용
export interface ChecklistReviewItem {
id: string
done: boolean
// 보완 내용(미완성 항목에만 적용)
note?: string
}
export interface ApprovalChangesPayload {
note: string