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 () => {
+ + + + + + + @@ -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)