Refine category management flow

This commit is contained in:
2026-08-13 16:18:03 +09:00
parent 085bf0a6ee
commit ba22cac395
2 changed files with 146 additions and 54 deletions

View File

@@ -12,3 +12,6 @@
- 자산 화면은 자산 목록과 자산별 월 거래 내역으로 분리했다.
- 설정 화면은 항목 목록에서 자산/카테고리 관리 상세로 들어가는 구조로 변경했다.
- PC 화면에서는 모바일 고정 폭을 제거하고 사이드바와 넓은 콘텐츠 영역을 사용하도록 전역 레이아웃을 수정했다.
- 카테고리 관리를 수입/지출별 상위 카테고리 목록과 하위 카테고리 상세 목록 구조로 변경했다.
- 카테고리 추가는 상시 노출 입력 폼 대신 헤더의 `+` 버튼과 바텀 시트 입력으로 변경했다.
- 카테고리 관리 화면에서 활성/비활성 토글 표시를 제거했다.

View File

@@ -1,16 +1,28 @@
<template>
<section class="min-h-screen bg-paper">
<header class="sticky top-0 z-10 flex h-16 items-center gap-3 border-b border-line bg-white px-4">
<header class="sticky top-0 z-10 flex h-16 items-center justify-between border-b border-line bg-white px-4">
<div class="flex items-center gap-3">
<button
v-if="view !== 'home'"
type="button"
class="flex h-10 w-10 items-center justify-center rounded-md text-3xl leading-none active:bg-paper"
aria-label="뒤로"
@click="goBack"
>
‹
</button>
<h1 class="text-xl font-bold">{{ title }}</h1>
</div>
<button
v-if="view !== 'home'"
v-if="showAddButton"
type="button"
class="flex h-10 w-10 items-center justify-center rounded-md text-3xl leading-none active:bg-paper"
aria-label="뒤로"
@click="view = 'home'"
class="flex h-10 w-10 items-center justify-center rounded-md text-3xl leading-none text-brand-700 active:bg-paper"
aria-label="추가"
@click="openAddSheet"
>
‹
+
</button>
<h1 class="text-xl font-bold">{{ title }}</h1>
</header>
<div v-if="view === 'home'">
@@ -84,47 +96,91 @@
</div>
</div>
<div v-else class="px-4 py-4">
<form class="space-y-3 border-b border-line pb-5" @submit.prevent="createCategory">
<div v-else class="py-4">
<div v-if="!selectedParentCategory" class="px-4">
<div class="segmented">
<button type="button" class="segmented-button" :class="{ 'segmented-button-active': categoryForm.type === 'income' }" @click="categoryForm.type = 'income'">수입</button>
<button type="button" class="segmented-button" :class="{ 'segmented-button-active': categoryForm.type === 'expense' }" @click="categoryForm.type = 'expense'">지출</button>
</div>
<BottomSheetSelect
v-model="categoryForm.parentId"
title="상위 분류 선택"
placeholder="대분류로 만들기"
:options="parentSheetOptions"
/>
<input v-model="categoryForm.name" class="field border-x-0 border-t-0 px-0" placeholder="카테고리 이름" required>
<button class="btn-primary w-full" type="submit">카테고리 추가</button>
</form>
<div class="mt-4 divide-y divide-line border-y border-line bg-white">
<div v-for="category in categories" :key="category.id" class="flex items-center justify-between gap-3 py-4">
<div>
<p class="font-semibold">{{ category.parent ? `${category.parent.name} > ${category.name}` : category.name }}</p>
<p class="mt-1 text-xs text-muted">
{{ category.type === 'income' ? '수입' : '지출' }} · {{ category.active ? '활성' : '비활성' }}
</p>
</div>
<ToggleSwitch :model-value="category.active" @update:model-value="toggleCategory(category, $event)" />
<button
type="button"
class="segmented-button"
:class="{ 'segmented-button-active': categoryType === 'income' }"
@click="categoryType = 'income'"
>
수입
</button>
<button
type="button"
class="segmented-button"
:class="{ 'segmented-button-active': categoryType === 'expense' }"
@click="categoryType = 'expense'"
>
지출
</button>
</div>
</div>
<div class="mt-4 divide-y divide-line border-y border-line bg-white">
<button
v-for="category in visibleCategories"
:key="category.id"
type="button"
class="flex w-full items-center justify-between px-4 py-5 text-left active:bg-paper"
@click="selectCategory(category)"
>
<span>
<span class="block text-lg font-semibold text-ink">{{ category.name }}</span>
<span class="mt-1 block text-sm text-muted">{{ categoryDescription(category) }}</span>
</span>
<span v-if="!selectedParentCategory" class="text-2xl text-muted">›</span>
</button>
<p v-if="!visibleCategories.length" class="px-4 py-12 text-center text-muted">
{{ selectedParentCategory ? '하위 카테고리가 없습니다.' : `${categoryTypeLabel} 상위 카테고리가 없습니다.` }}
</p>
</div>
</div>
<Teleport to="body">
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
<div v-if="addSheetOpen" class="fixed inset-0 z-40 bg-ink/35" @click="addSheetOpen = false" />
</Transition>
<Transition enter-active-class="transition duration-200 ease-out" enter-from-class="translate-y-full" enter-to-class="translate-y-0" leave-active-class="transition duration-150 ease-in" leave-from-class="translate-y-0" leave-to-class="translate-y-full">
<section v-if="addSheetOpen" class="fixed inset-x-0 bottom-0 z-50 mx-auto max-w-lg rounded-t-2xl bg-white shadow-2xl">
<form class="px-4 pb-6 pt-2" @submit.prevent="createCategory">
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
<div class="mb-5 flex items-center justify-between">
<h2 class="text-lg font-bold">{{ addSheetTitle }}</h2>
<button type="button" class="text-sm font-semibold text-muted" @click="addSheetOpen = false">닫기</button>
</div>
<input
v-model="categoryName"
class="field border-x-0 border-t-0 px-0"
:placeholder="selectedParentCategory ? '하위 카테고리 이름' : '상위 카테고리 이름'"
required
autofocus
>
<button class="btn-primary mt-5 w-full" type="submit">추가하기</button>
</form>
</section>
</Transition>
</Teleport>
</section>
</template>
<script setup lang="ts">
type SettingsView = 'home' | 'assets' | 'categories'
type CategoryType = 'income' | 'expense'
const view = ref<SettingsView>('home')
const categoryType = ref<CategoryType>('expense')
const selectedParentId = ref('')
const addSheetOpen = ref(false)
const categoryName = ref('')
const { data: assetData, refresh: refreshAssets } = await useFetch('/api/assets', { query: { includeInactive: true } })
const { data: categoryData, refresh: refreshCategories } = await useFetch('/api/categories', { query: { includeInactive: true } })
const menuItems = [
{ id: 'assets', label: '자산 관리', description: '자산 추가, 잔액 계산 방식, 활성 상태' },
{ id: 'categories', label: '카테고리 관리', description: '수입/지출 분류 추가와 활성 상태' }
{ id: 'categories', label: '카테고리 관리', description: '수입/지출 상위 분류와 하위 분류' }
] as const
const assetForm = reactive({
@@ -133,31 +189,61 @@ const assetForm = reactive({
initialBalance: 0
})
const categoryForm = reactive({
type: 'expense',
parentId: '',
name: ''
})
const title = computed(() => {
if (view.value === 'assets') return '자산 관리'
if (selectedParentCategory.value) return selectedParentCategory.value.name
if (view.value === 'categories') return '카테고리 관리'
return '설정'
})
const assets = computed(() => assetData.value || [])
const categories = computed(() => categoryData.value || [])
const parentOptions = computed(() => categories.value.filter((category: any) => category.type === categoryForm.type && !category.parentId && category.active))
const parentSheetOptions = computed(() => [
{ value: '', label: '대분류로 만들기', description: '상위 분류 없이 저장합니다.' },
...parentOptions.value.map((category: any) => ({
value: category.id,
label: category.name,
description: category.type === 'income' ? '수입' : '지출'
}))
])
const selectedParentCategory = computed(() => categories.value.find((category: any) => category.id === selectedParentId.value))
const categoryTypeLabel = computed(() => categoryType.value === 'income' ? '수입' : '지출')
const visibleCategories = computed(() => {
if (selectedParentId.value) {
return categories.value
.filter((category: any) => category.parentId === selectedParentId.value)
.sort((a: any, b: any) => a.sortOrder - b.sortOrder)
}
return categories.value
.filter((category: any) => category.type === categoryType.value && !category.parentId)
.sort((a: any, b: any) => a.sortOrder - b.sortOrder)
})
const showAddButton = computed(() => view.value === 'categories')
const addSheetTitle = computed(() => selectedParentCategory.value ? `${selectedParentCategory.value.name} 하위 카테고리 추가` : `${categoryTypeLabel.value} 상위 카테고리 추가`)
watch(categoryType, () => {
selectedParentId.value = ''
})
function open(nextView: SettingsView) {
view.value = nextView
selectedParentId.value = ''
}
function goBack() {
if (selectedParentId.value) {
selectedParentId.value = ''
return
}
view.value = 'home'
}
function openAddSheet() {
categoryName.value = ''
addSheetOpen.value = true
}
function selectCategory(category: any) {
if (selectedParentId.value) return
selectedParentId.value = category.id
}
function categoryDescription(category: any) {
if (selectedParentId.value) return categoryTypeLabel.value
const childCount = categories.value.filter((item: any) => item.parentId === category.id).length
return `${childCount}개 하위 카테고리`
}
async function createAsset() {
@@ -172,13 +258,16 @@ async function toggleAsset(asset: any, active: boolean) {
}
async function createCategory() {
await $fetch('/api/categories', { method: 'POST', body: categoryForm })
Object.assign(categoryForm, { type: categoryForm.type, parentId: '', name: '' })
await refreshCategories()
}
async function toggleCategory(category: any, active: boolean) {
await $fetch(`/api/categories/${category.id}`, { method: 'PATCH', body: { active } })
await $fetch('/api/categories', {
method: 'POST',
body: {
type: categoryType.value,
parentId: selectedParentId.value || '',
name: categoryName.value
}
})
categoryName.value = ''
addSheetOpen.value = false
await refreshCategories()
}
</script>