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;
+22 -8
View File
@@ -49,17 +49,31 @@ async function onDeleteLabel(label: ApiTodoLabel): Promise<void> {
}
// ----- 할 일 -----
async function onAddItem(labelId: string, title: string): Promise<void> {
await store.addItem(labelId, title)
// 오류 안내는 API 인터셉터가 토스트로 처리하므로 여기서는 거부만 삼킨다.
// (삼키지 않으면 템플릿 핸들러에서 처리되지 않은 Promise 거부가 남는다)
function swallow(p: Promise<unknown>): void {
void p.catch(() => undefined)
}
async function onToggleItem(item: ApiTodoItem): Promise<void> {
await store.toggleItem(item.id, !item.done)
// 추가만 실패를 호출측(카드)에 알려야 한다 — 입력을 지울지 남길지 판단하기 위해
function onAddItem(labelId: string, title: string): Promise<void> {
return store.addItem(labelId, title)
}
async function onRenameItem(item: ApiTodoItem, title: string): Promise<void> {
await store.renameItem(item.id, title)
function onToggleItem(item: ApiTodoItem): void {
swallow(store.toggleItem(item.id, !item.done))
}
function onRenameItem(item: ApiTodoItem, title: string): void {
swallow(store.renameItem(item.id, title))
}
async function onRemoveItem(item: ApiTodoItem): Promise<void> {
await store.removeItem(item.id)
// 되돌릴 수 없는 삭제라 라벨 삭제와 동일하게 확인을 받는다
const ok = await dialog.confirm(`'${item.title}' 할 일을 삭제할까요?`, {
title: '할 일 삭제',
confirmText: '삭제',
variant: 'danger',
})
if (!ok) return
swallow(store.removeItem(item.id))
}
</script>
@@ -131,7 +145,7 @@ async function onRemoveItem(item: ApiTodoItem): Promise<void> {
v-for="label in store.displayLabels"
:key="label.id"
:label="label"
@add="onAddItem(label.id, $event)"
:add-item="(title: string) => onAddItem(label.id, title)"
@toggle="onToggleItem"
@rename="onRenameItem"
@remove="onRemoveItem"
+48 -4
View File
@@ -1,7 +1,7 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useTodo } from '@/composables/useTodo'
import type { ApiTodoLabel, TodoLabelPayload } from '@/types/todo'
import type { ApiTodoItem, ApiTodoLabel, TodoLabelPayload } from '@/types/todo'
// 할 일 스토어 — 라벨(항목 포함) 목록 + 검색어 + CRUD.
// 항목 변경 API 는 변경된 라벨을 그대로 돌려주므로 해당 카드만 교체한다.
@@ -35,11 +35,31 @@ export const useTodoStore = defineStore('todo', () => {
labels.value.reduce((sum, l) => sum + l.totalCount, 0),
)
// 진행 중인 항목 변경 — 같은 항목에 중복 요청이 나가지 않도록 막는다.
// (응답이 올 때까지 화면 상태가 그대로라, 막지 않으면 같은 값을 두 번 보내고
// 두 응답이 역순으로 도착하면 나중 응답이 이겨 버린다)
const pendingItemIds = ref<Set<string>>(new Set())
function markPending(id: string, on: boolean): void {
const next = new Set(pendingItemIds.value)
if (on) next.add(id)
else next.delete(id)
pendingItemIds.value = next
}
// 변경된 라벨 한 건을 목록에 반영
function replaceLabel(next: ApiTodoLabel): void {
labels.value = labels.value.map((l) => (l.id === next.id ? next : l))
}
// 항목 한 건을 화면에서 먼저 바꾼다(낙관적 갱신). 진행 집계도 함께 맞춘다.
function patchItemLocally(id: string, patch: Partial<ApiTodoItem>): void {
labels.value = labels.value.map((l) => {
if (!l.items.some((i) => i.id === id)) return l
const items = l.items.map((i) => (i.id === id ? { ...i, ...patch } : i))
return { ...l, items, doneCount: items.filter((i) => i.done).length }
})
}
async function load(): Promise<void> {
loading.value = true
try {
@@ -69,14 +89,38 @@ export const useTodoStore = defineStore('todo', () => {
async function addItem(labelId: string, title: string): Promise<void> {
replaceLabel(await api.createItem(labelId, title))
}
// 체크 토글 — 응답을 기다리지 않고 먼저 반영하고, 실패하면 되돌린다
async function toggleItem(id: string, done: boolean): Promise<void> {
replaceLabel(await api.updateItem(id, { done }))
if (pendingItemIds.value.has(id)) return
const snapshot = labels.value
markPending(id, true)
patchItemLocally(id, { done })
try {
replaceLabel(await api.updateItem(id, { done }))
} catch (e) {
labels.value = snapshot
throw e
} finally {
markPending(id, false)
}
}
async function renameItem(id: string, title: string): Promise<void> {
replaceLabel(await api.updateItem(id, { title }))
if (pendingItemIds.value.has(id)) return
markPending(id, true)
try {
replaceLabel(await api.updateItem(id, { title }))
} finally {
markPending(id, false)
}
}
async function removeItem(id: string): Promise<void> {
replaceLabel(await api.deleteItem(id))
if (pendingItemIds.value.has(id)) return
markPending(id, true)
try {
replaceLabel(await api.deleteItem(id))
} finally {
markPending(id, false)
}
}
return {