From 252f00e527cc319a9d6e1e4388ebbddb5bbaca57 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Mon, 1 Jun 2026 22:37:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B6=84=EB=A5=98=20=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EA=B7=B8=EC=95=A4=EB=93=9C=EB=9E=8D=20=EC=A0=95=EB=A0=AC=20+?= =?UTF-8?q?=20=EC=98=88=EC=82=B0=ED=99=94=EB=A9=B4=20=EC=98=88=EC=83=81?= =?UTF-8?q?=EC=88=98=EC=9E=85/=EC=98=88=EC=82=B0=20=EB=B9=84=EA=B5=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 분류 관리: SortableJS 드래그 정렬(터치 지원), 순서는 내역 추가 화면에도 반영 - 예산: 월 예상 수입 입력 + 수입 대비 총 예산 비교(여유/초과) Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 7 ++ package.json | 1 + src/api/accountApi.js | 9 +++ src/views/account/BudgetView.vue | 111 +++++++++++++++++++++++++++++ src/views/account/CategoryView.vue | 72 +++++++++++++++++-- 5 files changed, 194 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index e850ff0..8ab5f16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "axios": "^1.16.1", "pinia": "^3.0.4", "prismjs": "^1.30.0", + "sortablejs": "^1.15.7", "vue": "^3.5.32", "vue-router": "^5.0.4" }, @@ -4883,6 +4884,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/sortablejs": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 9eb94f3..51f7f0c 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "axios": "^1.16.1", "pinia": "^3.0.4", "prismjs": "^1.30.0", + "sortablejs": "^1.15.7", "vue": "^3.5.32", "vue-router": "^5.0.4" }, diff --git a/src/api/accountApi.js b/src/api/accountApi.js index 8588f8b..5801779 100644 --- a/src/api/accountApi.js +++ b/src/api/accountApi.js @@ -65,6 +65,12 @@ export const accountApi = { budgetPeriod({ unit, year, month }) { return http.get('/account/budgets/period', { params: { unit, year, month } }) }, + expectedIncome() { + return http.get('/account/budgets/income') + }, + setExpectedIncome(expectedIncome) { + return http.put('/account/budgets/income', { expectedIncome }) + }, createBudget(payload) { return http.post('/account/budgets', payload) }, @@ -123,6 +129,9 @@ export const accountApi = { removeCategory(id) { return http.delete(`/account/categories/${id}`) }, + reorderCategories(type, ids) { + return http.put('/account/categories/reorder', { type, ids }) + }, importCategories() { return http.post('/account/categories/import') }, diff --git a/src/views/account/BudgetView.vue b/src/views/account/BudgetView.vue index 7c93376..f423df0 100644 --- a/src/views/account/BudgetView.vue +++ b/src/views/account/BudgetView.vue @@ -81,6 +81,32 @@ function barClass(s) { return r >= 1 ? 'over' : r >= 0.8 ? 'warn' : 'ok' } +// ===== 월 예상 수입 vs 총 예산 비교 ===== +const expectedIncome = ref(null) +const incomeDraft = ref('') +async function loadIncome() { + try { + const r = await accountApi.expectedIncome() + expectedIncome.value = r.expectedIncome + incomeDraft.value = r.expectedIncome ?? '' + } catch { + expectedIncome.value = null + } +} +async function saveIncome() { + try { + const v = incomeDraft.value === '' || incomeDraft.value == null ? 0 : Number(incomeDraft.value) + const r = await accountApi.setExpectedIncome(v) + expectedIncome.value = r.expectedIncome + } catch (e) { + alert(e.response?.data?.message || '저장에 실패했습니다.') + } +} +const totalBudget = computed(() => statuses.value.reduce((s, x) => s + (x.monthlyBudget || 0), 0)) +const incomeVal = computed(() => Number(expectedIncome.value) || 0) +const leftover = computed(() => incomeVal.value - totalBudget.value) // 여유(저축 가능액) +const budgetOfIncome = computed(() => (incomeVal.value > 0 ? Math.min(totalBudget.value / incomeVal.value, 1) : 0)) + async function load() { loading.value = true error.value = null @@ -193,6 +219,7 @@ async function remove(s) { onMounted(() => { load() loadCategories() + loadIncome() }) @@ -212,6 +239,28 @@ onMounted(() => { + +
+
+ + + +
+
+
+
+
+
+ 수입 {{ won(incomeVal) }} + 예산 {{ won(totalBudget) }} + + {{ leftover >= 0 ? '여유' : '초과' }} {{ won(Math.abs(leftover)) }} + +
+
+

예상 수입을 입력하면 설정한 예산과 비교해 여유 금액을 보여줍니다.

+
+

{{ error }}

불러오는 중...

@@ -352,6 +401,68 @@ button.primary { min-width: 8rem; text-align: center; } +/* 예상 수입 vs 예산 */ +.income-panel { + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 0.8rem 1rem; + margin-bottom: 1.25rem; +} +.inc-input { + display: flex; + align-items: center; + gap: 0.5rem; +} +.inc-input label { + font-size: 0.85rem; + font-weight: 600; + white-space: nowrap; +} +.inc-input input { + flex: 1; + min-width: 0; + padding: 0.45rem 0.6rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-background-soft); + color: var(--color-text); +} +.inc-compare { + margin-top: 0.7rem; +} +.inc-bar { + height: 8px; + background: var(--color-background-mute); + border-radius: 999px; + overflow: hidden; +} +.inc-fill { + height: 100%; + border-radius: 999px; + background: #2e7d32; + transition: width 0.3s; +} +.inc-fill.over { + background: #c0392b; +} +.inc-nums { + display: flex; + justify-content: space-between; + gap: 0.5rem; + margin-top: 0.4rem; + font-size: 0.85rem; +} +.inc-nums .income { + color: #2e7d32; +} +.inc-nums .expense { + color: #c0392b; +} +.inc-hint { + margin-top: 0.5rem; + font-size: 0.8rem; + opacity: 0.65; +} .status-list { list-style: none; } diff --git a/src/views/account/CategoryView.vue b/src/views/account/CategoryView.vue index 12fe384..f585c06 100644 --- a/src/views/account/CategoryView.vue +++ b/src/views/account/CategoryView.vue @@ -1,18 +1,24 @@