diff --git a/docs/release-2026-06-06.md b/docs/release-2026-06-06.md index fd2e174..ef4cc08 100644 --- a/docs/release-2026-06-06.md +++ b/docs/release-2026-06-06.md @@ -147,6 +147,13 @@ ## 32. 계좌 관리 — 탭 한 줄 - 계좌 종류 탭(은행/현금/카드/대출/투자)이 두 줄로 줄바꿈되던 것 → **한 줄(넘치면 가로 스크롤)**. 버튼 글자 줄바꿈 방지. +## 33. 분류 — 대분류/소분류(2단계) +- **분류를 대분류 아래 소분류로 묶기**(2단계). `account_category.parent_id` 추가(멱등 마이그레이션). 기존 분류는 모두 대분류로 시작 → 분류 관리에서 소분류로 재배치. +- **소분류 이름 기반 유지**(내역·예산·고정지출·통계 무변경) — 같은 소분류 이름 중복은 불가, 예산·통계는 **소분류 기준**. +- 분류 관리: 행별 대분류 선택·소분류 들여쓰기, 드래그 정렬 유지. 자식 있는 대분류는 소분류화·삭제 차단. +- 입력/예산/고정지출 드롭다운: **대분류 optgroup** 그룹핑(값은 소분류명). +- 대시보드 통계: 파이는 소분류 기준 + 아래 **대분류 합계** 롤업 표시. + --- ## 운영 반영 시 체크 diff --git a/src/views/account/AccountDashboardView.vue b/src/views/account/AccountDashboardView.vue index 952e050..eb5057e 100644 --- a/src/views/account/AccountDashboardView.vue +++ b/src/views/account/AccountDashboardView.vue @@ -22,6 +22,7 @@ const barData = ref([]) // [{bucket, budget, expense}] const monthlyStats = ref([]) // MONTH unit of year (income/expense) const catType = ref('EXPENSE') // 분류별 파이차트 구분 const catData = ref([]) // [{category, total}] +const categoryList = ref([]) // 분류 목록(대/소분류 매핑용) const trendData = ref([]) // [{month, netWorth}] const periodLabel = computed(() => `${year.value}년 ${String(month.value).padStart(2, '0')}월`) @@ -188,6 +189,33 @@ async function loadCat() { 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) { catType.value = t loadCat() @@ -290,6 +318,7 @@ onMounted(async () => { load() loadMonthly() loadTrend() + loadCategoryList() }) @@ -393,6 +422,15 @@ onMounted(async () => { +
{{ catType === 'EXPENSE' ? '지출' : '수입' }} 내역이 없습니다.
@@ -760,6 +798,33 @@ h2 { fill: var(--color-text); 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 { list-style: none; width: 100%; diff --git a/src/views/account/AccountView.vue b/src/views/account/AccountView.vue index 99c11f5..a71c13c 100644 --- a/src/views/account/AccountView.vue +++ b/src/views/account/AccountView.vue @@ -234,6 +234,23 @@ const categoryOptions = computed(() => { if (form.category && !names.includes(form.category)) names.unshift(form.category) 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))]) @@ -811,7 +828,14 @@ onMounted(async () => {