diff --git a/electron/main.cjs b/electron/main.cjs
index 56ee751..04e22c5 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -5,6 +5,9 @@ const { app, BrowserWindow, shell, session } = require('electron')
const fs = require('fs')
const path = require('path')
+// 네이티브 대화상자(alert/confirm) 제목은 app.getName() 을 쓴다 → package.json name('sb_pt') 대신 표기.
+app.setName('Slim Budget')
+
const APP_URL = 'https://app.sblog.kr'
// 창 크기/위치를 userData 에 저장해 다음 실행 때 복원 (기본 해상도 고정 대신 사용자가 맞춘 크기 유지)
diff --git a/src/views/account/AccountView.vue b/src/views/account/AccountView.vue
index 178bf41..fcf2acd 100644
--- a/src/views/account/AccountView.vue
+++ b/src/views/account/AccountView.vue
@@ -579,8 +579,18 @@ async function remove(e) {
}
}
-// 현재 수정 중인 내역을 '매월' 고정지출로 바로 등록 (주기·시작일은 고정지출 화면에서 변경)
-async function registerAsRecurring() {
+// 고정지출 등록 확인 모달 상태 (네이티브 confirm 대신 — PC 팝업 제목 'sb_pt' 노출 방지)
+const recurConfirm = reactive({ open: false, title: '', dom: 1, amount: 0, payload: null })
+const flashMsg = ref('')
+let flashTimer = null
+function flash(msg) {
+ flashMsg.value = msg
+ if (flashTimer) clearTimeout(flashTimer)
+ flashTimer = setTimeout(() => (flashMsg.value = ''), 2800)
+}
+
+// 현재 수정 중인 내역을 '매월' 고정지출로 등록 — 확인 모달을 띄운다
+function registerAsRecurring() {
if (form.type === 'REPAYMENT') return // 고정지출은 수입/지출/이체만
const amount = Number(form.amount)
if (!amount || amount <= 0) {
@@ -597,26 +607,36 @@ async function registerAsRecurring() {
// 이번 회차 중복 방지: 시작일을 내역 다음날로 → 다음 발생부터 생성
const start = new Date(y, m - 1, d + 1)
const startStr = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String(start.getDate()).padStart(2, '0')}`
- if (!confirm(`'${title}'\n매월 ${dom}일 · ${won(amount)} 고정지출로 등록할까요?\n(주기·시작일은 고정지출 화면에서 변경할 수 있어요)`)) return
+ recurConfirm.title = title
+ recurConfirm.dom = dom
+ recurConfirm.amount = amount
+ recurConfirm.payload = {
+ title,
+ type: form.type,
+ amount,
+ category: form.type === 'TRANSFER' ? null : form.category || null,
+ memo: form.memo || null,
+ walletId: form.walletId || null,
+ toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
+ frequency: 'MONTHLY',
+ dayOfMonth: dom,
+ startDate: startStr,
+ active: true,
+ }
+ recurConfirm.open = true
+}
+
+async function doRegisterRecurring() {
+ if (!recurConfirm.payload) return
submitting.value = true
try {
- await accountApi.createRecurring({
- title,
- type: form.type,
- amount,
- category: form.type === 'TRANSFER' ? null : form.category || null,
- memo: form.memo || null,
- walletId: form.walletId || null,
- toWalletId: form.type === 'TRANSFER' ? form.toWalletId || null : null,
- frequency: 'MONTHLY',
- dayOfMonth: dom,
- startDate: startStr,
- active: true,
- })
+ await accountApi.createRecurring(recurConfirm.payload)
+ recurConfirm.open = false
formOpen.value = false
- alert('고정지출로 등록했습니다. 고정지출 화면에서 주기를 변경할 수 있어요.')
+ flash('고정지출로 등록했습니다. 고정지출 화면에서 주기를 변경할 수 있어요.')
} catch (e) {
formError.value = e.response?.data?.message || '고정지출 등록에 실패했습니다.'
+ recurConfirm.open = false
} finally {
submitting.value = false
}
@@ -644,6 +664,7 @@ onMounted(async () => {
가계부확인 필요 {{ pendingCount }}건
+ {{ flashMsg }}
@@ -936,6 +957,26 @@ onMounted(async () => {
+
+
+
+
+
+
+
고정지출 등록
+
+ {{ recurConfirm.title }}
+ 매월 {{ recurConfirm.dom }}일 · {{ won(recurConfirm.amount) }} 으로 등록할까요?
+
+
주기·시작일은 고정지출 화면에서 변경할 수 있어요.
+
+
+
+
+
+
+
+
@@ -1256,6 +1297,26 @@ button.primary {
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
+.flash {
+ margin: 0.5rem 0 0;
+ padding: 0.6rem 0.9rem;
+ background: hsla(160, 100%, 37%, 0.12);
+ border: 1px solid hsla(160, 100%, 37%, 0.5);
+ border-radius: 8px;
+ font-size: 0.85rem;
+}
+.confirm-modal {
+ max-width: 340px;
+}
+.confirm-body {
+ margin: 0.5rem 0;
+ line-height: 1.5;
+}
+.confirm-sub {
+ margin: 0 0 0.5rem;
+ font-size: 0.8rem;
+ opacity: 0.6;
+}
.modal {
position: relative;
width: 100%;
diff --git a/src/views/account/CategoryView.vue b/src/views/account/CategoryView.vue
index 07174d9..cac3a4a 100644
--- a/src/views/account/CategoryView.vue
+++ b/src/views/account/CategoryView.vue
@@ -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)
@@ -148,30 +188,50 @@ onBeforeUnmount(() => sortable?.destroy())
- ≡ 손잡이를 끌어 순서 변경. 각 행의 대분류를 바꾸면 소분류로 묶입니다.
+ ≡ 손잡이로 순서 변경 — 대분류는 소분류와 함께, 소분류는 그 대분류 안에서만 이동합니다. 다른 대분류로 옮기려면 각 행의 ‘대분류’를 바꾸세요.
{{ error }}
불러오는 중...
-
- -
-
-
≡
-
↳
-
-
-
+
+ -
+
+
+ ≡
+ 대
+
+
+
+
+
-
+
-
+
등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없습니다.
@@ -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);
}