fix: 고정지출 등록 확인 인앱 모달 + 분류 관리 계층 드래그
CI / build (push) Successful in 36s

1. PC 네이티브 confirm 제목('sb_pt') 노출 → '고정지출 등록' 인앱 확인 모달로 교체
   (성공 시 토스트 안내). electron app.setName('Slim Budget')로 다른 네이티브
   대화상자 제목도 개선(다음 Electron 빌드 시 반영).
2. 분류 관리 드래그를 계층형으로: 대분류는 블록 단위(소분류 함께) 이동,
   소분류는 그 대분류 안에서만 이동(고유 group). 로컬 즉시반영으로 스냅백 방지.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-23 21:02:09 +09:00
parent cad1aba0ca
commit 8add41d94f
3 changed files with 213 additions and 62 deletions
+132 -45
View File
@@ -27,6 +27,10 @@ const majors = computed(() => rows.value.filter((c) => c.parentId == null))
function parentOptions(c) {
return majors.value.filter((m) => m.id !== c.id)
}
// 대분류 → 소분류 트리 (major/children 모두 실제 row 참조 유지 → v-model 바인딩 가능)
const tree = computed(() =>
majors.value.map((m) => ({ major: m, children: rows.value.filter((c) => c.parentId === m.id) })),
)
async function load() {
loading.value = true
@@ -39,36 +43,66 @@ async function load() {
loading.value = false
}
}
async function reload() {
await load()
await nextTick()
initSortable()
}
// ===== 드래그앤드랍 정렬 (SortableJS, 터치 지원) =====
const listEl = ref(null)
let sortable = null
// ===== 계층 드래그 정렬 (대분류 블록 / 대분류 내 소분류) =====
const majorListEl = ref(null)
let sortables = []
function destroySortables() {
sortables.forEach((s) => s.destroy())
sortables = []
}
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))
},
destroySortables()
if (!majorListEl.value) return
// 대분류 순서 — 블록 단위라 소분류가 함께 이동
sortables.push(
Sortable.create(majorListEl.value, {
handle: '.major-handle',
draggable: '.major-block',
animation: 150,
onEnd: () => {
const ids = [...majorListEl.value.querySelectorAll(':scope > .major-block')].map((li) => Number(li.dataset.id))
persistOrder(ids)
},
}),
)
// 각 대분류의 소분류 순서 — 고유 group 으로 다른 대분류로는 이동 불가
majorListEl.value.querySelectorAll('.sub-list').forEach((ul) => {
sortables.push(
Sortable.create(ul, {
handle: '.sub-handle',
draggable: '.sub-item',
group: 'sub-' + ul.dataset.parent,
animation: 150,
onEnd: () => {
const ids = [...ul.querySelectorAll(':scope > .sub-item')].map((li) => Number(li.dataset.id))
if (ids.length) persistOrder(ids)
},
}),
)
})
}
// 로컬 순서 즉시 반영(스냅백 방지) 후 백엔드 저장
function reorderLocal(ids) {
const idSet = new Set(ids)
const moved = ids.map((id) => categories.value.find((c) => c.id === id)).filter(Boolean)
let k = 0
categories.value = categories.value.map((c) => (idSet.has(c.id) ? moved[k++] : c))
syncRows()
}
async function persistOrder(ids) {
reorderLocal(ids)
try {
await accountApi.reorderCategories(activeType.value, ids)
await load()
} catch (e) {
alert(e.response?.data?.message || '순서 저장에 실패했습니다.')
await load()
}
await reload()
}
async function addCategory() {
@@ -77,7 +111,7 @@ async function addCategory() {
try {
await accountApi.createCategory({ type: activeType.value, name, parentId: newParent.value || null })
newName.value = ''
await load()
await reload()
} catch (e) {
alert(e.response?.data?.message || '추가에 실패했습니다.')
}
@@ -89,7 +123,7 @@ async function saveCategory(c) {
try {
// parentId 는 항상 현재값을 함께 전송(미전송 시 대분류로 풀림)
await accountApi.updateCategory(c.id, { type: c.type, name, parentId: c.parentId || null })
await load()
await reload()
} catch (e) {
alert(e.response?.data?.message || '수정에 실패했습니다.')
}
@@ -99,7 +133,7 @@ async function removeCategory(c) {
if (!confirm(`'${c.name}' 분류를 삭제할까요? (기존 내역의 분류명은 그대로 유지됩니다)`)) return
try {
await accountApi.removeCategory(c.id)
await load()
await reload()
} catch (e) {
alert(e.response?.data?.message || '삭제에 실패했습니다.')
}
@@ -109,17 +143,23 @@ async function importExisting() {
if (!confirm('기존 내역에서 사용한 분류들을 목록으로 가져올까요?')) return
try {
categories.value = await accountApi.importCategories()
await nextTick()
initSortable()
} catch (e) {
alert(e.response?.data?.message || '불러오기에 실패했습니다.')
}
}
onMounted(async () => {
await load()
// 탭(수입/지출) 전환 시 재렌더 → 드래그 재초기화
watch(activeType, async () => {
await nextTick()
initSortable()
})
onBeforeUnmount(() => sortable?.destroy())
onMounted(async () => {
await reload()
})
onBeforeUnmount(destroySortables)
</script>
<template>
@@ -148,30 +188,50 @@ onBeforeUnmount(() => sortable?.destroy())
<IconBtn icon="plus" title="추가" variant="primary" type="submit" size="sm" />
</div>
</form>
<p class="hint sm"> 손잡이 끌어 순서 변경. 행의 대분류 바꾸 소분류로 묶입니다.</p>
<p class="hint sm"> 손잡이 순서 변경 <b>대분류</b> 소분류와 함께, <b>소분류</b> 대분류 안에서만 이동합니다. 다른 대분류 옮기려 행의 대분류 바꾸세요.</p>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<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" :class="{ child: c.parentId != null }">
<div class="cat-main">
<span class="drag-handle" title="드래그하여 순서 변경"></span>
<span v-if="c.parentId != null" class="sub-mark" title="소분류"></span>
<input v-model="c.name" class="cat-name" @keyup.enter="saveCategory(c)" />
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(c)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(c)" />
<ul v-show="!loading && tree.length" ref="majorListEl" class="major-list">
<li v-for="g in tree" :key="g.major.id" :data-id="g.major.id" class="major-block">
<div class="cat-row major-row">
<div class="cat-main">
<span class="drag-handle major-handle" title="대분류 순서 변경(소분류 함께 이동)"></span>
<span class="major-badge"></span>
<input v-model="g.major.name" class="cat-name" @keyup.enter="saveCategory(g.major)" />
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(g.major)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(g.major)" />
</div>
<label class="cat-parent">
<span class="pl">대분류</span>
<select v-model="g.major.parentId" class="parent-sel" @change="saveCategory(g.major)">
<option :value="null">(없음 · 대분류)</option>
<option v-for="m in parentOptions(g.major)" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
</label>
</div>
<label class="cat-parent">
<span class="pl">대분류</span>
<select v-model="c.parentId" class="parent-sel" title="대분류" @change="saveCategory(c)">
<option :value="null">(없음 · 대분류)</option>
<option v-for="m in parentOptions(c)" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
</label>
<ul class="sub-list" :data-parent="g.major.id">
<li v-for="s in g.children" :key="s.id" :data-id="s.id" class="sub-item cat-row child">
<div class="cat-main">
<span class="drag-handle sub-handle" title="소분류 순서 변경"></span>
<span class="sub-mark"></span>
<input v-model="s.name" class="cat-name" @keyup.enter="saveCategory(s)" />
<IconBtn icon="check" title="저장" size="sm" @click="saveCategory(s)" />
<IconBtn icon="trash" title="삭제" variant="danger" size="sm" @click="removeCategory(s)" />
</div>
<label class="cat-parent">
<span class="pl">대분류</span>
<select v-model="s.parentId" class="parent-sel" @change="saveCategory(s)">
<option v-for="m in parentOptions(s)" :key="m.id" :value="m.id">{{ m.name }}</option>
<option :value="null">(대분류로 분리)</option>
</select>
</label>
</li>
</ul>
</li>
</ul>
<p v-if="!loading && !rows.length" class="msg">
<p v-if="!loading && !tree.length" class="msg">
등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없습니다.
</p>
</section>
@@ -250,16 +310,43 @@ button.danger {
flex: 1;
min-width: 0;
}
.cat-list {
.major-list {
list-style: none;
padding-left: 0;
margin: 0;
}
.major-block {
border: 1px solid var(--color-border);
border-radius: 8px;
margin-bottom: 0.5rem;
overflow: hidden;
}
.major-row {
background: var(--color-background-soft);
border-bottom: 0 !important;
}
.major-badge {
flex: 0 0 auto;
font-size: 0.62rem;
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
border: 1px solid hsla(160, 100%, 37%, 0.6);
border-radius: 4px;
padding: 0.05rem 0.25rem;
}
.sub-list {
list-style: none;
padding: 0 0 0 0.4rem;
margin: 0;
}
.sub-item:last-child {
border-bottom: 0;
}
.cat-row {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem 0;
padding: 0.5rem 0.5rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-background);
}