Fix category defaults and add sheet flow

This commit is contained in:
2026-08-13 18:37:02 +09:00
parent 5daf2d3925
commit a984a6b9b9
6 changed files with 87 additions and 47 deletions

View File

@@ -57,3 +57,6 @@
- 로그인 시 기본 자산/카테고리가 비어 있는 기존 계정을 자동 보정하도록 했다.
- 설정의 자산 관리를 `+` 추가, 목록, 상세 수정/삭제 흐름으로 변경하고 활성/비활성 토글을 제거했다.
- 거래가 연결되지 않은 자산을 삭제할 수 있는 API를 추가했다.
- 카테고리/자산 조회 시 기본 데이터가 비어 있으면 자동 보강되도록 했다.
- 기본 카테고리 초기값을 상위 카테고리 `기본 수입`, `기본 지출` 1개씩으로 단순화했다.
- 카테고리/자산 추가 바텀 시트의 닫기 버튼을 제거하고 추가 실패 안내를 표시하도록 했다.

View File

@@ -339,15 +339,14 @@
<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" />
<div v-if="addSheetOpen" class="fixed inset-0 z-40 bg-ink/35" @click="closeAddSheet" />
</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="submitAddSheet">
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
<div class="mb-5 flex items-center justify-between">
<div class="mb-5">
<h2 class="text-lg font-bold">{{ addSheetTitle }}</h2>
<button type="button" class="text-sm font-semibold text-muted" @click="addSheetOpen = false">닫기</button>
</div>
<template v-if="view === 'assets'">
@@ -383,6 +382,7 @@
:placeholder="selectedParentCategory ? '하위 카테고리 이름' : '상위 카테고리 이름'"
required
>
<p v-if="addSheetError" class="mt-3 text-sm font-semibold text-red-600">{{ addSheetError }}</p>
<button class="btn-primary mt-5 w-full" type="submit">추가하기</button>
</form>
</section>
@@ -405,9 +405,10 @@ const selectedParentId = ref('')
const selectedCategoryId = ref('')
const selectedAssetId = ref('')
const addSheetOpen = ref(false)
const addSheetError = ref('')
const categoryName = ref('')
const { data: assetData, refresh: refreshAssets } = await useFetch('/api/assets')
const { data: categoryData, refresh: refreshCategories } = await useFetch('/api/categories', { query: { includeInactive: true } })
const { data: categoryData, refresh: refreshCategories } = await useFetch('/api/categories')
const { data: shareLinkData, refresh: refreshShareLinks } = await useFetch('/api/share-links')
const menuItems = [
@@ -483,7 +484,7 @@ const visibleCategories = computed(() => {
.filter((category: any) => category.type === categoryType.value && !category.parentId)
.sort((a: any, b: any) => a.sortOrder - b.sortOrder)
})
const showAddButton = computed(() => (view.value === 'assets' && !selectedAsset.value) || view.value === 'categories')
const showAddButton = computed(() => (view.value === 'assets' && !selectedAsset.value) || (view.value === 'categories' && !selectedCategory.value))
const addSheetTitle = computed(() => {
if (view.value === 'assets') return '자산 추가'
return selectedParentCategory.value ? `${selectedParentCategory.value.name} 하위 카테고리 추가` : `${categoryTypeLabel.value} 상위 카테고리 추가`
@@ -544,10 +545,16 @@ function goBack() {
function openAddSheet() {
categoryName.value = ''
addSheetError.value = ''
Object.assign(assetForm, { name: '', balanceType: 'cumulative', initialBalance: 0 })
addSheetOpen.value = true
}
function closeAddSheet() {
addSheetOpen.value = false
addSheetError.value = ''
}
function submitAddSheet() {
if (view.value === 'assets') return createAsset()
return createCategory()
@@ -588,10 +595,19 @@ function categoryStyle(category: any) {
}
async function createAsset() {
await $fetch('/api/assets', { method: 'POST', body: assetForm })
Object.assign(assetForm, { name: '', balanceType: 'cumulative', initialBalance: 0 })
addSheetOpen.value = false
await refreshAssets()
addSheetError.value = ''
if (!assetForm.name.trim()) {
addSheetError.value = '자산 이름을 입력해 주세요.'
return
}
try {
await $fetch('/api/assets', { method: 'POST', body: assetForm })
Object.assign(assetForm, { name: '', balanceType: 'cumulative', initialBalance: 0 })
closeAddSheet()
await refreshAssets()
} catch {
addSheetError.value = '자산을 추가하지 못했습니다.'
}
}
async function saveAsset() {
@@ -620,19 +636,28 @@ async function deleteAsset() {
}
async function createCategory() {
await $fetch('/api/categories', {
method: 'POST',
body: {
type: categoryType.value,
parentId: selectedParentId.value || '',
name: categoryName.value,
icon: selectedParentCategory.value?.icon || 'circle',
color: selectedParentCategory.value?.color || '#6b7673'
}
})
categoryName.value = ''
addSheetOpen.value = false
await refreshCategories()
addSheetError.value = ''
if (!categoryName.value.trim()) {
addSheetError.value = '카테고리 이름을 입력해 주세요.'
return
}
try {
await $fetch('/api/categories', {
method: 'POST',
body: {
type: categoryType.value,
parentId: selectedParentId.value || undefined,
name: categoryName.value,
icon: selectedParentCategory.value?.icon || (categoryType.value === 'income' ? 'salary' : 'shopping'),
color: selectedParentCategory.value?.color || (categoryType.value === 'income' ? '#2563eb' : '#ef4444')
}
})
categoryName.value = ''
closeAddSheet()
await refreshCategories()
} catch {
addSheetError.value = '카테고리를 추가하지 못했습니다.'
}
}
async function saveCategory() {

View File

@@ -24,8 +24,8 @@ async function main() {
const transactionCount = await prisma.transaction.count({ where: { userId: user.id } })
if (transactionCount === 0) {
const cash = await prisma.asset.findFirstOrThrow({ where: { userId: user.id }, orderBy: { sortOrder: 'asc' } })
const salary = await prisma.category.findFirstOrThrow({ where: { userId: user.id, type: 'income', name: '월급' } })
const meal = await prisma.category.findFirstOrThrow({ where: { userId: user.id, type: 'expense', name: '외식' } })
const income = await prisma.category.findFirstOrThrow({ where: { userId: user.id, type: 'income', name: '기본 수입' } })
const expense = await prisma.category.findFirstOrThrow({ where: { userId: user.id, type: 'expense', name: '기본 지출' } })
await prisma.transaction.createMany({
data: [
{
@@ -33,7 +33,7 @@ async function main() {
type: 'income',
occurredAt: new Date(),
assetId: cash.id,
categoryId: salary.id,
categoryId: income.id,
amount: 3200000,
title: '월급'
},
@@ -42,7 +42,7 @@ async function main() {
type: 'expense',
occurredAt: new Date(),
assetId: cash.id,
categoryId: meal.id,
categoryId: expense.id,
amount: 14980,
title: '이마트칠성점'
}

View File

@@ -2,9 +2,11 @@ import { getQuery } from 'h3'
import { startOfMonth, endOfMonth } from 'date-fns'
import { prisma } from '../../utils/db'
import { requireUser } from '../../utils/auth'
import { ensureDefaultMoneyData } from '../../utils/defaults'
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
await ensureDefaultMoneyData(prisma, user.id)
const query = getQuery(event)
const includeInactive = query.includeInactive === 'true'
const month = typeof query.month === 'string' ? query.month : new Date().toISOString().slice(0, 7)

View File

@@ -1,9 +1,11 @@
import { getQuery } from 'h3'
import { prisma } from '../../utils/db'
import { requireUser } from '../../utils/auth'
import { ensureDefaultMoneyData } from '../../utils/defaults'
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
await ensureDefaultMoneyData(prisma, user.id)
const query = getQuery(event)
const includeInactive = query.includeInactive === 'true'

View File

@@ -13,36 +13,44 @@ export async function createDefaultMoneyData(prisma: MoneyDataClient, userId: st
}
})
const incomeParent = await prisma.category.create({
data: { userId, type: 'income', name: '근로', icon: 'work', color: '#2563eb', sortOrder: 0 }
await prisma.category.create({
data: { userId, type: 'income', name: '기본 수입', icon: 'salary', color: '#2563eb', sortOrder: 0 }
})
await prisma.category.create({
data: { userId, type: 'income', name: '월급', icon: 'salary', color: '#2563eb', parentId: incomeParent.id, sortOrder: 0 }
})
const foodParent = await prisma.category.create({
data: { userId, type: 'expense', name: '식비', icon: 'food', color: '#f97316', sortOrder: 0 }
})
await prisma.category.create({
data: { userId, type: 'expense', name: '외식', icon: 'food', color: '#f97316', parentId: foodParent.id, sortOrder: 0 }
})
const livingParent = await prisma.category.create({
data: { userId, type: 'expense', name: '생활', icon: 'shopping', color: '#ef4444', sortOrder: 1 }
})
await prisma.category.create({
data: { userId, type: 'expense', name: '마트', icon: 'shopping', color: '#ef4444', parentId: livingParent.id, sortOrder: 0 }
data: { userId, type: 'expense', name: '기본 지출', icon: 'shopping', color: '#ef4444', sortOrder: 0 }
})
return { cash }
}
export async function ensureDefaultMoneyData(prisma: MoneyDataClient, userId: string) {
const [assetCount, categoryCount] = await Promise.all([
const [assetCount, incomeTopCount, expenseTopCount] = await Promise.all([
prisma.asset.count({ where: { userId } }),
prisma.category.count({ where: { userId } })
prisma.category.count({ where: { userId, type: 'income', parentId: null } }),
prisma.category.count({ where: { userId, type: 'expense', parentId: null } })
])
if (assetCount || categoryCount) return
await createDefaultMoneyData(prisma, userId)
if (!assetCount) {
await prisma.asset.create({
data: {
userId,
name: '기본자산',
balanceType: 'cumulative',
initialBalance: 0,
sortOrder: 0
}
})
}
if (!incomeTopCount) {
await prisma.category.create({
data: { userId, type: 'income', name: '기본 수입', icon: 'salary', color: '#2563eb', sortOrder: 0 }
})
}
if (!expenseTopCount) {
await prisma.category.create({
data: { userId, type: 'expense', name: '기본 지출', icon: 'shopping', color: '#ef4444', sortOrder: 0 }
})
}
}