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