feat: 할 일 완료 항목 정리 기능 추가

오래 쓴 라벨에 완료 항목이 계속 쌓여 카드가 길어지는 문제를 해소한다.

- 목록 상단에 '완료 항목 숨기기' 토글 — 화면에서만 걸러내고 데이터는 유지
- 라벨 메뉴에 '완료 항목 N개 삭제' — 확인 후 일괄 삭제(DELETE labels/:id/done-items)
- 완료 항목이 없으면 두 진입점 모두 노출하지 않는다
- 숨김 상태에서도 진행 집계는 전체 기준을 유지한다

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 23:08:50 +09:00
parent d110790385
commit 5303b2e92e
6 changed files with 89 additions and 2 deletions
@@ -84,6 +84,18 @@ export class TodoController {
return this.todoService.createFromTask(dto, user.id);
}
@Delete('labels/:id/done-items')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '라벨의 완료된 할 일 일괄 삭제' })
@ApiResponse({ status: 200, description: '삭제 성공' })
@ApiResponse({ status: 404, description: '라벨 없음 (RES_001)' })
clearDoneItems(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<TodoLabelResponse> {
return this.todoService.clearDoneItems(id, user.id);
}
// ----- 항목 -----
@Post('labels/:labelId/items')
+11
View File
@@ -220,6 +220,17 @@ export class TodoService {
return this.findLabelOrThrow(label.id, ownerId);
}
// 라벨의 완료 항목 일괄 삭제 — 오래 쓴 라벨에 완료 항목이 쌓이는 것을 정리한다.
// 남길 항목이 없어도 라벨 자체는 유지한다(사용자가 명시적으로 지운 게 아니므로).
async clearDoneItems(
labelId: string,
ownerId: string,
): Promise<TodoLabelResponse> {
await this.assertLabelOwned(labelId, ownerId);
await this.itemRepo.delete({ label: { id: labelId }, done: true });
return this.findLabelOrThrow(labelId, ownerId);
}
// ----- 항목 -----
async createItem(
@@ -18,6 +18,7 @@ const emit = defineEmits<{
(e: 'remove', item: ApiTodoItem): void
(e: 'edit-label'): void
(e: 'delete-label'): void
(e: 'clear-done'): void
}>()
// 라벨 메뉴(편집/삭제) 드롭다운
@@ -155,6 +156,14 @@ function cancelEdit(): void {
>
라벨 편집
</button>
<!-- 완료 항목이 있을 때만 노출 쌓인 완료 항목을 정리하는 용도 -->
<button
v-if="props.label.doneCount > 0"
type="button"
@click="menuOpen = false; emit('clear-done')"
>
완료 항목 {{ props.label.doneCount }} 삭제
</button>
<button
type="button"
class="danger"
+8
View File
@@ -28,6 +28,13 @@ export function useTodo() {
await api.delete(`/todos/labels/${id}`)
}
// 라벨의 완료 항목 일괄 삭제 — 변경된 라벨을 돌려받아 카드만 교체한다
async function clearDoneItems(labelId: string): Promise<ApiTodoLabel> {
return (await api.delete(
`/todos/labels/${labelId}/done-items`,
)) as unknown as ApiTodoLabel
}
// 업무 → 할 일 생성. 같은 업무로 만든 라벨이 있으면 서버가 항목을 병합해 준다
async function createFromTask(
payload: TodoFromTaskPayload,
@@ -62,6 +69,7 @@ export function useTodo() {
createLabel,
updateLabel,
deleteLabel,
clearDoneItems,
createFromTask,
createItem,
updateItem,
+26
View File
@@ -48,6 +48,15 @@ async function onDeleteLabel(label: ApiTodoLabel): Promise<void> {
await store.deleteLabel(label.id)
}
async function onClearDone(label: ApiTodoLabel): Promise<void> {
const ok = await dialog.confirm(
`'${label.name}' 라벨의 완료된 할 일 ${label.doneCount}건을 삭제할까요?`,
{ title: '완료 항목 삭제', confirmText: '삭제', variant: 'danger' },
)
if (!ok) return
swallow(store.clearDoneItems(label.id))
}
// ----- 할 일 -----
// 오류 안내는 API 인터셉터가 토스트로 처리하므로 여기서는 거부만 삼킨다.
// (삼키지 않으면 템플릿 핸들러에서 처리되지 않은 Promise 거부가 남는다)
@@ -112,6 +121,15 @@ async function onRemoveItem(item: ApiTodoItem): Promise<void> {
placeholder="라벨·할 일 검색…"
>
</div>
<button
v-if="store.hasDoneItems"
type="button"
class="btn hide-done"
:aria-pressed="store.hideDone"
@click="store.hideDone = !store.hideDone"
>
{{ store.hideDone ? '완료 항목 보기' : '완료 항목 숨기기' }}
</button>
<button
type="button"
class="btn primary new-label"
@@ -151,6 +169,7 @@ async function onRemoveItem(item: ApiTodoItem): Promise<void> {
@remove="onRemoveItem"
@edit-label="openEditLabel(label)"
@delete-label="onDeleteLabel(label)"
@clear-done="onClearDone(label)"
/>
</div>
@@ -183,6 +202,13 @@ async function onRemoveItem(item: ApiTodoItem): Promise<void> {
margin: 0.25rem 0 1.125rem;
}
/* 회의 로비와 동일하게 주요 버튼을 우측으로 밀어 배치 */
/* 완료 숨김 토글은 주요 버튼 왼쪽에 붙인다 */
.hide-done {
margin-left: auto;
}
.hide-done + .new-label {
margin-left: 0;
}
.new-label {
margin-left: auto;
}
+23 -2
View File
@@ -11,14 +11,24 @@ export const useTodoStore = defineStore('todo', () => {
const labels = ref<ApiTodoLabel[]>([])
const keyword = ref('')
const loading = ref(false)
// 완료 항목 숨기기 — 화면에서만 걸러내며 데이터는 그대로 둔다
const hideDone = ref(false)
// 검색 — 라벨 이름 또는 할 일 내용에 걸리면 남긴다.
// 라벨 이름이 걸린 경우는 항목을 그대로 두고, 항목만 걸린 경우는 걸린 항목만 보여준다.
const displayLabels = computed<ApiTodoLabel[]>(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return labels.value
// 진행 집계(doneCount/totalCount)는 걸러내도 원본 수치를 유지한다 —
// '2/5 중 완료 숨김' 상태에서도 전체 진행률을 알 수 있어야 한다
const source = hideDone.value
? labels.value.map((l) => ({
...l,
items: l.items.filter((i) => !i.done),
}))
: labels.value
if (!kw) return source
const result: ApiTodoLabel[] = []
for (const l of labels.value) {
for (const l of source) {
if (l.name.toLowerCase().includes(kw)) {
result.push(l)
continue
@@ -29,6 +39,11 @@ export const useTodoStore = defineStore('todo', () => {
return result
})
// 완료 항목이 하나라도 있는지 — '완료 항목 정리' 노출 판단에 쓴다
const hasDoneItems = computed(() =>
labels.value.some((l) => l.doneCount > 0),
)
const isFiltered = computed(() => keyword.value.trim() !== '')
// 전체 할 일 수(헤더 count-pill 표기용)
const totalCount = computed(() =>
@@ -84,6 +99,9 @@ export const useTodoStore = defineStore('todo', () => {
await api.deleteLabel(id)
labels.value = labels.value.filter((l) => l.id !== id)
}
async function clearDoneItems(id: string): Promise<void> {
replaceLabel(await api.clearDoneItems(id))
}
// --- 항목 ---
async function addItem(labelId: string, title: string): Promise<void> {
@@ -128,12 +146,15 @@ export const useTodoStore = defineStore('todo', () => {
displayLabels,
keyword,
loading,
hideDone,
isFiltered,
hasDoneItems,
totalCount,
load,
createLabel,
updateLabel,
deleteLabel,
clearDoneItems,
addItem,
toggleItem,
renameItem,