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:
@@ -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;
|
||||
|
||||
@@ -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('보완 바랍니다');
|
||||
});
|
||||
|
||||
@@ -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) 상태이고 요청 메타가 있을 때만 노출
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface ChecklistItem {
|
||||
text: string
|
||||
done: boolean
|
||||
category: ChecklistCategory
|
||||
/** 보완 내용(수정 요청 시 항목별 입력) — 없으면 null */
|
||||
reviewNote?: string | null
|
||||
}
|
||||
|
||||
/** 프로젝트 — 작업의 기본 단위(우선) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user