feat: 분류 드래그앤드랍 정렬 + 예산화면 예상수입/예산 비교
CI / build (push) Failing after 13m46s

- 분류 관리: SortableJS 드래그 정렬(터치 지원), 순서는 내역 추가 화면에도 반영
- 예산: 월 예상 수입 입력 + 수입 대비 총 예산 비교(여유/초과)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-01 22:37:56 +09:00
parent 63a8a71e3b
commit 252f00e527
5 changed files with 194 additions and 6 deletions
+66 -6
View File
@@ -1,18 +1,24 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const categories = ref([])
const rows = ref([]) // 현재 탭(activeType)의 분류 — 드래그 정렬 대상
const loading = ref(false)
const error = ref(null)
const activeType = ref('EXPENSE') // EXPENSE / INCOME
const newName = ref('')
const filtered = computed(() => categories.value.filter((c) => c.type === activeType.value))
// categories/activeType 변경 시 현재 탭 목록 동기화 (백엔드가 sort_order 순 정렬)
function syncRows() {
rows.value = categories.value.filter((c) => c.type === activeType.value)
}
watch([categories, activeType], syncRows, { immediate: true })
async function load() {
loading.value = true
@@ -26,6 +32,37 @@ async function load() {
}
}
// ===== 드래그앤드랍 정렬 (SortableJS, 터치 지원) =====
const listEl = ref(null)
let sortable = null
function initSortable() {
if (sortable) {
sortable.destroy()
sortable = null
}
if (!listEl.value) return
sortable = Sortable.create(listEl.value, {
handle: '.drag-handle',
animation: 150,
onEnd: (evt) => {
if (evt.oldIndex === evt.newIndex) return
const arr = rows.value
const moved = arr.splice(evt.oldIndex, 1)[0]
arr.splice(evt.newIndex, 0, moved)
persistOrder(arr.map((r) => r.id))
},
})
}
async function persistOrder(ids) {
try {
await accountApi.reorderCategories(activeType.value, ids)
await load()
} catch (e) {
alert(e.response?.data?.message || '순서 저장에 실패했습니다.')
await load()
}
}
async function addCategory() {
const name = newName.value.trim()
if (!name) return
@@ -68,7 +105,12 @@ async function importExisting() {
}
}
onMounted(load)
onMounted(async () => {
await load()
await nextTick()
initSortable()
})
onBeforeUnmount(() => sortable?.destroy())
</script>
<template>
@@ -91,18 +133,20 @@ onMounted(load)
<input v-model="newName" type="text" :placeholder="activeType === 'EXPENSE' ? '예: 식비, 교통' : '예: 급여, 용돈'" />
<IconBtn icon="plus" title="추가" variant="primary" type="submit" />
</form>
<p class="hint sm"> 손잡이를 끌어 순서를 바꾸면 내역 추가 화면에도 순서로 나옵니다.</p>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<ul v-else-if="filtered.length" class="cat-list">
<li v-for="c in filtered" :key="c.id" class="cat-row">
<ul v-show="!loading && rows.length" ref="listEl" class="cat-list">
<li v-for="c in rows" :key="c.id" :data-id="c.id" class="cat-row">
<span class="drag-handle" title="드래그하여 순서 변경"></span>
<input v-model="c.name" class="cat-name" @keyup.enter="saveCategory(c)" />
<IconBtn icon="check" title="저장" @click="saveCategory(c)" />
<IconBtn icon="trash" title="삭제" variant="danger" @click="removeCategory(c)" />
</li>
</ul>
<p v-else-if="!loading" class="msg">
<p v-if="!loading && !rows.length" class="msg">
등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없습니다.
</p>
</section>
@@ -184,10 +228,26 @@ button.danger {
align-items: center;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
background: var(--color-background);
}
.drag-handle {
cursor: grab;
user-select: none;
opacity: 0.5;
font-size: 1.1rem;
padding: 0 0.2rem;
touch-action: none;
}
.drag-handle:active {
cursor: grabbing;
}
.cat-name {
flex: 1;
}
.hint.sm {
font-size: 0.78rem;
margin: 0 0 0.75rem;
}
.msg {
margin: 0.75rem 0;
}