Fix category defaults and add sheet flow
This commit is contained in:
@@ -57,3 +57,6 @@
|
||||
- 로그인 시 기본 자산/카테고리가 비어 있는 기존 계정을 자동 보정하도록 했다.
|
||||
- 설정의 자산 관리를 `+` 추가, 목록, 상세 수정/삭제 흐름으로 변경하고 활성/비활성 토글을 제거했다.
|
||||
- 거래가 연결되지 않은 자산을 삭제할 수 있는 API를 추가했다.
|
||||
- 카테고리/자산 조회 시 기본 데이터가 비어 있으면 자동 보강되도록 했다.
|
||||
- 기본 카테고리 초기값을 상위 카테고리 `기본 수입`, `기본 지출` 1개씩으로 단순화했다.
|
||||
- 카테고리/자산 추가 바텀 시트의 닫기 버튼을 제거하고 추가 실패 안내를 표시하도록 했다.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: '이마트칠성점'
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user