Fix signup defaults and asset management
This commit is contained in:
@@ -53,3 +53,7 @@
|
||||
- 거래 입력/수정 화면의 상단 저장 버튼을 제거하고 하단 저장/취소 2:1 버튼 구조로 변경했다.
|
||||
- 거래 입력/수정 화면에 필수값 누락 토스트와 해당 입력 줄 포커스/강조 피드백을 추가했다.
|
||||
- 거래 입력/수정 행 스타일을 박스형 입력에서 밑줄형 입력으로 변경했다.
|
||||
- 회원가입 중 기본 데이터 생성 실패 시 계정만 생성되는 문제를 막기 위해 가입 처리를 트랜잭션으로 묶었다.
|
||||
- 로그인 시 기본 자산/카테고리가 비어 있는 기존 계정을 자동 보정하도록 했다.
|
||||
- 설정의 자산 관리를 `+` 추가, 목록, 상세 수정/삭제 흐름으로 변경하고 활성/비활성 토글을 제거했다.
|
||||
- 거래가 연결되지 않은 자산을 삭제할 수 있는 API를 추가했다.
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
- 필수값 없이 저장을 누르면 토스트로 부족한 항목을 알려주고 해당 입력 줄에 포커스와 강조선을 준다.
|
||||
- 거래 입력 행은 위아래 박스 라인 대신 각 입력란 하단 밑줄만 사용하고, 포커스 중인 줄만 포인트 색상으로 강조한다.
|
||||
- 셀렉트 박스보다 모바일 친화적인 바텀 시트를 우선한다.
|
||||
- 활성/비활성 같은 상태는 텍스트 버튼보다 토글처럼 현재 상태가 보이는 UI를 쓴다.
|
||||
- 상태를 켜고 끄는 기능이 꼭 필요한 경우에는 텍스트 버튼보다 토글처럼 현재 상태가 보이는 UI를 쓴다.
|
||||
- 자산 관리는 활성/비활성 토글을 쓰지 않고, 등록된 자산은 모두 사용하는 것으로 본다.
|
||||
- 통계 화면은 그래프보다 숫자와 분류별 리스트를 우선한다.
|
||||
- 방향 이동 UI는 `assets/icons`의 키보드 화살표 SVG를 사용한다.
|
||||
- 거래 목록의 카테고리 표시는 하위 카테고리가 있으면 하위 이름만, 없으면 상위 이름만 보여준다.
|
||||
@@ -33,7 +34,7 @@
|
||||
- PostgreSQL을 기본 DB로 사용한다.
|
||||
- Prisma migration은 저장소에 포함한다.
|
||||
- 자산 잔액은 현재값을 저장하지 않고 거래 합계로 계산한다.
|
||||
- 카테고리와 자산은 완전 삭제보다 비활성화를 우선한다.
|
||||
- 자산은 활성/비활성으로 관리하지 않는다. 삭제는 거래가 연결되지 않은 자산에 한해 제공한다.
|
||||
- 거래에 연결되는 카테고리 선택은 하위가 있으면 하위 카테고리를 우선하고, 하위가 없는 경우에만 상위 카테고리를 직접 선택한다.
|
||||
- CSV Export는 데이터 보존을 위한 필수 기능으로 유지한다.
|
||||
|
||||
|
||||
@@ -77,10 +77,15 @@ async function submit() {
|
||||
await navigateTo('/')
|
||||
} catch (err: any) {
|
||||
const status = err?.statusCode || err?.response?.status
|
||||
const statusMessage = err?.statusMessage || err?.response?._data?.statusMessage || ''
|
||||
if (mode.value === 'register' && status === 409) {
|
||||
error.value = '이미 가입된 이메일입니다.'
|
||||
} else if (mode.value === 'register' && statusMessage) {
|
||||
error.value = statusMessage === 'Invalid registration'
|
||||
? '이메일과 8자 이상 비밀번호를 확인해 주세요.'
|
||||
: '가입 처리 중 문제가 생겼습니다. 잠시 후 다시 시도해 주세요.'
|
||||
} else if (mode.value === 'register') {
|
||||
error.value = '이메일과 8자 이상 비밀번호를 확인해 주세요.'
|
||||
error.value = '가입 처리 중 문제가 생겼습니다. 잠시 후 다시 시도해 주세요.'
|
||||
} else {
|
||||
error.value = '이메일 또는 비밀번호를 확인해 주세요.'
|
||||
}
|
||||
|
||||
@@ -148,42 +148,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'assets'" class="px-4 py-4">
|
||||
<form class="space-y-3 border-b border-line pb-5" @submit.prevent="createAsset">
|
||||
<input v-model="assetForm.name" class="field" placeholder="자산 이름" required>
|
||||
<div>
|
||||
<span class="label">잔액 계산 방식</span>
|
||||
<div class="space-y-2">
|
||||
<label class="flex gap-3 rounded-md border border-line bg-white p-3">
|
||||
<input v-model="assetForm.balanceType" type="radio" value="cumulative">
|
||||
<span>
|
||||
<strong class="block">계속 누적</strong>
|
||||
<small class="text-muted">이전 달의 잔액을 계속 이어갑니다.</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex gap-3 rounded-md border border-line bg-white p-3">
|
||||
<input v-model="assetForm.balanceType" type="radio" value="monthly_reset">
|
||||
<span>
|
||||
<strong class="block">매월 새로 시작</strong>
|
||||
<small class="text-muted">매월 1일 잔액을 0원부터 계산합니다.</small>
|
||||
</span>
|
||||
</label>
|
||||
<div v-else-if="view === 'assets'" class="py-4">
|
||||
<form v-if="selectedAsset" class="px-4" @submit.prevent="saveAsset">
|
||||
<section class="space-y-5 border-y border-line bg-white px-4 py-4">
|
||||
<div class="grid grid-cols-[5rem_1fr] items-center gap-3">
|
||||
<label class="text-sm font-semibold text-muted" for="assetName">이름</label>
|
||||
<input id="assetName" v-model="assetEditForm.name" class="field border-0 bg-transparent font-semibold shadow-none focus:ring-0" required>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[5rem_1fr] items-start gap-3">
|
||||
<span class="pt-2 text-sm font-semibold text-muted">계산 방식</span>
|
||||
<div class="grid gap-2">
|
||||
<label class="flex gap-3 rounded-md border p-3" :class="assetEditForm.balanceType === 'cumulative' ? 'border-brand-600 bg-brand-50' : 'border-line bg-white'">
|
||||
<input v-model="assetEditForm.balanceType" type="radio" value="cumulative">
|
||||
<span>
|
||||
<strong class="block text-sm">계속 누적</strong>
|
||||
<small class="text-muted">이전 달 잔액을 이어갑니다.</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex gap-3 rounded-md border p-3" :class="assetEditForm.balanceType === 'monthly_reset' ? 'border-brand-600 bg-brand-50' : 'border-line bg-white'">
|
||||
<input v-model="assetEditForm.balanceType" type="radio" value="monthly_reset">
|
||||
<span>
|
||||
<strong class="block text-sm">매월 새로 시작</strong>
|
||||
<small class="text-muted">조회 월 기준으로 계산합니다.</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[5rem_1fr] items-center gap-3">
|
||||
<label class="text-sm font-semibold text-muted" for="assetInitialBalance">최초 잔액</label>
|
||||
<input id="assetInitialBalance" v-model.number="assetEditForm.initialBalance" class="field border-0 bg-transparent text-right font-semibold shadow-none focus:ring-0" inputmode="numeric" type="number">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="assetMessage" class="mt-3 text-sm font-semibold text-red-600">{{ assetMessage }}</p>
|
||||
<div class="mt-5 grid grid-cols-[2fr_1fr] gap-2">
|
||||
<button class="btn-primary" type="submit">저장하기</button>
|
||||
<button class="btn-secondary border-red-200 text-red-600" type="button" @click="deleteAsset">삭제</button>
|
||||
</div>
|
||||
<input v-model.number="assetForm.initialBalance" class="field text-right" inputmode="numeric" type="number" placeholder="최초 잔액">
|
||||
<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="asset in assets" :key="asset.id" class="flex items-center justify-between gap-3 py-3">
|
||||
<div>
|
||||
<p class="font-semibold">{{ asset.name }}</p>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ asset.balanceType === 'cumulative' ? '계속 누적' : '매월 새로 시작' }} · {{ asset.active ? '활성' : '비활성' }}
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch :model-value="asset.active" @update:model-value="toggleAsset(asset, $event)" />
|
||||
</div>
|
||||
<div v-else class="divide-y divide-line border-y border-line bg-white">
|
||||
<button
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 px-4 py-4 text-left active:bg-paper"
|
||||
@click="openAssetDetail(asset)"
|
||||
>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-base font-semibold text-ink">{{ asset.name }}</span>
|
||||
<span class="mt-1 block truncate text-xs text-muted">
|
||||
{{ asset.balanceType === 'cumulative' ? '계속 누적' : '매월 새로 시작' }}
|
||||
</span>
|
||||
</span>
|
||||
<img class="h-6 w-6 opacity-60" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
|
||||
</button>
|
||||
|
||||
<p v-if="!assets.length" class="px-4 py-12 text-center text-muted">
|
||||
등록된 자산이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -318,13 +343,41 @@
|
||||
</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">
|
||||
<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">
|
||||
<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'">
|
||||
<div class="space-y-4">
|
||||
<input v-model="assetForm.name" class="field" placeholder="자산 이름" required>
|
||||
<div>
|
||||
<span class="label">잔액 계산 방식</span>
|
||||
<div class="space-y-2">
|
||||
<label class="flex gap-3 rounded-md border border-line bg-white p-3">
|
||||
<input v-model="assetForm.balanceType" type="radio" value="cumulative">
|
||||
<span>
|
||||
<strong class="block">계속 누적</strong>
|
||||
<small class="text-muted">이전 달의 잔액을 계속 이어갑니다.</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex gap-3 rounded-md border border-line bg-white p-3">
|
||||
<input v-model="assetForm.balanceType" type="radio" value="monthly_reset">
|
||||
<span>
|
||||
<strong class="block">매월 새로 시작</strong>
|
||||
<small class="text-muted">매월 1일 잔액을 0원부터 계산합니다.</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<input v-model.number="assetForm.initialBalance" class="field text-right" inputmode="numeric" type="number" placeholder="최초 잔액">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<input
|
||||
v-else
|
||||
v-model="categoryName"
|
||||
class="field"
|
||||
:placeholder="selectedParentCategory ? '하위 카테고리 이름' : '상위 카테고리 이름'"
|
||||
@@ -350,16 +403,17 @@ const session = useState<{ id: string; email: string; name: string } | null>('se
|
||||
const categoryType = ref<CategoryType>('expense')
|
||||
const selectedParentId = ref('')
|
||||
const selectedCategoryId = ref('')
|
||||
const selectedAssetId = ref('')
|
||||
const addSheetOpen = ref(false)
|
||||
const categoryName = ref('')
|
||||
const { data: assetData, refresh: refreshAssets } = await useFetch('/api/assets', { query: { includeInactive: true } })
|
||||
const { data: assetData, refresh: refreshAssets } = await useFetch('/api/assets')
|
||||
const { data: categoryData, refresh: refreshCategories } = await useFetch('/api/categories', { query: { includeInactive: true } })
|
||||
const { data: shareLinkData, refresh: refreshShareLinks } = await useFetch('/api/share-links')
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'account', label: '계정 관리', description: '로그인 계정 확인과 로그아웃' },
|
||||
{ id: 'sharing', label: '공유 링크 관리', description: '읽기 전용 공유 링크 생성과 삭제' },
|
||||
{ id: 'assets', label: '자산 관리', description: '자산 추가, 잔액 계산 방식, 활성 상태' },
|
||||
{ id: 'assets', label: '자산 관리', description: '자산 추가, 수정, 삭제' },
|
||||
{ id: 'categories', label: '카테고리 관리', description: '수입/지출 상위 분류와 하위 분류' }
|
||||
] as const
|
||||
|
||||
@@ -368,6 +422,12 @@ const assetForm = reactive({
|
||||
balanceType: 'cumulative',
|
||||
initialBalance: 0
|
||||
})
|
||||
const assetEditForm = reactive({
|
||||
name: '',
|
||||
balanceType: 'cumulative',
|
||||
initialBalance: 0
|
||||
})
|
||||
const assetMessage = ref('')
|
||||
const shareDayOptions = [1, 7, 30]
|
||||
const shareForm = reactive({
|
||||
name: '',
|
||||
@@ -398,6 +458,7 @@ const categoryColorOptions = CATEGORY_COLOR_OPTIONS
|
||||
const title = computed(() => {
|
||||
if (view.value === 'account') return '계정 관리'
|
||||
if (view.value === 'sharing') return '공유 링크 관리'
|
||||
if (selectedAsset.value) return '자산 정보'
|
||||
if (view.value === 'assets') return '자산 관리'
|
||||
if (selectedCategory.value) return '카테고리 정보'
|
||||
if (selectedParentCategory.value) return selectedParentCategory.value.name
|
||||
@@ -407,6 +468,7 @@ const title = computed(() => {
|
||||
const assets = computed(() => assetData.value || [])
|
||||
const categories = computed(() => categoryData.value || [])
|
||||
const shareLinks = computed(() => shareLinkData.value || [])
|
||||
const selectedAsset = computed(() => assets.value.find((asset: any) => asset.id === selectedAssetId.value))
|
||||
const selectedParentCategory = computed(() => categories.value.find((category: any) => category.id === selectedParentId.value))
|
||||
const selectedCategory = computed(() => categories.value.find((category: any) => category.id === selectedCategoryId.value))
|
||||
const categoryTypeLabel = computed(() => categoryType.value === 'income' ? '수입' : '지출')
|
||||
@@ -421,8 +483,11 @@ 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 === 'categories')
|
||||
const addSheetTitle = computed(() => selectedParentCategory.value ? `${selectedParentCategory.value.name} 하위 카테고리 추가` : `${categoryTypeLabel.value} 상위 카테고리 추가`)
|
||||
const showAddButton = computed(() => (view.value === 'assets' && !selectedAsset.value) || view.value === 'categories')
|
||||
const addSheetTitle = computed(() => {
|
||||
if (view.value === 'assets') return '자산 추가'
|
||||
return selectedParentCategory.value ? `${selectedParentCategory.value.name} 하위 카테고리 추가` : `${categoryTypeLabel.value} 상위 카테고리 추가`
|
||||
})
|
||||
|
||||
watch(categoryType, () => {
|
||||
selectedParentId.value = ''
|
||||
@@ -438,10 +503,22 @@ watch(selectedCategory, (category: any) => {
|
||||
})
|
||||
})
|
||||
|
||||
watch(selectedAsset, (asset: any) => {
|
||||
if (!asset) return
|
||||
Object.assign(assetEditForm, {
|
||||
name: asset.name,
|
||||
balanceType: asset.balanceType,
|
||||
initialBalance: asset.initialBalance || 0
|
||||
})
|
||||
assetMessage.value = ''
|
||||
})
|
||||
|
||||
function open(nextView: SettingsView) {
|
||||
view.value = nextView
|
||||
selectedParentId.value = ''
|
||||
selectedCategoryId.value = ''
|
||||
selectedAssetId.value = ''
|
||||
assetMessage.value = ''
|
||||
if (nextView === 'account') {
|
||||
accountForm.name = session.value?.name || ''
|
||||
accountMessage.value = ''
|
||||
@@ -450,6 +527,10 @@ function open(nextView: SettingsView) {
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (selectedAssetId.value) {
|
||||
selectedAssetId.value = ''
|
||||
return
|
||||
}
|
||||
if (selectedCategoryId.value) {
|
||||
selectedCategoryId.value = ''
|
||||
return
|
||||
@@ -463,9 +544,19 @@ function goBack() {
|
||||
|
||||
function openAddSheet() {
|
||||
categoryName.value = ''
|
||||
Object.assign(assetForm, { name: '', balanceType: 'cumulative', initialBalance: 0 })
|
||||
addSheetOpen.value = true
|
||||
}
|
||||
|
||||
function submitAddSheet() {
|
||||
if (view.value === 'assets') return createAsset()
|
||||
return createCategory()
|
||||
}
|
||||
|
||||
function openAssetDetail(asset: any) {
|
||||
selectedAssetId.value = asset.id
|
||||
}
|
||||
|
||||
function selectCategory(category: any) {
|
||||
if (selectedParentId.value) {
|
||||
openCategoryDetail(category)
|
||||
@@ -499,12 +590,33 @@ 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()
|
||||
}
|
||||
|
||||
async function toggleAsset(asset: any, active: boolean) {
|
||||
await $fetch(`/api/assets/${asset.id}`, { method: 'PATCH', body: { active } })
|
||||
async function saveAsset() {
|
||||
if (!selectedAsset.value) return
|
||||
await $fetch(`/api/assets/${selectedAsset.value.id}`, {
|
||||
method: 'PATCH',
|
||||
body: assetEditForm
|
||||
})
|
||||
await refreshAssets()
|
||||
selectedAssetId.value = ''
|
||||
}
|
||||
|
||||
async function deleteAsset() {
|
||||
if (!selectedAsset.value) return
|
||||
assetMessage.value = ''
|
||||
try {
|
||||
await $fetch(`/api/assets/${selectedAsset.value.id}`, { method: 'DELETE' })
|
||||
await refreshAssets()
|
||||
selectedAssetId.value = ''
|
||||
} catch (err: any) {
|
||||
const status = err?.statusCode || err?.response?.status
|
||||
assetMessage.value = status === 409
|
||||
? '거래가 연결된 자산은 삭제할 수 없습니다.'
|
||||
: '자산을 삭제하지 못했습니다.'
|
||||
}
|
||||
}
|
||||
|
||||
async function createCategory() {
|
||||
|
||||
19
server/api/assets/[id].delete.ts
Normal file
19
server/api/assets/[id].delete.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { getRouterParam, createError } from 'h3'
|
||||
import { prisma } from '../../utils/db'
|
||||
import { requireUser } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event)
|
||||
const id = getRouterParam(event, 'id')!
|
||||
|
||||
const asset = await prisma.asset.findFirst({ where: { id, userId: user.id } })
|
||||
if (!asset) throw createError({ statusCode: 404, statusMessage: 'Asset not found' })
|
||||
|
||||
const transactionCount = await prisma.transaction.count({ where: { assetId: id, userId: user.id } })
|
||||
if (transactionCount > 0) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'Asset has transactions' })
|
||||
}
|
||||
|
||||
await prisma.asset.delete({ where: { id } })
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -9,6 +9,9 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const asset = await prisma.asset.findFirst({ where: { id, userId: user.id } })
|
||||
if (!asset) throw createError({ statusCode: 404, statusMessage: 'Asset not found' })
|
||||
if (body.name !== undefined && !body.name.trim()) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Asset name is required' })
|
||||
}
|
||||
|
||||
return prisma.asset.update({
|
||||
where: { id },
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { readBody } from 'h3'
|
||||
import { readBody, createError } from 'h3'
|
||||
import { prisma } from '../../utils/db'
|
||||
import { requireUser } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event)
|
||||
const body = await readBody<{ name: string; balanceType: 'cumulative' | 'monthly_reset'; initialBalance?: number }>(event)
|
||||
const name = body.name?.trim()
|
||||
if (!name) throw createError({ statusCode: 400, statusMessage: 'Asset name is required' })
|
||||
|
||||
const count = await prisma.asset.count({ where: { userId: user.id } })
|
||||
|
||||
return prisma.asset.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: body.name.trim(),
|
||||
name,
|
||||
balanceType: body.balanceType,
|
||||
initialBalance: body.balanceType === 'cumulative' ? Number(body.initialBalance || 0) : 0,
|
||||
sortOrder: count
|
||||
|
||||
@@ -2,6 +2,7 @@ import { compare } from 'bcryptjs'
|
||||
import { readBody, createError } from 'h3'
|
||||
import { prisma } from '../../utils/db'
|
||||
import { setSession } from '../../utils/auth'
|
||||
import { ensureDefaultMoneyData } from '../../utils/defaults'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{ email: string; password: string }>(event)
|
||||
@@ -11,6 +12,7 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
await ensureDefaultMoneyData(prisma, user.id)
|
||||
setSession(event, user.id)
|
||||
return { id: user.id, email: user.email, name: user.name }
|
||||
})
|
||||
|
||||
@@ -19,15 +19,18 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 409, statusMessage: 'Email already exists' })
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
passwordHash: await hash(password, 10)
|
||||
}
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
passwordHash: await hash(password, 10)
|
||||
}
|
||||
})
|
||||
await createDefaultMoneyData(tx, created.id)
|
||||
return created
|
||||
})
|
||||
|
||||
await createDefaultMoneyData(prisma, user.id)
|
||||
setSession(event, user.id)
|
||||
|
||||
return { id: user.id, email: user.email, name: user.name }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||
|
||||
export async function createDefaultMoneyData(prisma: PrismaClient, userId: string) {
|
||||
type MoneyDataClient = PrismaClient | Prisma.TransactionClient
|
||||
|
||||
export async function createDefaultMoneyData(prisma: MoneyDataClient, userId: string) {
|
||||
const cash = await prisma.asset.create({
|
||||
data: {
|
||||
userId,
|
||||
@@ -34,3 +36,13 @@ export async function createDefaultMoneyData(prisma: PrismaClient, userId: strin
|
||||
|
||||
return { cash }
|
||||
}
|
||||
|
||||
export async function ensureDefaultMoneyData(prisma: MoneyDataClient, userId: string) {
|
||||
const [assetCount, categoryCount] = await Promise.all([
|
||||
prisma.asset.count({ where: { userId } }),
|
||||
prisma.category.count({ where: { userId } })
|
||||
])
|
||||
|
||||
if (assetCount || categoryCount) return
|
||||
await createDefaultMoneyData(prisma, userId)
|
||||
}
|
||||
|
||||
3
todo.md
3
todo.md
@@ -36,8 +36,6 @@
|
||||
- 자산 목록 화면의 PC/모바일 레이아웃을 다듬는다.
|
||||
- 자산 상세 화면의 월 이동 헤더를 공통 구조에 맞춘다.
|
||||
- 자산 상세에서 일별/월별/연별 보기 전환을 구현한다.
|
||||
- 자산 추가/수정 화면을 별도 상세 화면 또는 바텀 시트로 정리한다.
|
||||
- 자산 삭제 또는 숨김 처리 규칙을 정한다.
|
||||
|
||||
## 6. 통계
|
||||
|
||||
@@ -50,7 +48,6 @@
|
||||
## 7. 설정
|
||||
|
||||
- 설정 홈의 항목 구성을 다시 정리한다.
|
||||
- 자산 관리 화면을 추가/수정/삭제가 명확한 상세 구조로 바꾼다.
|
||||
- 주 화폐, 월 시작일, 주간 시작일 같은 기본 설정 항목을 구현한다.
|
||||
- 설정 변경이 실제 화면 계산과 표시 방식에 반영되도록 연결한다.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user