feat: 분류 대/소분류 UI — 관리·입력·예산·고정지출·통계
CI / build (push) Failing after 12m0s

- 분류 관리: 대분류 선택·소분류 들여쓰기(드래그 정렬 유지)
- 입력/예산/고정지출 분류 드롭다운 대분류 optgroup 그룹핑(값은 소분류명)
- 대시보드: 파이는 소분류 기준 + 대분류 합계 롤업 표시
- 릴리스 노트 33

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-22 23:16:46 +09:00
parent b1f85cee52
commit 6ab8d331e3
6 changed files with 182 additions and 9 deletions
+7
View File
@@ -147,6 +147,13 @@
## 32. 계좌 관리 — 탭 한 줄 ## 32. 계좌 관리 — 탭 한 줄
- 계좌 종류 탭(은행/현금/카드/대출/투자)이 두 줄로 줄바꿈되던 것 → **한 줄(넘치면 가로 스크롤)**. 버튼 글자 줄바꿈 방지. - 계좌 종류 탭(은행/현금/카드/대출/투자)이 두 줄로 줄바꿈되던 것 → **한 줄(넘치면 가로 스크롤)**. 버튼 글자 줄바꿈 방지.
## 33. 분류 — 대분류/소분류(2단계)
- **분류를 대분류 아래 소분류로 묶기**(2단계). `account_category.parent_id` 추가(멱등 마이그레이션). 기존 분류는 모두 대분류로 시작 → 분류 관리에서 소분류로 재배치.
- **소분류 이름 기반 유지**(내역·예산·고정지출·통계 무변경) — 같은 소분류 이름 중복은 불가, 예산·통계는 **소분류 기준**.
- 분류 관리: 행별 대분류 선택·소분류 들여쓰기, 드래그 정렬 유지. 자식 있는 대분류는 소분류화·삭제 차단.
- 입력/예산/고정지출 드롭다운: **대분류 optgroup** 그룹핑(값은 소분류명).
- 대시보드 통계: 파이는 소분류 기준 + 아래 **대분류 합계** 롤업 표시.
--- ---
## 운영 반영 시 체크 ## 운영 반영 시 체크
@@ -22,6 +22,7 @@ const barData = ref([]) // [{bucket, budget, expense}]
const monthlyStats = ref([]) // MONTH unit of year (income/expense) const monthlyStats = ref([]) // MONTH unit of year (income/expense)
const catType = ref('EXPENSE') // 분류별 파이차트 구분 const catType = ref('EXPENSE') // 분류별 파이차트 구분
const catData = ref([]) // [{category, total}] const catData = ref([]) // [{category, total}]
const categoryList = ref([]) // 분류 목록(대/소분류 매핑용)
const trendData = ref([]) // [{month, netWorth}] const trendData = ref([]) // [{month, netWorth}]
const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`) const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`)
@@ -188,6 +189,33 @@ async function loadCat() {
catData.value = [] catData.value = []
} }
} }
async function loadCategoryList() {
try {
categoryList.value = await accountApi.categories()
} catch {
categoryList.value = []
}
}
// 소분류명 → 대분류명 매핑(현재 구분). 소분류가 아니면 자기 자신.
const majorRollup = computed(() => {
const list = categoryList.value.filter((c) => c.type === catType.value)
const byId = {}
list.forEach((c) => (byId[c.id] = c))
const toMajor = (name) => {
const c = list.find((x) => x.name === name)
if (c && c.parentId != null && byId[c.parentId]) return byId[c.parentId].name
return name
}
const map = {}
for (const d of catData.value) {
const major = toMajor(d.category)
map[major] = (map[major] || 0) + d.total
}
const rows = Object.entries(map).map(([category, total]) => ({ category, total })).sort((a, b) => b.total - a.total)
// 실제로 묶인 게 있을 때만(소분류 존재) 노출
const hasSub = list.some((c) => c.parentId != null) && rows.length < catData.value.length
return hasSub ? rows : []
})
function setCatType(t) { function setCatType(t) {
catType.value = t catType.value = t
loadCat() loadCat()
@@ -290,6 +318,7 @@ onMounted(async () => {
load() load()
loadMonthly() loadMonthly()
loadTrend() loadTrend()
loadCategoryList()
}) })
</script> </script>
@@ -393,6 +422,15 @@ onMounted(async () => {
</li> </li>
</ul> </ul>
</div> </div>
<div v-if="majorRollup.length" class="major-rollup">
<div class="mr-head">대분류 합계</div>
<ul class="mr-list">
<li v-for="m in majorRollup" :key="m.category">
<span class="mr-cat">{{ m.category }}</span>
<span class="mr-amt">{{ won(m.total) }}</span>
</li>
</ul>
</div>
<p v-else class="empty">{{ catType === 'EXPENSE' ? '지출' : '수입' }} 내역이 없습니다.</p> <p v-else class="empty">{{ catType === 'EXPENSE' ? '지출' : '수입' }} 내역이 없습니다.</p>
</div> </div>
@@ -760,6 +798,33 @@ h2 {
fill: var(--color-text); fill: var(--color-text);
opacity: 0.6; opacity: 0.6;
} }
.major-rollup {
margin-top: 0.8rem;
padding-top: 0.6rem;
border-top: 1px dashed var(--color-border);
}
.mr-head {
font-size: 0.78rem;
opacity: 0.6;
margin-bottom: 0.3rem;
}
.mr-list {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.1rem 1rem;
margin: 0;
padding: 0;
}
.mr-list li {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
padding: 0.1rem 0;
}
.mr-cat {
font-weight: 600;
}
.pie-legend { .pie-legend {
list-style: none; list-style: none;
width: 100%; width: 100%;
+25 -1
View File
@@ -234,6 +234,23 @@ const categoryOptions = computed(() => {
if (form.category && !names.includes(form.category)) names.unshift(form.category) if (form.category && !names.includes(form.category)) names.unshift(form.category)
return names return names
}) })
// 대분류로 그룹핑(소분류 = optgroup 옵션 / 자식없는 대분류 = 단독 옵션). 값은 분류명(소분류) 그대로.
const categoryGroups = computed(() => {
const list = categories.value.filter((c) => c.type === form.type)
const groups = []
if (form.category && !list.some((c) => c.name === form.category)) {
groups.push({ key: '_cur', label: null, options: [form.category] })
}
for (const m of list.filter((c) => c.parentId == null)) {
const children = list.filter((c) => c.parentId === m.id)
if (children.length) {
groups.push({ key: 'g' + m.id, label: m.name, options: children.map((c) => c.name) })
} else {
groups.push({ key: 'i' + m.id, label: null, options: [m.name] })
}
}
return groups
})
// 필터용 전체 분류명 (중복 제거) // 필터용 전체 분류명 (중복 제거)
const allCategoryNames = computed(() => [...new Set(categories.value.map((c) => c.name))]) const allCategoryNames = computed(() => [...new Set(categories.value.map((c) => c.name))])
@@ -811,7 +828,14 @@ onMounted(async () => {
<div v-if="!addingCategory" class="cat-input"> <div v-if="!addingCategory" class="cat-input">
<select v-model="form.category" :disabled="submitting"> <select v-model="form.category" :disabled="submitting">
<option value="">(선택)</option> <option value="">(선택)</option>
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option> <template v-for="g in categoryGroups" :key="g.key">
<optgroup v-if="g.label" :label="g.label">
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
</optgroup>
<template v-else>
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
</template>
</template>
</select> </select>
<button type="button" class="cat-add-btn" :disabled="submitting" @click="startAddCategory">+ 추가</button> <button type="button" class="cat-add-btn" :disabled="submitting" @click="startAddCategory">+ 추가</button>
</div> </div>
+22 -1
View File
@@ -44,6 +44,20 @@ const categoryOptions = computed(() => {
if (form.category && !names.includes(form.category)) names.unshift(form.category) if (form.category && !names.includes(form.category)) names.unshift(form.category)
return names return names
}) })
// 대분류로 그룹핑 (소분류=optgroup 옵션 / 자식없는 대분류=단독 옵션)
const categoryGroups = computed(() => {
const list = categories.value.filter((c) => c.type === 'EXPENSE')
const groups = []
if (form.category && !list.some((c) => c.name === form.category)) {
groups.push({ key: '_cur', label: null, options: [form.category] })
}
for (const m of list.filter((c) => c.parentId == null)) {
const children = list.filter((c) => c.parentId === m.id)
if (children.length) groups.push({ key: 'g' + m.id, label: m.name, options: children.map((c) => c.name) })
else groups.push({ key: 'i' + m.id, label: null, options: [m.name] })
}
return groups
})
const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`) const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`)
@@ -305,7 +319,14 @@ onMounted(() => {
<label>카테고리 <label>카테고리
<select v-model="form.category" :disabled="submitting"> <select v-model="form.category" :disabled="submitting">
<option value="">(선택)</option> <option value="">(선택)</option>
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option> <template v-for="g in categoryGroups" :key="g.key">
<optgroup v-if="g.label" :label="g.label">
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
</optgroup>
<template v-else>
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
</template>
</template>
</select> </select>
</label> </label>
+41 -6
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
@@ -13,6 +13,7 @@ const loading = ref(false)
const error = ref(null) const error = ref(null)
const activeType = ref('EXPENSE') // EXPENSE / INCOME const activeType = ref('EXPENSE') // EXPENSE / INCOME
const newName = ref('') const newName = ref('')
const newParent = ref('') // 추가 시 대분류('' = 대분류로 추가)
// categories/activeType 변경 시 현재 탭 목록 동기화 (백엔드가 sort_order 순 정렬) // categories/activeType 변경 시 현재 탭 목록 동기화 (백엔드가 sort_order 순 정렬)
function syncRows() { function syncRows() {
@@ -20,6 +21,13 @@ function syncRows() {
} }
watch([categories, activeType], syncRows, { immediate: true }) watch([categories, activeType], syncRows, { immediate: true })
// 현재 탭의 대분류(parent 없는 것) 목록 — 부모 선택 옵션용
const majors = computed(() => rows.value.filter((c) => c.parentId == null))
// 부모(대분류) 선택 옵션: 자기 자신 제외
function parentOptions(c) {
return majors.value.filter((m) => m.id !== c.id)
}
async function load() { async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
@@ -67,7 +75,7 @@ async function addCategory() {
const name = newName.value.trim() const name = newName.value.trim()
if (!name) return if (!name) return
try { try {
await accountApi.createCategory({ type: activeType.value, name }) await accountApi.createCategory({ type: activeType.value, name, parentId: newParent.value || null })
newName.value = '' newName.value = ''
await load() await load()
} catch (e) { } catch (e) {
@@ -79,7 +87,8 @@ async function saveCategory(c) {
const name = (c.name || '').trim() const name = (c.name || '').trim()
if (!name) return if (!name) return
try { try {
await accountApi.updateCategory(c.id, { type: c.type, name }) // parentId 는 항상 현재값을 함께 전송(미전송 시 대분류로 풀림)
await accountApi.updateCategory(c.id, { type: c.type, name, parentId: c.parentId || null })
await load() await load()
} catch (e) { } catch (e) {
alert(e.response?.data?.message || '수정에 실패했습니다.') alert(e.response?.data?.message || '수정에 실패했습니다.')
@@ -122,7 +131,7 @@ onBeforeUnmount(() => sortable?.destroy())
<IconBtn icon="back" title="가계부로" @click="router.push('/account')" /> <IconBtn icon="back" title="가계부로" @click="router.push('/account')" />
</div> </div>
</header> </header>
<p class="hint">내역·예산에서 선택할 분류를 관리합니다. 분류 이름을 바꾸면 기존 내역·예산에도 반영됩니다.</p> <p class="hint">내역·예산에서 선택할 분류를 관리합니다. <b>대분류 아래 소분류</b> 묶을 있고(2단계), 예산·통계는 소분류 기준입니다. 이름을 바꾸면 기존 내역·예산에도 반영됩니다.</p>
<div class="tabs"> <div class="tabs">
<button type="button" :class="{ active: activeType === 'EXPENSE' }" @click="activeType = 'EXPENSE'">지출 분류</button> <button type="button" :class="{ active: activeType === 'EXPENSE' }" @click="activeType = 'EXPENSE'">지출 분류</button>
@@ -130,18 +139,27 @@ onBeforeUnmount(() => sortable?.destroy())
</div> </div>
<form class="new-cat" @submit.prevent="addCategory"> <form class="new-cat" @submit.prevent="addCategory">
<select v-model="newParent" class="parent-sel" title="대분류 선택">
<option value="">대분류로 추가</option>
<option v-for="m in majors" :key="m.id" :value="m.id">{{ m.name }} 소분류</option>
</select>
<input v-model="newName" type="text" :placeholder="activeType === 'EXPENSE' ? '예: 식비, 교통' : '예: 급여, 용돈'" /> <input v-model="newName" type="text" :placeholder="activeType === 'EXPENSE' ? '예: 식비, 교통' : '예: 급여, 용돈'" />
<IconBtn icon="plus" title="추가" variant="primary" type="submit" /> <IconBtn icon="plus" title="추가" variant="primary" type="submit" />
</form> </form>
<p class="hint sm"> 손잡이를 끌어 순서 바꾸면 내역 추가 화면에도 순서 나옵니다.</p> <p class="hint sm"> 손잡이를 끌어 순서 변경. 행의 대분류를 바꾸면 소분류 묶입니다.</p>
<p v-if="error" class="msg error">{{ error }}</p> <p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p> <p v-if="loading" class="msg">불러오는 중...</p>
<ul v-show="!loading && rows.length" ref="listEl" class="cat-list"> <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"> <li v-for="c in rows" :key="c.id" :data-id="c.id" class="cat-row" :class="{ child: c.parentId != null }">
<span class="drag-handle" title="드래그하여 순서 변경"></span> <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)" /> <input v-model="c.name" class="cat-name" @keyup.enter="saveCategory(c)" />
<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>
<IconBtn icon="check" title="저장" @click="saveCategory(c)" /> <IconBtn icon="check" title="저장" @click="saveCategory(c)" />
<IconBtn icon="trash" title="삭제" variant="danger" @click="removeCategory(c)" /> <IconBtn icon="trash" title="삭제" variant="danger" @click="removeCategory(c)" />
</li> </li>
@@ -245,6 +263,23 @@ button.danger {
} }
.cat-name { .cat-name {
flex: 1; flex: 1;
min-width: 0;
}
.parent-sel {
max-width: 40%;
font-size: 0.85rem;
}
.new-cat .parent-sel {
flex: 0 0 auto;
}
.cat-row.child {
padding-left: 1.2rem;
}
.cat-row.child .cat-name {
background: var(--color-background-soft);
}
.sub-mark {
opacity: 0.45;
} }
.hint.sm { .hint.sm {
font-size: 0.78rem; font-size: 0.78rem;
+22 -1
View File
@@ -41,6 +41,20 @@ const categoryOptions = computed(() => {
if (form.category && !names.includes(form.category)) names.unshift(form.category) if (form.category && !names.includes(form.category)) names.unshift(form.category)
return names return names
}) })
// 대분류로 그룹핑 (소분류=optgroup 옵션 / 자식없는 대분류=단독 옵션)
const categoryGroups = computed(() => {
const list = categories.value.filter((c) => c.type === form.type)
const groups = []
if (form.category && !list.some((c) => c.name === form.category)) {
groups.push({ key: '_cur', label: null, options: [form.category] })
}
for (const m of list.filter((c) => c.parentId == null)) {
const children = list.filter((c) => c.parentId === m.id)
if (children.length) groups.push({ key: 'g' + m.id, label: m.name, options: children.map((c) => c.name) })
else groups.push({ key: 'i' + m.id, label: null, options: [m.name] })
}
return groups
})
// 계좌/카드 — 종류(계좌/카드)를 라디오로 고르고, 그 종류만 셀렉트로 노출 // 계좌/카드 — 종류(계좌/카드)를 라디오로 고르고, 그 종류만 셀렉트로 노출
const WALLET_KINDS = [ const WALLET_KINDS = [
{ value: 'BANK', label: '계좌' }, { value: 'BANK', label: '계좌' },
@@ -326,7 +340,14 @@ onMounted(load)
<label v-if="form.type !== 'TRANSFER'">분류 <label v-if="form.type !== 'TRANSFER'">분류
<select v-model="form.category" :disabled="submitting"> <select v-model="form.category" :disabled="submitting">
<option value="">(선택)</option> <option value="">(선택)</option>
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option> <template v-for="g in categoryGroups" :key="g.key">
<optgroup v-if="g.label" :label="g.label">
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
</optgroup>
<template v-else>
<option v-for="n in g.options" :key="n" :value="n">{{ n }}</option>
</template>
</template>
</select> </select>
</label> </label>
<label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label> <label>메모<input v-model="form.memo" type="text" placeholder="(선택)" :disabled="submitting" /></label>