- 분류 관리: SortableJS 드래그 정렬(터치 지원), 순서는 내역 추가 화면에도 반영 - 예산: 월 예상 수입 입력 + 수입 대비 총 예산 비교(여유/초과) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user