fix: 할 일 화면 입력·삭제·동기화 문제 수정

할 일 기능 전반을 재검토해 사용자가 바로 겪는 문제를 고친다.

- 이름 수정 시 입력에 포커스를 주지 않아 타이핑도 blur 확정도 되지 않던 문제
- 한글 조합 중 Enter 가 미완성 문자열을 저장하던 문제(추가·수정 양쪽)
- 되돌릴 수 없는 할 일 삭제에 확인 다이얼로그 추가
- 추가 실패 시 입력이 사라지던 문제 — 성공했을 때만 비운다
- 수정 입력에 빠져 있던 maxlength 추가
- 체크 토글을 낙관적으로 반영하고 실패 시 되돌린다
- 같은 항목의 중복 요청을 막아 응답 역순 도착으로 인한 덮어쓰기 방지
- 편집 중인 행이 목록에서 사라지면 편집 상태를 정리
- 처리되지 않은 Promise 거부 제거
- 삭제 버튼이 키보드 포커스 시에도 보이도록 수정
- 업무에서 만든 라벨에 원본 업무 링크 표시(직접 만든 라벨은 표시하지 않음)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 23:03:21 +09:00
parent 8d2436cd10
commit 2ad0629975
3 changed files with 175 additions and 24 deletions
+105 -12
View File
@@ -1,13 +1,18 @@
<script setup lang="ts">
// 라벨 카드 — 헤더(색점 + 이름 + 진행 + 편집/삭제) + 할 일 행 목록 + 인라인 추가 입력.
// 전역 .list 카드 규격을 그대로 쓰고, 행은 카드 안에서 구분선으로 나눈다.
import { ref } from 'vue'
import { nextTick, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useClickOutside } from '@/composables/useClickOutside'
import type { ApiTodoLabel, ApiTodoItem } from '@/types/todo'
const props = defineProps<{ label: ApiTodoLabel }>()
// 추가는 emit 이 아니라 함수 prop 으로 받는다 —
// 성공 여부를 알아야 실패 시 입력을 남겨 둘 수 있기 때문(emit 은 반환값이 없다).
const props = defineProps<{
label: ApiTodoLabel
addItem: (title: string) => Promise<void>
}>()
const emit = defineEmits<{
(e: 'add', title: string): void
(e: 'toggle', item: ApiTodoItem): void
(e: 'rename', item: ApiTodoItem, title: string): void
(e: 'remove', item: ApiTodoItem): void
@@ -20,23 +25,54 @@ const menuOpen = ref(false)
const menuRef = ref<HTMLElement | null>(null)
useClickOutside(menuOpen, () => (menuOpen.value = false), [menuRef])
// 인라인 추가 입력
// 한글 등 IME 조합 중의 Enter 는 조합 확정용이라 제출로 보면 안 된다
// (조합 중 Enter 를 제출로 처리하면 미완성 문자열이 저장된다)
function isComposing(e: KeyboardEvent): boolean {
return e.isComposing || e.keyCode === 229
}
// 인라인 추가 입력 — 성공했을 때만 입력을 비운다(실패 시 다시 타이핑하지 않도록)
const draft = ref('')
function submitDraft(): void {
const adding = ref(false)
async function submitDraft(e: KeyboardEvent): Promise<void> {
if (isComposing(e)) return
const title = draft.value.trim()
if (!title) return
emit('add', title)
draft.value = ''
if (!title || adding.value) return
adding.value = true
try {
await props.addItem(title)
draft.value = ''
} catch {
// 오류 안내는 인터셉터가 처리 — 입력한 내용은 그대로 남긴다
} finally {
adding.value = false
}
}
// 항목 인라인 수정 — 한 번에 한 행만 편집 상태가 된다
const editingId = ref<string | null>(null)
const editDraft = ref('')
function startEdit(item: ApiTodoItem): void {
const editInput = ref<HTMLInputElement | null>(null)
async function startEdit(item: ApiTodoItem): Promise<void> {
editingId.value = item.id
editDraft.value = item.title
// 입력이 그려진 뒤 포커스를 줘야 한다. 포커스가 없으면 타이핑도 blur 확정도 되지 않는다.
await nextTick()
editInput.value?.focus()
editInput.value?.select()
}
function commitEdit(item: ApiTodoItem): void {
// 편집 중인 행이 목록에서 사라지면(삭제·검색 필터) 편집 상태를 정리한다
watch(
() => props.label.items,
(items) => {
if (editingId.value && !items.some((i) => i.id === editingId.value)) {
cancelEdit()
}
},
)
function commitEdit(item: ApiTodoItem, e?: KeyboardEvent): void {
if (e && isComposing(e)) return
// Enter 로 확정하면 입력이 사라지며 blur 가 뒤따라 한 번 더 호출된다.
// 편집 중인 행이 아니면 무시해 같은 요청이 두 번 나가지 않게 한다.
if (editingId.value !== item.id) return
@@ -61,6 +97,23 @@ function cancelEdit(): void {
<h2 class="tl-name">
{{ props.label.name }}
</h2>
<!-- 원본 업무 링크 업무에서 만든 라벨에만 있다(직접 만든 라벨은 없음) -->
<RouterLink
v-if="props.label.sourceTask"
class="tl-src"
:to="`/projects/${props.label.sourceTask.projectId}/tasks/${props.label.sourceTask.seq}`"
:title="`원본 업무: ${props.label.sourceTask.title}`"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.5 1.5" /><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7L12 19" /></svg>
업무 #{{ props.label.sourceTask.seq }}
</RouterLink>
<span class="tl-progress">{{ props.label.doneCount }}/{{ props.label.totalCount }}</span>
<div
ref="menuRef"
@@ -140,9 +193,11 @@ function cancelEdit(): void {
</button>
<input
v-if="editingId === item.id"
ref="editInput"
v-model="editDraft"
class="tl-edit"
@keydown.enter="commitEdit(item)"
maxlength="200"
@keydown.enter="commitEdit(item, $event)"
@keydown.esc="cancelEdit"
@blur="commitEdit(item)"
>
@@ -191,6 +246,11 @@ function cancelEdit(): void {
maxlength="200"
@keydown.enter="submitDraft"
>
<span
v-if="adding"
class="tl-add-busy"
aria-live="polite"
>추가 </span>
</div>
</section>
</template>
@@ -369,13 +429,46 @@ function cancelEdit(): void {
display: none;
place-items: center;
}
.tl-row:hover .tl-del {
/* 키보드로 탭 이동했을 때도 보이게 한다 — 보이지 않는 포커스 정거장을 만들지 않는다 */
.tl-row:hover .tl-del,
.tl-del:focus-visible {
display: grid;
}
.tl-del:hover {
background: #eceef1;
color: var(--text);
}
/* 원본 업무 링크 — 라벨 이름 옆의 보조 정보라 작고 낮은 대비로 둔다 */
.tl-src {
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
height: 1.5rem;
padding: 0 0.4375rem;
font-size: 0.719rem;
font-weight: 600;
color: var(--text-2);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
text-decoration: none;
white-space: nowrap;
}
.tl-src:hover {
color: var(--accent);
border-color: var(--accent-border);
background: var(--accent-weak);
}
.tl-src svg {
width: 0.75rem;
height: 0.75rem;
}
.tl-add-busy {
flex-shrink: 0;
font-size: 0.719rem;
color: var(--text-3);
}
.tl-del svg {
width: 0.813rem;
height: 0.813rem;