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(() => {
예상 수입을 입력하면 설정한 예산과 비교해 여유 금액을 보여줍니다.
+{{ 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 @@ @@ -91,18 +133,20 @@ onMounted(load)≡ 손잡이를 끌어 순서를 바꾸면 내역 추가 화면에도 그 순서로 나옵니다.
{{ error }}
불러오는 중...
-+
등록된 {{ activeType === 'EXPENSE' ? '지출' : '수입' }} 분류가 없습니다.
@@ -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; }