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:
@@ -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