feat: 수정 요청 모달을 '할 일 완료 검토' 디자인으로 개편

시안(수정 요청 모달.html)을 반영해 모달 UI를 재구성한다.

- 헤더(그라데이션 배지·안내)·스크롤 본문·고정 푸터 구조로 변경
- 항목별 완료/보완을 세그먼트 토글(완료=초록 / 보완=빨강 좌측 바)로 표시,
  카테고리 그룹·진행률 바·완료 카운트 동봉
- 보완 항목 수에 따라 안내 배너(warn/good)와 메시지 필수 여부가 전환
- 모든 항목 완료 시 버튼이 '승인 완료'(초록)로 바뀌어 곧바로 승인,
  보완 항목이 있으면 '수정 요청 보내기 (N건)'(빨강)으로 메시지와 함께 반려
- 모달 진입 시 전 항목을 '완료'로 초기화(담당자가 완료를 주장하며 요청)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 17:18:53 +09:00
parent 79e0c820ba
commit 20f593bd5a
+639 -141
View File
@@ -146,18 +146,38 @@ async function approveTask() {
showApprove.value = false
}
}
// 수정 요청(review→changes) — 지시자 액션. 보완 메시지 필수 + 항목별 완료 체크
// 할 일 완료 검토(지시자) — 모든 항목 '완료'면 승인, 보완 항목이 있으면 메시지와 함께 수정 요청
const changesNote = ref('')
// 항목 ID → 완 여부(체크=true / X=false). 모달을 열 때 현재 완료 상태로 초기화
// 항목 ID → 완 여부(완료=true / 보완=false)
const reviewChecks = ref<Record<string, boolean>>({})
const reviewTotal = computed(() => task.value?.checklist.length ?? 0)
const reviewDoneCount = computed(
() => Object.values(reviewChecks.value).filter(Boolean).length,
)
// 수정 요청 모달 열기 — 체크리스트 검토 상태를 현재 완료값으로 초기화
const reviewTodoCount = computed(() => reviewTotal.value - reviewDoneCount.value)
const reviewPct = computed(() =>
reviewTotal.value
? Math.round((reviewDoneCount.value / reviewTotal.value) * 100)
: 0,
)
// 보완 항목이 없으면 곧바로 승인 가능
const reviewAllDone = computed(() => reviewTodoCount.value === 0)
// 제출 가능 여부 — 보완 항목이 있으면 메시지 필수
const reviewSubmittable = computed(
() => reviewAllDone.value || changesNote.value.trim().length > 0,
)
// 담당자 표기(푸터)
const assigneeNames = computed(() => {
const names = task.value?.assignees.map((a) => a.name) ?? []
if (names.length === 0) return '담당자'
if (names.length === 1) return names[0]
return `${names[0]}${names.length - 1}`
})
// 수정 요청 모달 열기 — 모든 항목을 '완료'로 초기화(담당자가 완료를 주장하며 요청)
function openChanges() {
const map: Record<string, boolean> = {}
for (const item of task.value?.checklist ?? []) {
if (item.id) map[item.id] = item.done
if (item.id) map[item.id] = true
}
reviewChecks.value = map
changesNote.value = ''
@@ -166,27 +186,37 @@ function openChanges() {
function reviewState(item: ChecklistItem): boolean {
return item.id ? !!reviewChecks.value[item.id] : false
}
function toggleReview(item: ChecklistItem) {
function setReview(item: ChecklistItem, done: boolean) {
if (!item.id) return
reviewChecks.value = {
...reviewChecks.value,
[item.id]: !reviewChecks.value[item.id],
}
reviewChecks.value = { ...reviewChecks.value, [item.id]: done }
}
// 카테고리 → 그룹 도트 색 클래스
function groupClass(cat: ChecklistCategory): string {
const map: Record<ChecklistCategory, string> = {
필수: 'g-req',
관리: 'g-mng',
보안: 'g-sec',
부가: 'g-ext',
}
return map[cat]
}
// 검토 제출 — 전부 완료면 승인, 보완 항목이 있으면 수정 요청
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] }))
if (!task.value || !reviewSubmittable.value) return
try {
task.value = await taskStore.requestChanges(
projectId.value,
task.value.id,
note,
checklist.length ? checklist : undefined,
)
if (reviewAllDone.value) {
task.value = await taskStore.approve(projectId.value, task.value.id)
} 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] }))
task.value = await taskStore.requestChanges(
projectId.value,
task.value.id,
changesNote.value.trim(),
checklist.length ? checklist : undefined,
)
}
} catch {
// 인터셉터가 알림 처리
} finally {
@@ -1308,58 +1338,92 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
</div>
</div>
<!-- 수정 요청 -->
<!-- 완료 검토(수정 요청 / 승인) -->
<div
class="modal-backdrop"
:class="{ open: showChanges }"
@click.self="showChanges = false"
>
<div
class="modal"
v-if="task"
class="rv-modal"
role="dialog"
aria-modal="true"
aria-label=" 완료 검토"
>
<div class="modal-body left">
<h2 class="modal-title">
수정 요청
</h2>
<p class="modal-desc spaced">
완성된 항목은 체크(), 보완이 필요한 항목은 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 class="rv-head">
<div class="rv-head-top">
<div class="rv-badge">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" /></svg>
</div>
<div class="rv-htext">
<div class="rv-title">
완료 검토
</div>
<div class="rv-sub">
완료된 항목은 <span class="ok">완료</span>, 보완이 필요한 항목은 <span class="no">보완</span>으로 표시하세요.
보완 항목이 있으면 메시지와 함께 담당자에게 전달되어 업무가 다시 <b>진행 </b>으로 돌아갑니다.
</div>
</div>
</div>
<button
class="rv-x"
type="button"
aria-label="닫기"
@click="showChanges = false"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
<!-- 본문 -->
<div class="rv-body">
<div
v-if="task.checklist.length"
class="ck-card"
>
<div class="ck-top">
<span class="ck-top-name">진행률</span>
<span class="ck-progress">
<span class="pbar"><i
:class="{ red: reviewDoneCount === 0 }"
:style="{ width: reviewPct + '%' }"
/></span>
</span>
<span class="ck-stat"><b>{{ reviewDoneCount }}</b> / {{ reviewTotal }} 완료</span>
</div>
<div
v-for="group in checklistGroups"
:key="group.category"
class="review-group"
class="ck-group"
:class="groupClass(group.category)"
>
<div
class="cat-group-head"
:class="categoryClass(group.category)"
>
<span class="cat-dot" />
<span class="cat-name">{{ group.category }}</span>
<div class="ck-glabel">
<span class="ck-dot" />{{ group.category }}
</div>
<button
<div
v-for="item in group.items"
:key="item.id"
type="button"
class="review-item"
:class="reviewState(item) ? 'ok' : 'no'"
@click="toggleReview(item)"
class="ck-item"
:class="reviewState(item) ? 'done' : 'todo'"
>
<span class="review-box">
<span class="ck-state">
<svg
v-if="reviewState(item)"
viewBox="0 0 24 24"
@@ -1379,34 +1443,117 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
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>
<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>
</div>
</div>
<label class="review-field-label">수정 요청 메시지</label>
<textarea
v-model="changesNote"
class="modal-textarea"
placeholder="예) 세로형 카피 가독성이 낮으니 자간과 대비를 조정해주세요."
/>
<!-- 수정 요청 메시지 -->
<div class="rev-sec">
<div
class="rev-flag"
:class="reviewAllDone ? 'good' : 'warn'"
>
<svg
v-if="reviewAllDone"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
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="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
<span v-if="reviewAllDone">
모든 항목이 완료되었습니다. 메시지 없이 바로 승인할 있어요.
</span>
<span v-else>
<b>{{ reviewTodoCount }}</b> 항목에 보완이 필요합니다. 무엇을 수정해야 하는지 적어 주세요.
</span>
</div>
<div class="rev-label">
수정 요청 메시지
<span class="opt">{{ reviewAllDone ? '· 선택' : '· 보완 항목이 있어 필수' }}</span>
</div>
<textarea
v-model="changesNote"
class="rev-ta"
:disabled="reviewAllDone"
placeholder="예) '로그인 상태 유지' 옵션이 동작하지 않습니다. 체크 후에도 세션이 유지되도록 수정해 주세요."
/>
</div>
</div>
<div class="modal-foot">
<!-- 푸터 -->
<div class="rv-foot">
<span class="foot-note">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z" /></svg>
<span>{{ assigneeNames }} 님에게 전달됩니다</span>
</span>
<button
class="mbtn"
class="btn"
type="button"
@click="showChanges = false"
>
취소
</button>
<button
class="mbtn danger"
class="btn"
:class="reviewAllDone ? 'approve' : 'danger'"
type="button"
:disabled="!changesNote.trim()"
:disabled="!reviewSubmittable"
@click="submitChanges"
>
수정 요청 보내기
<svg
v-if="reviewAllDone"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
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="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z" /></svg>
{{ reviewAllDone ? '승인 완료' : `수정 요청 보내기 (${reviewTodoCount}건)` }}
</button>
</div>
</div>
@@ -1606,105 +1753,456 @@ 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 {
/* ===== 할 일 완료 검토 모달(수정 요청 / 승인) ===== */
.rv-modal {
width: 100%;
max-width: 37.5rem;
max-height: calc(100vh - 3rem);
background: var(--panel);
border-radius: 1rem;
box-shadow: var(--shadow-pop);
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
flex-direction: column;
overflow: hidden;
}
.review-title {
font-size: 0.813rem;
/* 헤더 */
.rv-head {
position: relative;
padding: 1.25rem 1.375rem 1.125rem;
border-bottom: 1px solid var(--border);
}
.rv-head-top {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.rv-badge {
width: 2.375rem;
height: 2.375rem;
border-radius: 0.6875rem;
flex-shrink: 0;
background: linear-gradient(140deg, #6366f1, #4338ca);
display: grid;
place-items: center;
color: #fff;
box-shadow:
0 4px 12px rgba(79, 70, 229, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.rv-badge svg {
width: 1.25rem;
height: 1.25rem;
}
.rv-htext {
flex: 1;
min-width: 0;
padding-top: 0.0625rem;
}
.rv-title {
font-size: 1.0625rem;
font-weight: 700;
letter-spacing: -0.0187rem;
}
.rv-sub {
font-size: 0.781rem;
color: var(--text-2);
margin-top: 0.25rem;
line-height: 1.55;
}
.rv-sub b {
color: var(--text);
font-weight: 600;
}
.rv-sub .ok {
color: var(--green);
font-weight: 700;
}
.rv-sub .no {
color: var(--red);
font-weight: 700;
}
.rv-x {
position: absolute;
top: 1rem;
right: 1rem;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
border: none;
background: transparent;
color: var(--text-3);
display: grid;
place-items: center;
cursor: pointer;
}
.rv-x:hover {
background: rgba(20, 24, 33, 0.06);
color: var(--text);
}
.review-count {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-2);
.rv-x svg {
width: 1.125rem;
height: 1.125rem;
}
.review-group + .review-group {
margin-top: 0.5rem;
/* 본문 */
.rv-body {
overflow-y: auto;
padding: 1.125rem 1.375rem 1.25rem;
flex: 1;
}
.review-group .cat-group-head {
padding: 0.25rem 0 0.125rem;
/* 체크리스트 카드 */
.ck-card {
border: 1px solid var(--border);
border-radius: 0.75rem;
overflow: hidden;
}
.review-item {
.ck-top {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem 0.8125rem;
background: #fbfbfd;
border-bottom: 1px solid var(--border);
}
.ck-top-name {
font-size: 0.844rem;
font-weight: 700;
letter-spacing: -0.0125rem;
white-space: nowrap;
}
.ck-progress {
flex: 1;
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;
min-width: 0;
}
.review-item:hover {
.pbar {
flex: 1;
height: 0.375rem;
border-radius: 0.25rem;
background: #e7e9ed;
overflow: hidden;
min-width: 3.75rem;
}
.pbar > i {
display: block;
height: 100%;
border-radius: 0.25rem;
background: var(--green);
transition: width 0.25s ease;
}
.pbar > i.red {
background: var(--red);
}
.ck-stat {
font-size: 0.781rem;
font-weight: 700;
color: var(--text-2);
white-space: nowrap;
}
.ck-stat b {
color: var(--green);
}
.ck-group {
padding: 0.8125rem 1rem 0.25rem;
}
.ck-group + .ck-group {
border-top: 1px solid var(--border);
}
.ck-glabel {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-2);
margin-bottom: 0.5625rem;
}
.ck-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.g-req .ck-dot {
background: var(--accent);
}
.g-mng .ck-dot {
background: #1b7a47;
}
.g-sec .ck-dot {
background: #c0394f;
}
.g-ext .ck-dot {
background: var(--amber);
}
.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;
position: relative;
transition:
background 0.14s,
border-color 0.14s;
}
.review-box {
width: 1.25rem;
height: 1.25rem;
border-radius: 0.375rem;
.ck-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0.1875rem;
border-radius: 0.5625rem 0 0 0.5625rem;
background: var(--red);
opacity: 0;
transition: opacity 0.14s;
}
.ck-state {
width: 1.875rem;
height: 1.875rem;
border-radius: 0.5rem;
flex-shrink: 0;
display: grid;
place-items: center;
border: 1.5px solid var(--border-strong);
background: #fff;
color: #fff;
transition: background 0.14s;
}
.review-box svg {
.ck-state svg {
width: 1.0625rem;
height: 1.0625rem;
}
.ck-text {
flex: 1;
font-size: 0.844rem;
color: var(--text);
line-height: 1.5;
letter-spacing: -0.00625rem;
}
/* 세그먼트 토글(완료/보완) */
.seg {
display: flex;
background: #f1f2f4;
border: 1px solid var(--border);
border-radius: 0.5625rem;
padding: 0.1875rem;
gap: 0.125rem;
flex-shrink: 0;
}
.seg button {
border: none;
background: transparent;
font-family: inherit;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-3);
padding: 0.375rem 0.6875rem;
border-radius: 0.375rem;
cursor: pointer;
line-height: 1;
white-space: nowrap;
}
.seg button:hover {
color: var(--text-2);
}
/* 완료 상태 */
.ck-item.done {
background: #f4fbf6;
border-color: var(--green-border);
}
.ck-item.done .ck-state {
background: var(--green);
}
.ck-item.done .seg button.done {
background: #fff;
color: var(--green);
box-shadow: var(--shadow-sm);
}
.ck-item.done .seg button.done:hover {
color: var(--green);
}
/* 보완 상태 */
.ck-item.todo {
background: var(--red-weak);
border-color: var(--red-border);
}
.ck-item.todo::before {
opacity: 1;
}
.ck-item.todo .ck-state {
background: var(--red);
}
.ck-item.todo .seg {
background: #f8dde1;
border-color: var(--red-border);
}
.ck-item.todo .seg button.todo {
background: #fff;
color: var(--red);
box-shadow: var(--shadow-sm);
}
.ck-item.todo .seg button.todo:hover {
color: var(--red);
}
/* 수정 요청 메시지 */
.rev-sec {
margin-top: 1.25rem;
}
.rev-flag {
display: flex;
align-items: center;
gap: 0.5625rem;
font-size: 0.781rem;
font-weight: 600;
padding: 0.625rem 0.8125rem;
border-radius: 0.5625rem;
margin-bottom: 0.6875rem;
line-height: 1.5;
}
.rev-flag svg {
width: 0.9375rem;
height: 0.9375rem;
flex-shrink: 0;
}
.rev-flag.warn {
color: #a8324a;
background: var(--red-weak);
border: 1px solid var(--red-border);
}
.rev-flag.good {
color: #1b7a40;
background: var(--green-weak);
border: 1px solid var(--green-border);
}
.rev-label {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.5rem;
}
.rev-label .opt {
font-size: 0.719rem;
font-weight: 500;
color: var(--text-3);
}
.rev-ta {
width: 100%;
min-height: 6rem;
border: 1px solid var(--border-strong);
border-radius: 0.5625rem;
padding: 0.6875rem 0.8125rem;
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
line-height: 1.6;
resize: vertical;
background: #fff;
}
.rev-ta::placeholder {
color: var(--text-3);
}
.rev-ta:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.rev-ta:disabled {
background: #f7f8fa;
color: var(--text-3);
cursor: not-allowed;
resize: none;
}
/* 푸터 */
.rv-foot {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.875rem 1.375rem;
border-top: 1px solid var(--border);
background: #fbfbfd;
}
.foot-note {
margin-right: auto;
font-size: 0.75rem;
color: var(--text-3);
display: flex;
align-items: center;
gap: 0.4375rem;
min-width: 0;
}
.foot-note svg {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
}
.review-item.ok .review-box {
background: var(--green);
border-color: var(--green);
.rv-foot .btn {
height: 2.625rem;
padding: 0 1.25rem;
border-radius: 0.5625rem;
font-size: 0.875rem;
font-weight: 700;
border: 1px solid var(--border-strong);
background: var(--panel);
color: var(--text-2);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
line-height: 1;
font-family: inherit;
white-space: nowrap;
}
.review-item.no .review-box {
.rv-foot .btn:hover {
background: #f7f8fa;
color: var(--text);
}
.rv-foot .btn svg {
width: 1rem;
height: 1rem;
}
.rv-foot .btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.rv-foot .btn.danger {
background: var(--red);
border-color: var(--red);
color: #fff;
box-shadow: 0 1px 2px rgba(214, 69, 93, 0.4);
}
.rv-foot .btn.danger:hover {
background: var(--red-hover, #c23950);
border-color: var(--red-hover, #c23950);
color: #fff;
}
.rv-foot .btn.danger:disabled:hover {
background: var(--red);
border-color: var(--red);
}
.review-text {
flex: 1;
font-size: 0.813rem;
color: var(--text);
line-height: 1.4;
.rv-foot .btn.approve {
background: var(--green);
border-color: var(--green);
color: #fff;
box-shadow: 0 1px 2px rgba(31, 157, 87, 0.38);
}
.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;
.rv-foot .btn.approve:hover {
background: #1b8a4d;
border-color: #1b8a4d;
color: #fff;
}
/* 체크리스트(읽기 전용) */